From 11a1eec996cc6ef14f6145b4f275aa44fbe79b12 Mon Sep 17 00:00:00 2001 From: Eric Law <39393654+acn-ericlaw@users.noreply.github.com> Date: Tue, 1 Sep 2026 11:52:46 -0700 Subject: [PATCH] =?UTF-8?q?feat(memory):=20v4.39.0=20=E2=80=94=20merge-sca?= =?UTF-8?q?le=20layout:=20one=20Open=20Thread=20per=20file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Threads move out of continuity.md into memory/open-threads/thread-.md (filename = fact id; the directory is the index), last_session is dropped (derived from the newest session log), archive files union-merge, reviews run serialized. All three built-in script pairs treat thread files as fact surfaces; new lint checks [thread-file], [duplicate-id], and [duplicate-state-key] (absorbed from PR #27). Contract test pins the merge behavior with real git merges, incl. the preserved same-thread conflict (the Tier 2 human gate) and a mutation check. Dogfooded: this repo's own 35 threads migrated. Design record: docs/DESIGN-merge-scale.md. Co-Authored-By: Claude Fable 5 Co-Authored-By: Roland Heusser --- .gitattributes | 8 + CHANGELOG.md | 35 +++ CLAUDE.md | 4 +- DECAY.md | 22 +- ENABLE.md | 42 +-- GEMINI.md | 4 +- MANIFEST.md | 7 + MERGE.md | 29 +- MIGRATE.md | 5 +- README.md | 2 +- REVIEW.md | 31 +- UPGRADE.md | 57 ++++ VERSION | 2 +- agent-skills/archive-fact/SKILL.md | 5 +- .../archive-fact/scripts/archive-fact.mjs | 49 ++- .../archive-fact/scripts/archive-fact.py | 63 +++- .../scripts/test_archive_fact.mjs | 55 +++- .../archive-fact/scripts/test_archive_fact.py | 53 ++++ agent-skills/memory-lint/SKILL.md | 12 +- .../memory-lint/scripts/memory-lint.mjs | 150 +++++++++- .../memory-lint/scripts/memory-lint.py | 129 +++++++- .../memory-lint/scripts/test_memory_lint.mjs | 172 +++++++++++ .../memory-lint/scripts/test_memory_lint.py | 138 ++++++++- agent-skills/refresh-metadata/SKILL.md | 7 +- .../scripts/refresh-metadata.mjs | 38 ++- .../scripts/refresh-metadata.py | 33 ++- .../scripts/test_refresh_metadata.mjs | 57 ++++ .../scripts/test_refresh_metadata.py | 55 ++++ docs/DESIGN-merge-scale.md | 94 ++++++ examples/evolving-memory-example/README.md | 8 + .../ENABLE_OUTPUT.md | 8 + examples/rust-event-bus/ENABLE_OUTPUT.md | 8 + memory/PROTOCOL.md | 26 +- memory/continuity.md | 279 +----------------- .../thread-backlog-real-work-dogfood.md | 5 + memory/open-threads/thread-bp-greenfield.md | 3 + memory/open-threads/thread-bp-multi-user.md | 8 + .../thread-bp-reconcile-target-state.md | 8 + memory/open-threads/thread-bp-sdlc-overlay.md | 7 + .../thread-hook-dispatchers-v4350.md | 6 + .../open-threads/thread-merge-scale-v4390.md | 10 + .../thread-onboarding-efficiency-v4380.md | 8 + ...read-ot-agents-native-activation-canary.md | 9 + ...d-ot-before-session-cursor-kiro-backlog.md | 14 + .../thread-ot-bitbucket-forge-backlog.md | 21 ++ .../open-threads/thread-ot-claude-md-sync.md | 4 + .../thread-ot-disable-protocol.md | 2 + .../thread-ot-dogfood-backfill-optional.md | 6 + .../thread-ot-enterprise-github-migration.md | 3 + .../thread-ot-example-continue-migration.md | 2 + .../thread-ot-examples-mode-b-fixture.md | 3 + .../thread-ot-protocol-text-propagation.md | 12 + .../thread-ot-publish-github-done.md | 6 + .../thread-ot-rerun-industry-assessment.md | 4 + .../thread-ot-reverify-invariants-20260814.md | 6 + .../thread-ot-surrealdb-set-aside.md | 7 + .../thread-ot-test-claude-jsonl-migration.md | 2 + .../thread-ot-test-continue-migration.md | 2 + .../thread-ot-test-cursor-aider-migration.md | 2 + .../thread-pre-commit-secret-guard-v4340.md | 7 + ...-protocol-pointer-enterprise-activation.md | 8 + .../thread-secret-fp-backtick-v4332.md | 7 + .../thread-secret-fp-enum-constants-v4331.md | 6 + .../thread-secret-fp-guidance-ident-v4381.md | 9 + .../thread-secret-fp-self-knob-v4342.md | 7 + .../thread-secret-redaction-lint-v4330.md | 8 + .../thread-secret-review-hardening-v4333.md | 6 + .../thread-secret-template-default-v4334.md | 5 + .../thread-secret-test-fixtures-env.md | 6 + .../thread-vbdi-lifecycle-direction.md | 20 ++ memory/sessions/2026-09-01-185108.md | 79 +++++ memory/smoke-test.md | 2 +- mkdocs.yml | 1 + templates/.agent/schema.md | 87 ++++-- templates/.gitattributes | 8 + templates/CLAUDE.md | 4 +- templates/GEMINI.md | 4 +- templates/memory/PROTOCOL.md | 26 +- templates/memory/continuity.md | 11 +- templates/memory/open-threads/.gitkeep | 0 templates/memory/smoke-test.md | 2 +- tests/test_thread_layout_merge.sh | 102 +++++++ 82 files changed, 1831 insertions(+), 431 deletions(-) create mode 100644 docs/DESIGN-merge-scale.md create mode 100644 memory/open-threads/thread-backlog-real-work-dogfood.md create mode 100644 memory/open-threads/thread-bp-greenfield.md create mode 100644 memory/open-threads/thread-bp-multi-user.md create mode 100644 memory/open-threads/thread-bp-reconcile-target-state.md create mode 100644 memory/open-threads/thread-bp-sdlc-overlay.md create mode 100644 memory/open-threads/thread-hook-dispatchers-v4350.md create mode 100644 memory/open-threads/thread-merge-scale-v4390.md create mode 100644 memory/open-threads/thread-onboarding-efficiency-v4380.md create mode 100644 memory/open-threads/thread-ot-agents-native-activation-canary.md create mode 100644 memory/open-threads/thread-ot-before-session-cursor-kiro-backlog.md create mode 100644 memory/open-threads/thread-ot-bitbucket-forge-backlog.md create mode 100644 memory/open-threads/thread-ot-claude-md-sync.md create mode 100644 memory/open-threads/thread-ot-disable-protocol.md create mode 100644 memory/open-threads/thread-ot-dogfood-backfill-optional.md create mode 100644 memory/open-threads/thread-ot-enterprise-github-migration.md create mode 100644 memory/open-threads/thread-ot-example-continue-migration.md create mode 100644 memory/open-threads/thread-ot-examples-mode-b-fixture.md create mode 100644 memory/open-threads/thread-ot-protocol-text-propagation.md create mode 100644 memory/open-threads/thread-ot-publish-github-done.md create mode 100644 memory/open-threads/thread-ot-rerun-industry-assessment.md create mode 100644 memory/open-threads/thread-ot-reverify-invariants-20260814.md create mode 100644 memory/open-threads/thread-ot-surrealdb-set-aside.md create mode 100644 memory/open-threads/thread-ot-test-claude-jsonl-migration.md create mode 100644 memory/open-threads/thread-ot-test-continue-migration.md create mode 100644 memory/open-threads/thread-ot-test-cursor-aider-migration.md create mode 100644 memory/open-threads/thread-pre-commit-secret-guard-v4340.md create mode 100644 memory/open-threads/thread-protocol-pointer-enterprise-activation.md create mode 100644 memory/open-threads/thread-secret-fp-backtick-v4332.md create mode 100644 memory/open-threads/thread-secret-fp-enum-constants-v4331.md create mode 100644 memory/open-threads/thread-secret-fp-guidance-ident-v4381.md create mode 100644 memory/open-threads/thread-secret-fp-self-knob-v4342.md create mode 100644 memory/open-threads/thread-secret-redaction-lint-v4330.md create mode 100644 memory/open-threads/thread-secret-review-hardening-v4333.md create mode 100644 memory/open-threads/thread-secret-template-default-v4334.md create mode 100644 memory/open-threads/thread-secret-test-fixtures-env.md create mode 100644 memory/open-threads/thread-vbdi-lifecycle-direction.md create mode 100644 memory/sessions/2026-09-01-185108.md create mode 100644 templates/memory/open-threads/.gitkeep create mode 100755 tests/test_thread_layout_merge.sh diff --git a/.gitattributes b/.gitattributes index dd1a726..d233c83 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,3 +4,11 @@ *.sh text eol=lf .githooks/* text eol=lf .githooks/*.d/* text eol=lf + +# agent-memory — archive files are append-mostly cold storage (review sweeps append at +# EOF), so let git union-merge them instead of raising spurious add/add conflicts +# (v4.39.0). Safe here because appends dominate; the one wrong case — a reactivation's +# removed lines resurrected by the other branch — is caught deterministically by +# memory-lint ([both] / [over-archived] ERRORs). Live memory files (continuity.md, +# open-threads/) deliberately do NOT get union: there a conflict is signal (MERGE.md). +memory/archive/*.md merge=union diff --git a/CHANGELOG.md b/CHANGELOG.md index 1571b3f..2f52b12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 > introduced after 3.0.0 shipped), organized by capability rather than by individual > commit. The capability ladder matches `VERSION` and `UPGRADE.md`. +## Version 4.39.0, 9/1/2026 + +> **Merge-scale memory: threads as files (MINOR).** As team adoption grew, `memory/continuity.md` +> merge conflicts became regular business — concentrated in `## Open Threads` (multi-line blocks +> appended and edited at one anchor) and the `last_session` scalar (bumped by nearly every +> session). This release removes those conflict surfaces structurally, with no merge-time +> machinery (design record: `docs/DESIGN-merge-scale.md`). It closes part of the `bp-multi-user` +> Blueprint gap: simultaneous contributors, hardened rather than assumed. +> +> - **One thread per file** — `memory/open-threads/thread-.md`; filename = the thread's kebab +> fact id, content = exactly the bullet block that previously sat in continuity. Parallel work +> on *different* threads cannot conflict (different files); both sides editing the *same* +> thread conflicts per-file — the `MERGE.md` Tier 2 human gate, preserved by design. No index +> file: the directory is the index, like `sessions/`. Decay/pinning/supersession rules are +> unchanged; only the location moved. +> - **`last_session` dropped** from Project State — fully derivable (newest `sessions/` filename; +> the agent name is that log's `**Agent:**` header) and the most frequent scalar conflict. +> - **Archive files union-merge** — `memory/archive/*.md merge=union` in `.gitattributes` +> (git-native, no driver, no per-clone registration); the one wrong case (a reactivation's +> removal resurrected) is caught deterministically by `[both]` / `[over-archived]`. +> - **Reviews run serialized** (`REVIEW.md`): up-to-date default branch, committed promptly. +> - **Scripts:** `memory-lint`, `refresh-metadata`, and `archive-fact` treat thread files as +> fact surfaces (refresh edits footers in place — filenames never churn; the sweep moves a +> thread's block to the quarter archive and deletes the file). New lint checks: +> `[thread-file]` (filename = footer id; one block per file), `[duplicate-id]` (an id exists +> exactly once across the live layer — the silent-fork backstop), and `[duplicate-state-key]` +> (a Project State scalar set twice — **absorbed from PR #27, credit: Roland Heusser**, whose +> field artifact and merge-rehearsal test pattern also shaped this release; the PR's merge +> driver itself was closed after review findings, recorded in the design doc). +> - `tests/test_thread_layout_merge.sh` pins the merge contract with real git merges — 4 cases +> including a mutation check (stripping the union attribute must restore the conflict). +> - Suites: memory-lint 67 ×2, refresh-metadata 6 ×2, archive-fact 9 ×2, thread-layout merge 4. +> - Protocol text changed (open-threads read/create steps; `last_session` derivation) → ships +> the mandated Semantic steps row, plus the thread-migration semantic step for installed repos. + ## Version 4.38.1, 8/25/2026 > **Scanner-neutral guidance constant (PATCH).** An enterprise security pipeline (Snyk) in diff --git a/CLAUDE.md b/CLAUDE.md index 9ddeaa2..6bd14e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,8 +8,8 @@ AI-enabling another repository. The protocol and core memory files are imported below, so they are structurally present at session start (presence is guaranteed; *attending* to them is still the protocol). Imports -can't express dynamic paths — still scan the newest 2–3 logs in `memory/sessions/` per the -protocol. +can't express dynamic paths — still list `memory/open-threads/` (read the unchecked +thread files) and scan the newest 2–3 logs in `memory/sessions/` per the protocol. @AGENTS.md @memory/PROTOCOL.md diff --git a/DECAY.md b/DECAY.md index 1f26040..fae9570 100644 --- a/DECAY.md +++ b/DECAY.md @@ -17,8 +17,11 @@ ## 1. Fact metadata -Every fact in `memory/continuity.md` carries an HTML-comment footer. Invisible -when rendered, readable and editable by any agent or human, diff-friendly. +Every fact carries an HTML-comment footer. Invisible when rendered, readable and +editable by any agent or human, diff-friendly. Facts live on two surfaces that these +rules treat identically: `memory/continuity.md`, and — for Open Threads — one file per +thread under `memory/open-threads/` (`thread-.md`, filename = the fact id; +v4.39.0, so concurrent thread work merges without conflict — see `.agent/schema.md`). ```markdown - POST-only for mutations, no PUT/PATCH (legacy decision, do not change) @@ -27,7 +30,7 @@ when rendered, readable and editable by any agent or human, diff-friendly. | Field | Set | Recomputed at review? | |---|---|---| -| `id` | once at creation; kebab-case, unique within the file; never changes | no | +| `id` | once at creation; kebab-case, unique across continuity + thread files; never changes | no | | `created` | once at creation (date the fact entered memory) | no | | `last_used` | date of the most recent session that referenced the id | **yes** | | `uses` | count of sessions that referenced the id | **yes** | @@ -50,8 +53,10 @@ rows above). `id` and `created` are immutable. Ordinary facts are born `working` ### Assigning an id Lowercase, hyphenated, derived from the fact's gist (`webhook-fire-forget`, -`drizzle-over-prisma`). Unique within `continuity.md`. Once assigned it is -permanent — it is the handle that session logs use to reference the fact. +`drizzle-over-prisma`). Unique across the live layer — `continuity.md` and every +`memory/open-threads/thread-.md` (`memory-lint` flags a `[duplicate-id]`). Once +assigned it is permanent — it is the handle that session logs use to reference the +fact, and for a thread it is also the filename. --- @@ -258,9 +263,10 @@ Everything above is *backward*-looking — it keeps memory faithful to what happ *intended*. Full design: `docs/DESIGN-vbdi-lifecycle.md`. The rule-level essentials the memory layer enforces: -- **The primitives.** *Current State* = `continuity.md` (read at session start). *Vision* = - `memory/vision.md` (the target; `core`, invariant-verified). *Blueprint* = typed - `(blueprint)` Open Threads = the Vision↔Current-State gap. *Design* = Key Decisions / +- **The primitives.** *Current State* = `continuity.md` + the Open Thread files (read at + session start). *Vision* = `memory/vision.md` (the target; `core`, invariant-verified). + *Blueprint* = typed `(blueprint)` Open Threads (one file each under + `memory/open-threads/`) = the Vision↔Current-State gap. *Design* = Key Decisions / Architectural Invariants (and, **optionally**, a human-facing `docs/arch-decisions/ADR.md` decision log — Architecture Decision Records, read on demand, never in the per-session path; its supersede/deprecate-never-delete lifecycle mirrors §9, and — once the log exists — is **kept in diff --git a/ENABLE.md b/ENABLE.md index 16d4563..8524734 100644 --- a/ENABLE.md +++ b/ENABLE.md @@ -342,8 +342,8 @@ From this analysis, determine: (`pom.xml` → ``, `package.json` → `"version"`, `Cargo.toml` → `version =`, `pyproject.toml` → `version =`, `*.gemspec` → `spec.version`). If README, docs, comments, or other files reference a *different* version - string, **do not fix the drift** — log it as an Open Thread in - `memory/continuity.md`: + string, **do not fix the drift** — log it as an Open Thread + (`memory/open-threads/thread-….md`): `- [ ] Version drift: build manifest is X.Y.Z but reference a different version — verify and align` Resolving drift is the user's responsibility, not the enablement step. 8. **Hosting forge** — GitHub | GitLab | Azure DevOps | unknown (from the Structure-signals @@ -408,7 +408,7 @@ Extract the durable, project-defining facts and feed them into the Step 5 seedin (and seed **Architectural Invariants** in `continuity.md` from explicit "must / never" rules; record an `(ADR-…)` cross-link only if an ADR log exists). - Current goals, roadmap, in-flight work, kanban "in progress" / decision-log open - items → **Open Threads** in `continuity.md`, and the aspiration → the + items → **Open Threads** (one `memory/open-threads/thread-….md` file each), and the aspiration → the **Current-state context** of `memory/vision.md` (5g) — still never *fabricate* the target. - Newcomer-facing knowledge → candidate **smoke-test questions** (5f). @@ -465,15 +465,10 @@ Fill in: - **`## Stack & Tools`** — the canonical live home for the current language version, dependencies, and tool versions (the precise facts `instructions.md` defers here) - Today's date as `last_enabled` -- `last_session`: - - If migrated from vendor history, use the most recent session date from those logs - - Otherwise point it at the **first enable session log** (Step 5c) — the enable *is* the - first session: ` | agent: ()`, e.g. - `2026-08-06 | agent: Claude Code (2026-08-06-142530)`. Fill it when you write that log — - the same moment its stem becomes the seeded facts' `origin`. Never leave `(none yet)`: - it is false the instant the enable completes, and it defeats the multi-agent continuity - check that reads this field (`memory/PROTOCOL.md` → Activate the session). - `last_review`: `(none yet)` +- There is **no `last_session` field** (v4.39.0) — the newest `memory/sessions/` log *is* + the last session (its `**Agent:**` header names the agent); the Step 5c enable log + satisfies the multi-agent continuity check from day one - **repo:** write the path `~`-relative (e.g. `~/projects/foo`) — never an absolute `/Users//…` (or `/home//…`) path. `memory/` is committed to git and shared across the team, so absolute home paths would leak the enabling user's @@ -481,8 +476,12 @@ Fill in: - **Architectural Invariants:** seed from hard constraints in the build manifest / README / `instructions.md` (things that must never change — e.g. "POST-only API", "no runtime deps"). If none are obvious, remove the section. Facts here never decay. -- Open Threads: include any TODOs surfaced during analysis or migration -- **Greenfield only:** seed a `- [ ] Greenfield — no code yet` Open Thread listing what to +- Open Threads: create `memory/open-threads/` and write any TODOs surfaced during + analysis or migration as **one file per thread** — `thread-.md`, whose content is + exactly the thread's bullet block with its footer (see `.agent/schema.md`); continuity's + `## Open Threads` section carries only the template's pointer note, never thread bodies +- **Greenfield only:** seed a `- [ ] Greenfield — no code yet` Open Thread (its own + `thread-…` file) listing what to record as the first code lands: the stack in `## Stack & Tools`, coding conventions, Architectural Invariants, **and the stack's build-output `.gitignore` entries** (apply the Step 7 stack-aware seed table then — at enable there is no stack to seed) @@ -515,9 +514,9 @@ Apply the **redaction rule** (`memory/PROTOCOL.md` → Write the session log): n into the log — redact any pasted command output to `(REDACTED)` before persisting. This makes the enable traceable, lets the facts you seed in 5b set a real `origin`, and -supplies the value 5b's `last_session` points at (the enable *is* the first session — never -leave `(none yet)`). (A `.gitkeep` is then unnecessary — the directory is non-empty; only add -one if for some reason no first log is written.) +is what the multi-agent continuity check reads as the last session (the enable *is* the +first session). (A `.gitkeep` is then unnecessary — the directory is non-empty; only add +one if for some reason no first log is written; same for `memory/open-threads/`.) ### 5d. `.agent/schema.md` [reconcile-covered] @@ -568,7 +567,7 @@ target is the human's to set (same principle as User Preferences: never infer). still the human's to confirm, never inferred). - Leave the **target, success criteria, and non-goals as prompts** (the template's `(…)` placeholders) — do not infer the aspiration. Keep the ⚠️ DRAFT banner. -- Raise a human-gate Open Thread in `memory/continuity.md`: +- Raise a human-gate Open Thread (`memory/open-threads/thread-vision-bootstrap.md`): `- [ ] (vision-bootstrap) Confirm the Vision in memory/vision.md — set the target / success criteria / non-goals; then derive the Blueprint.` - **Do not derive the Blueprint yet** — Blueprint gaps depend on a confirmed target. The gate thread carries that forward. Until the Vision is confirmed, VBDI drift-detection is @@ -960,8 +959,13 @@ The executable scripts (`*.sh`), git hook dispatchers (`.githooks/*`), and hook *.sh text eol=lf .githooks/* text eol=lf .githooks/*.d/* text eol=lf +memory/archive/*.md merge=union ``` +The `merge=union` rule (v4.39.0) lets git auto-merge concurrent appends to the +append-mostly archive files; live memory files deliberately never get union (a conflict +there is signal — `MERGE.md`). + Apply additively (same discipline as `.gitignore`): **no `.gitattributes`** → copy `templates/.gitattributes` verbatim; **exists** → add only the LF rules not already present (de-duplicate; never remove/reorder the user's entries). After adding, run `git add --renormalize .` @@ -1025,7 +1029,7 @@ describe what was intended. 6. **Vision bootstrapped (not fabricated).** `memory/vision.md` exists with the Current-state context filled and the target / success criteria / non-goals left as prompts. The ⚠️ DRAFT banner and the `(…)` prompts are **intentional** — not unfilled - placeholders. A `- [ ] (vision-bootstrap)` Open Thread is present in `continuity.md`, + placeholders. A `- [ ] (vision-bootstrap)` Open Thread file is present in `memory/open-threads/`, and no Blueprint gaps were derived yet (they await the confirmed Vision). 7. **Skills installed + promoted + adapters complete.** Confirm the **built-in skills** @@ -1056,7 +1060,7 @@ describe what was intended. (`instructions.md` / `continuity.md`) and **not** in a vendor steering file. Missing log ⇒ write it now (Step 5c). -Log any issue you cannot fix as an Open Thread in `memory/continuity.md` and +Log any issue you cannot fix as an Open Thread (`memory/open-threads/thread-….md`) and note it in the report. --- diff --git a/GEMINI.md b/GEMINI.md index 779d6e5..8e2869f 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -8,8 +8,8 @@ AI-enabling another repository. The protocol and core memory files are imported below, so they are structurally present at session start (presence is guaranteed; *attending* to them is still the protocol). Imports -can't express dynamic paths — still scan the newest 2–3 logs in `memory/sessions/` per the -protocol. +can't express dynamic paths — still list `memory/open-threads/` (read the unchecked +thread files) and scan the newest 2–3 logs in `memory/sessions/` per the protocol. @./AGENTS.md @./memory/PROTOCOL.md diff --git a/MANIFEST.md b/MANIFEST.md index 11333a3..2e41f18 100644 --- a/MANIFEST.md +++ b/MANIFEST.md @@ -87,6 +87,7 @@ the sanctioned contributor/consumer fork structure (v4.38.0 — see the AGENTS.m | memory/instructions.md | templates/memory/instructions.md | seed-generate | all | step:5a | | memory/continuity.md | templates/memory/continuity.md | seed-generate | all | step:5b | | memory/sessions/ | templates/memory/sessions/.gitkeep | seed-generate | all | step:5c | +| memory/open-threads/ | templates/memory/open-threads/.gitkeep | seed-generate | all | step:5b | | memory/decay-policy.md | templates/memory/decay-policy.md | seed-generate | all | step:5e | | memory/smoke-test.md | templates/memory/smoke-test.md | seed-generate | all | step:5f | | memory/vision.md | templates/memory/vision.md | seed-generate | all | step:5g | @@ -124,6 +125,10 @@ Row notes (the table stays machine-lean; nuance lives here): - **memory/sessions/** — "present" means the directory exists with at least one file; the first enable session log (Step 5c) is what normally satisfies it (`.gitkeep` only as a fallback when no log is written). +- **memory/open-threads/** — one Open Thread per `thread-.md` file (v4.39.0, the + merge-scale layout). "Present" means the directory exists with at least one file; the + threads Step 5b seeds (at minimum the `(vision-bootstrap)` gate thread) normally satisfy + it, `.gitkeep` only as a fallback. Thread bodies never live in `continuity.md`. - **.gitlab-ci.yml** — seed-copy installs the canonical root file (include + the `workflow:rules` guard) only when the target has none. A pre-existing root file is never edited mechanically: the `wire` attr sends the agent to `ENABLE.md` Step 6 (add-only @@ -159,6 +164,8 @@ in the named `UPGRADE.md` rung. Mode B = reconcile + the applicable rows + stamp | 4.36.0 | 4.35.0 -> 4.36.0 | PRE-APPLY: If a hook entrypoint carries locally owned behavior (not the stock agent-memory monolith), move it into a differently named executable fragment (00- to 49- before agent-memory, 51- to 99- after) before accepting the dispatcher re-copy; never overwrite a differently named fragment; if local and agent-memory logic are interleaved, stop for a human decision. | | 4.37.0 | 4.36.0 -> 4.37.0 | PRE-APPLY: Before any reconcile apply, prove recovery of the existing AGENTS.md, merge its local directives and any existing protocol into the target-only memory/PROTOCOL.md with provenance and authority checks, revalidate source hashes, then install the exact one-line shim. Stop before every write on an unsafe merge. | | 4.38.0 | 4.37.0 -> 4.38.0 | Protocol text changed (consumer step-0, terse close records, optional ready-to-work checkpoint): re-copy a target memory/PROTOCOL.md that is still byte-identical to the 4.37.0 template; arbitrate a customized one per ENABLE.md 5i (add the new sections, never drop local directives). Optionally add closed_narrative_max_lines: 150 to memory/decay-policy.md (lint falls back to the default when absent). | +| 4.39.0 | 4.38.1 -> 4.39.0 | Thread migration (merge-scale layout): create memory/open-threads/ and move every Open Thread block out of memory/continuity.md into its own thread-.md (verbatim cut-paste, checkbox + body + footer intact; mint a kebab id + footer first for a legacy thread that lacks one); replace continuity's Open Threads section body with the template's pointer note; delete the last_session line from Project State (derivable from the newest session log). Run memory-lint after - [thread-file]/[duplicate-id] gate the migration. | +| 4.39.0 | 4.38.1 -> 4.39.0 | Protocol text changed (open-threads read/create steps, last_session derivation): re-copy a target memory/PROTOCOL.md still byte-identical to the 4.38.0 template; arbitrate a customized one per ENABLE.md 5i (add the new wording, never drop local directives). | ## Tool-only (never installed) diff --git a/MERGE.md b/MERGE.md index b04703e..2605e2d 100644 --- a/MERGE.md +++ b/MERGE.md @@ -7,8 +7,11 @@ ## Why this exists `memory/` is committed and shared, so concurrent teammates — on any vendor — will sometimes -collide. Session logs almost never conflict (timestamped filenames); `continuity.md` can. -The hard rule comes straight from the **`never-pick-a-winner`** invariant: +collide. Session logs almost never conflict (timestamped filenames), Open Threads can't +conflict across threads (one file each under `memory/open-threads/`, v4.39.0), and archive +files union-merge (`.gitattributes`); what remains is `continuity.md` and the rare +same-thread-file clash. The hard rule comes straight from the **`never-pick-a-winner`** +invariant: > **An AI must never silently choose a winner between two conflicting memory facts.** @@ -33,13 +36,15 @@ isn't "don't touch the text," it's "don't silently pick a winner.") Look at what actually diverged between the two sides: - **Additive** — both sides *added* different facts/bullets to an append-only section - (`## Open Threads`, `## Key Decisions`, `## Conventions`, `## What's Been Built`). The - commonest case. -- **Scalar** — both sides bumped the same single-value field (`last_session`, `last_review`, + (`## Key Decisions`, `## Conventions`, `## What's Been Built`). The commonest + continuity case. +- **Scalar** — both sides bumped the same single-value field (`last_review`, `last_invariant_check`, the `status` version token). -- **Semantic clash** — both sides changed the **same** fact's substance; or one checked a +- **Semantic clash** — both sides changed the **same** fact's substance (in continuity, or + a conflict in the *same* `memory/open-threads/thread-.md` file); or one checked a thread `[ ]`→`[x]` while the other edited it; or a fact was superseded on one side and - edited on the other. The rare case — and the **only** one needing judgment. + edited on the other. The rare case — and the **only** one needing judgment. A thread-file + conflict is *always* this class — the layout has already eliminated the mechanical cases. ## (2) Resolve by tier @@ -48,8 +53,14 @@ Look at what actually diverged between the two sides: - **Additive → UNION. Keep BOTH sides' additions.** They are independent facts; dropping either loses information. Order doesn't matter — the review re-sorts and decays. Each fact keeps its own `id` + footer. -- **Scalar → take the LATER value** (later date / higher semver). `last_session` is also - derivable from the newest `sessions/` filename, so never block on it. +- **Scalar → take the LATER value** (later date / higher semver). +- **Archive files** (`memory/archive/*.md`) carry `merge=union` in `.gitattributes`, so + concurrent sweeps' appends merge on their own. The one case union gets wrong — a + reactivation's removed lines resurrected by the other side — is caught by `memory-lint`'s + `[both]` / `[over-archived]` ERRORs; fix by re-removing the resurrected block. +- **Two session logs with the same filename** (same-second persist on parallel branches — + an add/add conflict): keep both by renaming either one +1 second; never merge their + contents into one file. No "propose a repair" step — apply the rule. diff --git a/MIGRATE.md b/MIGRATE.md index d3c92bd..e0f45f3 100644 --- a/MIGRATE.md +++ b/MIGRATE.md @@ -223,9 +223,10 @@ Authorization headers, PII, or absolute home paths verbatim into the converted l Vendor-specific extraction rules are in the sections below. -### D. Note in continuity +### D. Note the follow-ups as Open Threads -In `memory/continuity.md`, add to Open Threads: +Create the follow-ups as Open Thread files (`memory/open-threads/thread-.md`, +one per thread — v4.39.0; see `.agent/schema.md`): ```markdown - [ ] Review migrated sessions from (under memory/sessions/) diff --git a/README.md b/README.md index f111048..66185dd 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,7 @@ it in place** — additively, never destructively. | Version | Capability | |---|---| +| 4.39.0 | **Merge-scale memory — threads as files:** from maintainer field reports, `continuity.md` merge conflicts became regular business as team adoption grew, concentrated in `## Open Threads` and the `last_session` scalar. Structural fix, no merge-time machinery: each Open Thread lives in its own `memory/open-threads/thread-.md` (filename = the fact id; the directory is the index, like `sessions/`) so parallel work on different threads cannot conflict, while a same-thread edit conflicts per-file — the MERGE.md Tier 2 human gate, preserved by design; `last_session` is dropped (derivable from the newest session log); archive files union-merge via `.gitattributes`; reviews run serialized. All three built-in script pairs treat thread files as fact surfaces; new lint checks `[thread-file]`, `[duplicate-id]`, `[duplicate-state-key]` (the last absorbed from PR #27 — credit: Roland Heusser); `tests/test_thread_layout_merge.sh` pins the merge contract with real git merges. Design record: `docs/DESIGN-merge-scale.md` | | 4.38.1 | **Scanner-neutral guidance constant:** an enterprise security pipeline (Snyk) in a downstream field deployment rejected builds, flagging `memory-lint`'s prose guidance constant as a hardcoded secret — that detector class keys on identifier-contains-SECRET + string-literal assignment. Renamed (`GUIDANCE`) in both runtimes with zero output or behavior change (byte-identical lint results before/after, python and node in agreement); each mirror suite gains a script-hygiene self-check that scans the shipped scripts for the flagged identifier shape (trigger words assembled at runtime, so the suites never commit the shape), and `SKILLS.md` records the house rule: prose-constant identifiers in shipped scripts stay scanner-neutral. Downstream interim edits converge to zero diff on the next re-copy | | 4.38.0 | **Onboarding efficiency — consumer routing + close-record economy:** from a fresh-agent assessment of an enabled repo that is *also a consumable product* (mercury-composable): consumers were routed into contributor memory, and 64% of continuity was completed-thread ship narrative. The root shim gains a **sanctioned contributor/consumer fork** (structure-checked by the reconcile: canonical read-imperative + repo-local consumer entry, a ≤ 16-line routing stub sized to the live field artifact — anything else stays drift with the PRE-APPLY stop), and the target protocol opens with a consumers-exit-here step-0. **Close-record economy:** completed `[x]` threads are specced as 3–6-line stubs (full narrative lives in the origin session log), `REVIEW.md` condenses oversized records at review time, and the new `[closed-thread-bloat]` advisory (knob: `closed_narrative_max_lines`, default 150) measures exactly that class. Plus an optional ready-to-work checkpoint in Work-from-intent | | 4.37.0 | **Enterprise-efficient protocol activation:** root `AGENTS.md` is now an exact one-line pointer to `memory/PROTOCOL.md`, cutting the enterprise IDE auto-loaded surface from ~20 KB to 61 bytes. The relocated tool and target protocols are ordered for activation, compressed without weakening directives (both under 11.2 KB), and directly imported by capable bootstraps. Reconcile installs the target protocol before the shim and enforces the PRE-APPLY boundary: fresh enable and upgrade preserve pre-existing protocol destinations, customized roots, and hook behavior, refusing every write until the protected files are safe and explicitly confirmed | @@ -182,7 +183,6 @@ it in place** — additively, never destructively. | 4.34.1 | **Secret-guard output readability:** field feedback from the maintainer's own regression test of the enforcing guard — every finding line repeated the same advisory tail. `[secret-material]` finding lines now end at `(N hit(s), first at line N)`; the redact/rotate/history guidance appears **once per run** — in the pre-commit hook's `-> fix it` footer (with a blank separator line), and as a single trailer in `--scan-files` and full-lint runs. Both runtimes at parity; the hook's grep contract unchanged | | 4.34.0 | **Pre-commit secret guard — prevention, not detection, on both surfaces:** the v4.33.x incident's *origin* was never a memory file — the credentials entered inside a Postman JSON and an OpenShift YAML, then contaminated a session log via a dry-run. The committed `.githooks/pre-commit` scans the **staged** content (the index — exactly what the commit would publish; only what THIS commit stages) of `memory/**.md` (full profile) **and of config files** (`.json`/`.yml`/`.yaml`/`.properties`/`.toml`/`.ini`/`.env*` — credential-class checks) **before the commit exists**; the CI floor's three forge wrappers run the matching changed-config scan on push (`memory-lint --scan-files`). The guard **enforces by default** — findings block the commit, the deliberate exception to the advisory doctrine (secrets carry irreversible after-the-fact cost); `AGENT_MEMORY_SECRET_GUARD=advisory` opts down, `--no-verify` bypasses once; JSON/properties exemptions in the committed `.agent/secret-scan-ignore`; the CI floor stays advisory (`AGENT_MEMORY_STRICT=1` gates). Detector tuned on a **661-file live-corpus probe to zero false positives** (single-brace + GH-Actions templates, `demo`/`test` placeholder words, placeholder-fallback defaults, dotted route refs, JAAS `;` delimiter, Postman split-pair pattern); 49 mirror tests per runtime | | 4.33.4 | **`[secret-material]`: reject non-empty template defaults:** v4.33.3's broad `${…}` exemption accepted literal fallbacks, allowing `client_secret=${CLIENT_SECRET:-RealSecret123}` to bypass assignment detection. `${NAME}`, `${NAME:}`, and dotted references remain safe; non-empty defaults flag without echoing values. Python/Node remain byte-identical, 46 mirror tests each | -| 4.33.3 | **`[secret-material]` security-review hardening:** closes four fresh-context findings: forge wrappers now invoke `memory-lint --strict` so advisory findings are observable and `AGENT_MEMORY_STRICT=1` genuinely blocks; the ALL-CAPS exemption is limited to enum-dimension keys instead of trusting uppercase secrets globally; quoted JSON/YAML assignments, Authorization headers, and embedded-placeholder bypasses now flag without echoing values; Mode C must redact migrated history and triage lint before commit. Fold-in from the post-merge live-target review: `${…}` template recognition accepts default-value / dotted env-var forms (`${VAR:}`, `${a.b.c}` — still full-value anchored). Python/Node remain byte-identical, 46 mirror tests each | When you "AI enable" a repo that's already on an older version, Mode B detects the drift and **reconciles it against the current target state** (`MANIFEST.md`) — one diff --git a/REVIEW.md b/REVIEW.md index 356f9c6..9d45ed6 100644 --- a/REVIEW.md +++ b/REVIEW.md @@ -15,9 +15,10 @@ Three triggers: 1. **Cadence** — when `sessions_since_last_review ≥ review_every` (from `memory/decay-policy.md`). Checked during the post-session update. 2. **On command** — the user says *"review memory"* / *"compact memory"*. -3. **Size** — when `memory/continuity.md` holds more than `continuity_max_facts` - decaying facts/threads (the primary signal — a count, immune to verbosity and session - velocity), **or** exceeds `continuity_max_lines` (a coarse backstop). +3. **Size** — when the live layer (`memory/continuity.md` + the `memory/open-threads/` + files) holds more than `continuity_max_facts` decaying facts/threads (the primary + signal — a count, immune to verbosity and session velocity), **or** `continuity.md` + exceeds `continuity_max_lines` (a coarse backstop). > **The triggers don't rely on the agent remembering.** `memory-lint` surfaces all three as > advisories — `[review-overdue]` (cadence) and `[continuity-bloat]` (facts/lines) — so a lapsed @@ -36,10 +37,17 @@ it never fires more often than reviews do. ## Inputs - `memory/continuity.md` — facts + metadata +- `memory/open-threads/` — Open Threads, one file per thread (v4.39.0) - `memory/decay-policy.md` — windows + triggers - `memory/sessions/` — the event log; read each `## Memory References` - `memory/archive/` — cold storage + `INDEX.md` +> **Run reviews serialized.** Start from an **up-to-date default branch** and commit the +> result promptly, before other memory work: the metadata refresh rewrites many footers at +> once, and running it on a stale branch tangles mechanical churn with teammates' in-flight +> substantive edits — the worst conflict shape. (Reviews are cadence-gated and effectively +> single-actor; this just makes that explicit.) + --- ## The routine (incremental — the normal path) @@ -50,12 +58,14 @@ it never fires more often than reviews do. - `Referenced` / `Created`: increment `uses`; set `last_used` to the latest session date that names the id. - `Reactivated`: if the id currently lives in the archive, move it back into - `continuity.md` as `active`, then apply the Referenced bump. + the live layer as `active` (a fact into `continuity.md`; a thread back to its + own `memory/open-threads/thread-.md`), then apply the Referenced bump. - `Superseded: → ` (or ` (invalidated)`): confirm the old fact is marked `tier: superseded` + `superseded-by: ` (the agent marks it at write time — `DECAY.md` §9; set it here if missing) and the successor carries `supersedes: `. -3. **Re-tier every fact.** For each fact in `continuity.md`, compute +3. **Re-tier every fact.** For each fact in `continuity.md` and each thread file in + `memory/open-threads/`, compute `sessions_since_last_used` (count files — `DECAY.md` §4) and apply the `DECAY.md` §5 rules in order. Record each tier change. > **Preferred — steps 2–3 are pure arithmetic; run the `refresh-metadata` skill** @@ -81,9 +91,11 @@ it never fires more often than reviews do. false, not merely stale — and carry their `superseded-by` link into the archive. 5. **Sweep completed threads.** `- [x]` Open Threads whose completion is older than `archive_window` sessions move to the archive the same way (usually the biggest - lean-up). Keep recently-completed threads for context — **but condense them to - stubs** (v4.38.0): while a completed thread waits out `archive_window`, its record - is 3–6 lines — outcome, PR/commit/release refs, one durable lesson, and its + lean-up) — for a thread file the sweep moves its block to the quarter file + + `INDEX.md` and **deletes the file** (`archive-fact` handles thread files; the move + preserves everything). Keep recently-completed threads for context — **but condense + them to stubs** (v4.38.0): while a completed thread waits out `archive_window`, its + record is 3–6 lines — outcome, PR/commit/release refs, one durable lesson, and its `origin:` pointer. Trim prose only; never edit the id or footer metadata. Nothing is lost — the full narrative lives in the thread's origin session log (immutable), and `[closed-thread-bloat]` is the advisory that measures this. A condensed thread @@ -157,7 +169,8 @@ session whose `## Memory References` names the id under `Created` (`DECAY.md` § ## Reactivation When an archived id is named in a session (`Referenced`/`Reactivated`): -- move the fact from its `archive/.md` back into `continuity.md`, +- move the fact from its `archive/.md` back into the live layer + (`continuity.md`; a thread back to `memory/open-threads/thread-.md`), - set `tier: active`, refresh `last_used`, increment `uses`, - remove or annotate its `archive/INDEX.md` line, - note it in the review summary. diff --git a/UPGRADE.md b/UPGRADE.md index c0fdb65..d8456c0 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -140,6 +140,7 @@ dev-numbered 4.22–4.25 — into a single MINOR over the released 4.21.0.)* | 4.37.0 | **Enterprise-efficient protocol activation (MINOR):** root `AGENTS.md` becomes an exact one-line discovery shim to the new canonical `memory/PROTOCOL.md`. The protocol is reordered for activation, compressed without weakening directives, imported directly by capable bootstraps, and split correctly between the tool's dual-mode operator protocol and the installed target-only protocol. `MANIFEST.md` owns the target protocol row; fresh enable and upgrade preserve pre-existing destinations and customized root instructions, stopping for human resolution on unsafe merges | | 4.38.0 | **Onboarding efficiency: consumer routing + close-record economy (MINOR):** from a fresh-agent assessment of mercury-composable (an enabled repo that is *also a consumable product*). (1) The root shim gains a **sanctioned contributor/consumer fork** — structure-checked by the reconcile (`fork-ok`: canonical read-imperative first line, repo-local consumer link, ≤ 16 non-empty lines / ≤ 2 KB routing stub — bounds sized to the live field artifact, pinned verbatim in both test suites), emitted by `ENABLE.md` Step 6 when the repo declares a consumer entry; the target protocol opens with a consumers-exit-here step-0. (2) **Close-record economy** — measured 64% of a hot repo's continuity was completed-`[x]` ship narrative whose canonical home is the origin log: the protocol and schema now spec 3–6-line close records; `REVIEW.md` step 5 condenses oversized records to stubs at review time (prose only, footers immutable); new `memory-lint` advisory `[closed-thread-bloat]` with the `closed_narrative_max_lines` knob (default 150). (3) Optional ready-to-work checkpoint in the protocol's Work-from-intent. Protocol-text change → ships the mandated Semantic steps row (the first exercise of the v4.37.0 propagation obligation) | | 4.38.1 | **`memory-lint`: scanner-neutral guidance constant (PATCH):** an enterprise security pipeline (Snyk) in a downstream field deployment rejected builds, flagging the consolidated guidance constant (introduced 4.34.1) as a hardcoded secret — that detector class keys on identifier-contains-SECRET + string-literal assignment, and the prose constant carried the name. Renamed `GUIDANCE` in both runtimes; no string content, output, or hook-contract change (byte-identical lint results before/after, both runtimes in agreement). Each mirror suite gains a script-hygiene self-check scanning the shipped scripts for the flagged identifier shape, trigger words assembled at runtime (53 each; red-verified against the motivating artifact pre-rename). `SKILLS.md` (Authoring) records the house rule: prose-constant identifiers in shipped scripts stay scanner-neutral. Downstream interim local edits converge to zero diff on re-copy (`verbatim-dir`) | +| 4.39.0 | **Merge-scale memory: threads as files (MINOR):** from maintainer field reports — `continuity.md` merge conflicts became regular business as team adoption grew, concentrated in `## Open Threads` and the `last_session` scalar (PR #27's field artifact: two stacked branches, both appending a thread and bumping `last_session`). Structural fix, no merge-time machinery: (1) **one thread per file** — `memory/open-threads/thread-.md`, filename = the fact id, content = exactly the thread's bullet block; parallel work on different threads cannot conflict, a same-thread edit conflicts per-file (the MERGE.md Tier 2 human gate, preserved by design — a rejected merge-driver alternative silently unioned it; see docs/DESIGN-merge-scale.md). No index file — the directory is the index, like sessions/. (2) **`last_session` dropped** — derivable from the newest session log; the protocol's multi-agent check reads that log's `**Agent:**` header. (3) **archive files union-merge** (`memory/archive/*.md merge=union` in `.gitattributes`; git-native, backstopped by `[both]`/`[over-archived]`). (4) Reviews run serialized (REVIEW.md). All three built-in script pairs treat thread files as fact surfaces (lint 67 tests ×2, refresh 6 ×2, archive 9 ×2); new lint checks `[thread-file]`, `[duplicate-id]`, and `[duplicate-state-key]` (absorbed from PR #27 — credit: Roland Heusser); `tests/test_thread_layout_merge.sh` pins the merge contract with real git merges (4 cases incl. a mutation check). Protocol-text change → ships the mandated Semantic steps row | Each enabled repo records what it is on in **`.agent/version.md`**: @@ -2373,3 +2374,59 @@ change; no semantic step. `enabled_with` and `mode`. Use an edit/read-before-write path, never truncate first. 3. **Verify:** mirror suites pass (53 each); `memory-lint` output on the repo is unchanged from 4.38.0 (same findings, same once-per-run guidance line). +## Rung: 4.38.1 → 4.39.0 — Merge-scale memory: threads as files (MINOR) + +**What changed:** at team scale, `memory/continuity.md` merge conflicts became a per-merge +tax — concentrated in `## Open Threads` (multi-line blocks appended and edited at one +anchor) and the `last_session` scalar (bumped by nearly every session). The fix is +structural, not merge-time machinery (design record: `docs/DESIGN-merge-scale.md`; a +merge-driver alternative, PR #27, was verified to silently union Tier 2 semantic clashes +and winner-pick dateless scalars, and was closed with its `[duplicate-state-key]` check and +merge-rehearsal test pattern absorbed — credit: Roland Heusser): + +- **One thread per file:** `memory/open-threads/thread-.md`, filename = the thread's + kebab fact id (stable for the thread's lifetime), content = exactly the bullet block that + previously sat in continuity (checkbox + body + footer). No index file — the directory is + the index, like `sessions/`. Decay/pinning/supersession rules are unchanged; only the + location moved. +- **`last_session` dropped** from Project State: derivable (newest `sessions/` filename; + agent name in that log's `**Agent:**` header). The protocol's activation and close + rituals no longer read or write it. +- **Archive union-merge:** `memory/archive/*.md merge=union` in `.gitattributes` — concurrent + review sweeps' appends merge without conflict; the resurrect case is caught by + `[both]`/`[over-archived]`. +- **Scripts treat thread files as fact surfaces:** `memory-lint` (plus new checks + `[thread-file]`, `[duplicate-id]`, `[duplicate-state-key]`), `refresh-metadata` + (footers refresh in place — filenames never churn), `archive-fact` (sweeping a thread + moves its block to the quarter archive + INDEX and deletes the file). +- **Reviews run serialized** (REVIEW.md): from an up-to-date default branch, committed + promptly. + +**Steps:** + +1. **Reconcile** (`scripts/reconcile.py --dry-run`, then `--apply`, or walk `MANIFEST.md` + by hand): re-copies `DECAY.md`, `REVIEW.md`, `MERGE.md`, `.agent/schema.md`, the three + updated built-in skill dirs (`memory-lint`, `refresh-metadata`, `archive-fact`), and + merges the new `memory/archive/*.md merge=union` rule into `.gitattributes` + (sentinel-merge; `renorm` applies — check the staged diff on a CRLF repo). The new + `memory/open-threads/` row seeds the directory. +2. **Semantic — thread migration** (the judgment half; fail closed on anything odd): + move every Open Thread block out of `memory/continuity.md` into its own + `memory/open-threads/thread-.md` — a **verbatim cut-paste** of checkbox line, body, + `serves:` trace, and footer; never rewrite content in flight. A legacy thread with no + id/footer gets one minted first (`created`/`last_used` = today, `uses: 1`, + `tier: working` — the 3.0.0 backfill rule). Replace the `## Open Threads` section body + with the template's pointer note (`templates/memory/continuity.md`). Then delete the + `- **last_session:** …` line from Project State. +3. **Semantic — protocol re-copy/arbitrate:** protocol text changed (open-threads read and + create steps; last_session derivation). If the target's `memory/PROTOCOL.md` is still + byte-identical to the 4.38.0 template, re-copy `templates/memory/PROTOCOL.md`; a + customized copy is arbitrated per `ENABLE.md` §5i — add the new wording, never drop + local directives. Import-capable bootstraps (`CLAUDE.md`/`GEMINI.md`) get the one-line + "still list `memory/open-threads/`" note merged into their scan sentence. +4. **Stamp** `.agent/version.md` → `version: 4.39.0`, `last_upgraded: `, preserving + `enabled_with` and `mode`. Use an edit/read-before-write path, never truncate first. +5. **Verify:** run `memory-lint` (any runtime) — `[thread-file]` and `[duplicate-id]` gate + the migration (a mis-named file or an id left behind in continuity fails here); + the mirror suites pass (67 lint / 6 refresh / 9 archive, each ×2); in the tool repo, + `bash tests/test_thread_layout_merge.sh` pins the merge contract. diff --git a/VERSION b/VERSION index bcdb580..280bdbc 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.38.1 +4.39.0 diff --git a/agent-skills/archive-fact/SKILL.md b/agent-skills/archive-fact/SKILL.md index 0b206e5..93f1e9b 100644 --- a/agent-skills/archive-fact/SKILL.md +++ b/agent-skills/archive-fact/SKILL.md @@ -8,7 +8,8 @@ provenance: agent-memory-builtin > in place — fork under a new name, or upstream a fix to the agent-memory project (see `SKILLS.md`). This skill **is** the safe archive-move. It performs `REVIEW.md` step 4 — moving a faded/superseded fact's -block out of `continuity.md` and into the quarter archive — as a **runnable script**, so the move can't be +block out of the live layer — `continuity.md`, or a thread's own `memory/open-threads/thread-.md` +file (v4.39.0; the sweep moves the block to the quarter archive and deletes the file) — as a **runnable script**, so the move can't be botched. **Don't hand-edit `continuity.md` to do this** (the read-modify-write `open(f,"w").write(open(f).read()+…)` truncates the file *before* the read — it has wiped a `version.md` stamp and then this repo's archive, 50 facts → 6, once each). This script reads the whole file into memory first and writes once, so truncation @@ -47,7 +48,7 @@ never truncates), and (3) rewrites `continuity.md` without those blocks (read-in helper is safe by construction; the lint is the deterministic proof. ## Guards (it refuses, exit 1) -- an id with no footer in `continuity.md` (typo / already moved); +- an id with no footer in `continuity.md` or `memory/open-threads/` (typo / already moved); - an id already present in the archive (no double-archive); - a move that would leave `continuity.md` empty. diff --git a/agent-skills/archive-fact/scripts/archive-fact.mjs b/agent-skills/archive-fact/scripts/archive-fact.mjs index a4f1322..52fb8a5 100644 --- a/agent-skills/archive-fact/scripts/archive-fact.mjs +++ b/agent-skills/archive-fact/scripts/archive-fact.mjs @@ -14,7 +14,7 @@ // Exit: 0 = moved (or dry-run ok), 1 = refused, 2 = could not locate memory/. // Run `memory-lint` afterward to confirm. -import { readFileSync, writeFileSync, appendFileSync, existsSync, statSync, readdirSync } from "node:fs"; +import { readFileSync, writeFileSync, appendFileSync, existsSync, statSync, readdirSync, unlinkSync } from "node:fs"; import { resolve, dirname, join, basename } from "node:path"; import { fileURLToPath } from "node:url"; @@ -64,10 +64,27 @@ function derive_quarter(y, mo) { return `${y}-Q${Math.floor((mo - 1) / 3) + 1}`; } +function thread_files(mem) { + // fid -> path for memory/open-threads/thread-*.md (v4.39.0). A thread file's single + // footer id is its identity; archiving one moves its block to the quarter file and + // deletes the file (the move preserves everything, including the footer). + const out = new Map(); + const tdir = join(mem, "open-threads"); + if (!existsSync(tdir)) return out; + for (const name of readdirSync(tdir).sort()) { + if (!name.endsWith(".md")) continue; + const path = join(tdir, name); + const m = read_text(path).match(/ +`; + +function threadSetup() { + const root = mkdtempSync(join(tmpdir(), "aftest-threads-")); + mkdirSync(join(root, "memory", "archive"), { recursive: true }); + mkdirSync(join(root, "memory", "open-threads"), { recursive: true }); + writeFileSync(join(root, "memory", "continuity.md"), "# Continuity\n\n- a fact stays\n \n"); + writeFileSync(join(root, "memory", "archive", "INDEX.md"), "# Archive INDEX\n"); + writeFileSync(join(root, "memory", "archive", "2026-Q1.md"), "# 2026-Q1\n"); + writeFileSync(join(root, "memory", "open-threads", "thread-gamma-thread.md"), GAMMA_THREAD); + return root; +} + +test("thread file moves to archive and is deleted", () => { + const root = threadSetup(); + try { + const { code, msg } = archive_facts(root, ["gamma-thread"], "faded", "2026-Q1", null, false); + assert.equal(code, 0); + assert.ok(msg.includes("gamma-thread")); + assert.ok(!existsSync(join(root, "memory", "open-threads", "thread-gamma-thread.md"))); + const quarter = readFileSync(join(root, "memory", "archive", "2026-Q1.md"), "utf-8"); + assert.ok(quarter.includes("id: gamma-thread")); // block + footer preserved + assert.ok(quarter.includes("Shipped vZ — gamma.")); + assert.ok(readFileSync(join(root, "memory", "archive", "INDEX.md"), "utf-8").includes("- gamma-thread — ")); + // continuity untouched (no cont plans) + assert.ok(readFileSync(join(root, "memory", "continuity.md"), "utf-8").includes("stay-fact")); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test("dry run keeps the thread file", () => { + const root = threadSetup(); + try { + const { code, msg } = archive_facts(root, ["gamma-thread"], "faded", "2026-Q1", null, true); + assert.equal(code, 0); + assert.ok(msg.includes("DRY-RUN")); + assert.ok(existsSync(join(root, "memory", "open-threads", "thread-gamma-thread.md"))); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test("missing id names both surfaces", () => { + const root = threadSetup(); + try { + const { code, msg } = archive_facts(root, ["ghost-id"], "faded", "2026-Q1", null, false); + assert.equal(code, 1); + assert.ok(msg.includes("continuity.md or memory/open-threads/")); + } finally { rmSync(root, { recursive: true, force: true }); } +}); diff --git a/agent-skills/archive-fact/scripts/test_archive_fact.py b/agent-skills/archive-fact/scripts/test_archive_fact.py index 6cc1fef..3e80b8e 100644 --- a/agent-skills/archive-fact/scripts/test_archive_fact.py +++ b/agent-skills/archive-fact/scripts/test_archive_fact.py @@ -103,5 +103,58 @@ def test_derive_quarter(self): self.assertEqual(archive_fact.derive_quarter(datetime.date(2026, 12, 31)), "2026-Q4") +class TestArchiveThreadFile(unittest.TestCase): + # v4.39.0: a completed thread lives in its own memory/open-threads/thread-.md; + # archiving moves the block to the quarter file + INDEX and DELETES the file — the + # sweep for the merge-free thread layout. + THREAD = """- [x] **Shipped vZ — gamma.** Did gamma. + +""" + + def _setup(self): + root = tempfile.mkdtemp() + os.makedirs(os.path.join(root, "memory", "archive")) + os.makedirs(os.path.join(root, "memory", "open-threads")) + with open(os.path.join(root, "memory", "continuity.md"), "w") as f: + f.write("# Continuity\n\n- a fact stays\n \n") + with open(os.path.join(root, "memory", "archive", "INDEX.md"), "w") as f: + f.write("# Archive INDEX\n") + with open(os.path.join(root, "memory", "archive", "2026-Q1.md"), "w") as f: + f.write("# 2026-Q1\n") + with open(os.path.join(root, "memory", "open-threads", "thread-gamma-thread.md"), "w") as f: + f.write(self.THREAD) + return root + + def _read(self, root, *parts): + with open(os.path.join(root, "memory", *parts)) as f: + return f.read() + + def test_thread_file_moves_to_archive_and_is_deleted(self): + root = self._setup() + code, msg = archive_fact.archive_facts(root, ["gamma-thread"], "faded", "2026-Q1", None, False) + self.assertEqual(code, 0) + self.assertIn("gamma-thread", msg) + self.assertFalse(os.path.exists(os.path.join(root, "memory", "open-threads", "thread-gamma-thread.md"))) + quarter = self._read(root, "archive", "2026-Q1.md") + self.assertIn("id: gamma-thread", quarter) # block + footer preserved + self.assertIn("Shipped vZ — gamma.", quarter) + self.assertIn("- gamma-thread — ", self._read(root, "archive", "INDEX.md")) + # continuity untouched (no cont plans) + self.assertIn("stay-fact", self._read(root, "continuity.md")) + + def test_dry_run_keeps_thread_file(self): + root = self._setup() + code, msg = archive_fact.archive_facts(root, ["gamma-thread"], "faded", "2026-Q1", None, True) + self.assertEqual(code, 0) + self.assertIn("DRY-RUN", msg) + self.assertTrue(os.path.exists(os.path.join(root, "memory", "open-threads", "thread-gamma-thread.md"))) + + def test_missing_id_names_both_surfaces(self): + root = self._setup() + code, msg = archive_fact.archive_facts(root, ["ghost-id"], "faded", "2026-Q1", None, False) + self.assertEqual(code, 1) + self.assertIn("continuity.md or memory/open-threads/", msg) + + if __name__ == "__main__": unittest.main() diff --git a/agent-skills/memory-lint/SKILL.md b/agent-skills/memory-lint/SKILL.md index 752616e..632400e 100644 --- a/agent-skills/memory-lint/SKILL.md +++ b/agent-skills/memory-lint/SKILL.md @@ -45,7 +45,15 @@ script, so the riskiest operation is verified against observable evidence. node --test agent-skills/memory-lint/scripts/test_memory_lint.mjs ``` 2. It checks, deterministically: - - **no id lives in both `continuity.md` and the archive** (a fact exists in exactly one place); + - **no id lives in both the live layer and the archive** (a fact exists in exactly one place) — + the live layer is `continuity.md` **plus the one-thread-per-file `memory/open-threads/`** + directory (v4.39.0), whose facts and checkbox pinning count exactly like continuity's; + - **`[thread-file]`** — the thread-file contract: one thread block per file, filename + `thread-.md` matching the footer id (filename = the merge-free identity); + - **`[duplicate-id]`** — an id must exist exactly once across the live layer; two live + footers is the silent-fork shape of a same-id creation collision on parallel branches; + - **`[duplicate-state-key]`** — a `## Project State` scalar set twice (a union-style hand + merge that kept both sides — absorbed from PR #27, credit: Roland Heusser); - **no archived-as-faded fact was referenced within `archive_window` sessions** — the decay-miscount guard: if it was, the count was wrong, so **reactivate it**; - *advisory* — continuity facts overdue for archival (`sslu > archive_window`), excluding `core`, @@ -55,7 +63,7 @@ script, so the riskiest operation is verified against observable evidence. empty/malformed manifest breaks Mode B upgrade detection (this was a real bug: a truncating stamp one-liner emptied it). A *missing* file is the valid pre-versioning baseline and is not flagged. - **no leftover merge-conflict markers** (`<<<<<<<` / `>>>>>>>` / diff3 `|||||||`) in the **live - top-level `memory/*.md`** files (`continuity.md`, `instructions.md`, `vision.md`, `decay-policy.md`, + top-level `memory/*.md`** files plus `memory/open-threads/*.md` (`continuity.md`, `instructions.md`, `vision.md`, `decay-policy.md`, `smoke-test.md`) — an unresolved conflict there silently corrupts shared memory the agent reads as truth. `sessions/` and `archive/` are **excluded** (immutable/append narrative that legitimately *quotes* markers — e.g. a session log pasting a diff). A bare `=======` line is *not* flagged diff --git a/agent-skills/memory-lint/scripts/memory-lint.mjs b/agent-skills/memory-lint/scripts/memory-lint.mjs index 6c38591..a49ad80 100644 --- a/agent-skills/memory-lint/scripts/memory-lint.mjs +++ b/agent-skills/memory-lint/scripts/memory-lint.mjs @@ -150,13 +150,33 @@ function parse_args(args) { return { strict, root_arg, scan_files }; } +export function load_thread_files(mem) { + // memory/open-threads/*.md — one Open Thread per file (v4.39.0). Returns + // [[basename, text]]; an absent directory (pre-4.39.0 layout) is an empty list. + // Thread files are live continuity-domain facts: their footers merge into `cont` + // and their checkbox state feeds the pinned set, so every decay/reference rule + // applies to them unchanged — only the storage location moved (merge-scale). + const tdir = join(mem, "open-threads"); + if (!existsSync(tdir)) return []; + return readdirSync(tdir) + .filter((x) => x.endsWith(".md")) + .sort(byCodePoint) + .map((n) => [n, read_text(join(tdir, n))]); +} + export function load_repo(root) { - // Read the memory/ layer. Returns { cont, pinned, arch, extra, sessions, refs }. + // Read the memory/ layer. Returns { cont, pinned, arch, extra, sessions, refs, threads }. const mem = join(root, "memory"); const cont_text = read_text(join(mem, "continuity.md")); const cont = parse_footers(cont_text); const pinned = pinned_open_threads(cont_text); + const threads = load_thread_files(mem); + for (const [, ttext] of threads) { + for (const [k, v] of parse_footers(ttext)) cont.set(k, v); + for (const fid of pinned_open_threads(ttext)) pinned.add(fid); + } + let archive_text = ""; const archiveDir = join(mem, "archive"); if (existsSync(archiveDir)) { @@ -183,7 +203,7 @@ export function load_repo(root) { ? readdirSync(sessDir).filter((x) => x.endsWith(".md")).sort(byCodePoint) : []; const refs = sessions.map((s) => memref_ids(read_text(join(sessDir, s)))); - return { cont, pinned, arch, extra, sessions, refs }; + return { cont, pinned, arch, extra, sessions, refs, threads }; } function make_sslu(refs) { @@ -278,13 +298,20 @@ export function check_conflict_markers(root) { const mem = join(root, "memory"); const marker = /^(<{7}|>{7}|\|{7})(\s|$)/; if (!existsSync(mem)) return out; - const files = readdirSync(mem).filter((n) => n.endsWith(".md")).sort(byCodePoint); - for (const name of files) { - const lines = read_text(join(mem, name)).split("\n"); + const live = readdirSync(mem).filter((n) => n.endsWith(".md")).sort(byCodePoint) + .map((n) => [join(mem, n), `memory/${n}`]); + const tdir = join(mem, "open-threads"); // thread files are live truth too (v4.39.0) + if (existsSync(tdir)) { + for (const n of readdirSync(tdir).filter((x) => x.endsWith(".md")).sort(byCodePoint)) { + live.push([join(tdir, n), `memory/open-threads/${n}`]); + } + } + for (const [path, rel] of live) { + const lines = read_text(path).split("\n"); for (let i = 0; i < lines.length; i++) { if (marker.test(lines[i])) { out.push( - `[conflict-marker] memory/${name}:${i + 1} unresolved merge-conflict marker ` + + `[conflict-marker] ${rel}:${i + 1} unresolved merge-conflict marker ` + "— resolve it before committing" ); break; // one report per file is enough @@ -294,6 +321,101 @@ export function check_conflict_markers(root) { return out; } +export function check_thread_files(threads) { + // (12) the thread-file contract (v4.39.0): memory/open-threads/ holds ONE Open Thread + // per file, named thread-.md after its footer id. Filename = identity is what makes + // concurrent thread work merge-free (parallel branches touch different files), so drift + // here is an ERROR, not style: a wrong name or a second block re-creates the shared-file + // conflict surface this layout exists to remove. + const out = []; + for (const [name, text] of threads) { + const rel = `memory/open-threads/${name}`; + const footers = [...text.matchAll(FOOTER_RE)]; + if (footers.length === 0) { + out.push(`[thread-file] ${rel} has no fact footer — a thread file carries exactly one \`\``); + continue; + } + if (footers.length > 1) { + out.push(`[thread-file] ${rel} holds ${footers.length} footers — one thread per file; split it`); + continue; + } + const fid = footers[0][1]; + const expect = `thread-${fid}.md`; + if (name !== expect) { + out.push(`[thread-file] ${rel} should be named ${expect} (filename = the footer id)`); + } + const first = text.split(/\r?\n/).find((ln) => ln.trim()) ?? ""; + if (!first.startsWith("- [ ]") && !first.startsWith("- [x]") && !first.startsWith("- [X]")) { + out.push(`[thread-file] ${rel} does not start with a \`- [ ]\`/\`- [x]\` bullet — file content is exactly the thread block`); + } + } + return out; +} + +export function check_duplicate_ids(cont_text, threads) { + // (13) an id exists exactly ONCE across the live layer (continuity + thread files). + // Two live footers with one id is the silent-fork shape a same-id creation collision + // on parallel branches (or a bad hand-merge) produces — [both] covers live-vs-archive, + // this covers live-vs-live. Without it, parse_footers' id-keyed map hides the twin. + const where = new Map(); + const surfaces = [["memory/continuity.md", cont_text]]; + for (const [n, txt] of threads) surfaces.push([`memory/open-threads/${n}`, txt]); + for (const [src, text] of surfaces) { + for (const m of text.matchAll(FOOTER_RE)) { + if (!where.has(m[1])) where.set(m[1], []); + where.get(m[1]).push(src); + } + } + const out = []; + for (const fid of [...where.keys()].sort(byCodePoint)) { + const srcs = where.get(fid); + if (srcs.length > 1) { + out.push( + `[duplicate-id] ${fid} has ${srcs.length} footers across the live layer ` + + `(${srcs.join(", ")}) — an id exists exactly once; merge the copies or re-id one` + ); + } + } + return out; +} + +export function check_duplicate_state_keys(root) { + // (14) `## Project State` holds SCALARS — one value each, latest wins. This is the + // backstop for a union-style hand merge that kept both sides of a bumped scalar, or a + // hand-edited header. Deliberately scoped to `## Project State`: a repeated key anywhere + // else is a bullet, not a scalar, and repetition there is legitimate. + // (Absorbed from PR #27 — credit: Roland Heusser.) + const out = []; + const p = join(root, "memory", "continuity.md"); + if (!existsSync(p) || !statSync(p).isFile()) return out; + const keyRe = /^-\s+\*\*([a-z_]+):\*\*/; + const lines = read_text(p).split(/\r?\n/); + const seen = new Map(); + let inState = false; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (line.startsWith("## ")) { + if (inState) break; + inState = line.trim() === "## Project State"; + continue; + } + if (!inState) continue; + const m = keyRe.exec(line); + if (!m) continue; + const key = m[1]; + if (seen.has(key)) { + out.push( + `[duplicate-state-key] memory/continuity.md:${i + 1} '${key}' is set twice ` + + `(also line ${seen.get(key)}) — Project State fields are scalars. Usually a union ` + + `merge keeping both sides: delete the stale line, keeping the later value.` + ); + } else { + seen.set(key, i + 1); + } + } + return out; +} + export function check_dangling(allf) { // (4) supersession links resolve const out = []; @@ -431,10 +553,12 @@ export function closed_narrative_lines(cont_text) { return count; } -export function check_closed_thread_bloat(cont_text, cap) { +export function check_closed_thread_bloat(cont_text, cap, threads = []) { // (11) advisory: completed threads should wait out archive_window as terse // stubs (3–6 lines), not full ship narratives — REVIEW.md condenses them. - const n = closed_narrative_lines(cont_text); + // Measured across every live surface: continuity + the thread files (v4.39.0). + let n = closed_narrative_lines(cont_text); + for (const [, txt] of threads) n += closed_narrative_lines(txt); if (n <= cap) return []; return [ `[closed-thread-bloat] ${n} line(s) of completed [x] thread records > ` + @@ -574,6 +698,7 @@ export function check_secret_material(root) { } }; addDir(mem, "memory/"); + addDir(join(mem, "open-threads"), "memory/open-threads/"); addDir(join(mem, "sessions"), "memory/sessions/"); addDir(join(mem, "archive"), "memory/archive/"); @@ -664,7 +789,7 @@ export function scan_secret_files(paths) { function report({ cont, arch, sessions, acw, aw, warns, errors, strict }) { console.log( - `memory-lint: ${cont.size} continuity facts, ${arch.size} archived, ` + + `memory-lint: ${cont.size} live facts (continuity + open-threads), ${arch.size} archived, ` + `${sessions.length} sessions; windows active=${acw} archive=${aw}` ); for (const line of warns) console.log("WARN " + line); @@ -701,7 +826,7 @@ export function main(argv) { return 2; } - const { cont, pinned, arch, extra, sessions, refs } = load_repo(root); + const { cont, pinned, arch, extra, sessions, refs, threads } = load_repo(root); const w = load_windows(root); const aw = w.archive_window; const acw = w.active_window; @@ -715,6 +840,9 @@ export function main(argv) { ...check_over_archived(arch, sslu, aw), ...check_version_manifest(root), ...check_conflict_markers(root), + ...check_thread_files(threads), + ...check_duplicate_ids(cont_text, threads), + ...check_duplicate_state_keys(root), ]; const stems = sessions.map((s) => s.replace(/\.md$/, "")); const overdue = check_overdue(cont, pinned, sslu, aw); @@ -731,7 +859,7 @@ export function main(argv) { cont, sessions, cont_text, cont_lines, w.review_every, w.continuity_max_facts, w.continuity_max_lines, pinned, archivable ), - ...check_closed_thread_bloat(cont_text, w.closed_narrative_max_lines), + ...check_closed_thread_bloat(cont_text, w.closed_narrative_max_lines, threads), ...check_stale_metadata(cont, pinned, refs, stems, w.working_window, acw, aw), ...check_secret_material(root), ]; diff --git a/agent-skills/memory-lint/scripts/memory-lint.py b/agent-skills/memory-lint/scripts/memory-lint.py index 53de4bd..52151ca 100644 --- a/agent-skills/memory-lint/scripts/memory-lint.py +++ b/agent-skills/memory-lint/scripts/memory-lint.py @@ -140,13 +140,33 @@ def parse_args(args): return strict, root_arg, scan_files +def load_thread_files(mem): + """memory/open-threads/*.md — one Open Thread per file (v4.39.0). Returns + [(basename, text)]; an absent directory (pre-4.39.0 layout) is an empty list. + Thread files are live continuity-domain facts: their footers merge into `cont` + and their checkbox state feeds the pinned set, so every decay/reference rule + applies to them unchanged — only the storage location moved (merge-scale).""" + tdir = os.path.join(mem, "open-threads") + if not os.path.isdir(tdir): + return [] + return [ + (os.path.basename(f), read_text(f)) + for f in sorted(glob.glob(os.path.join(tdir, "*.md"))) + ] + + def load_repo(root): - """Read the memory/ layer. Returns (cont, pinned, arch, extra, sessions, refs).""" + """Read the memory/ layer. Returns (cont, pinned, arch, extra, sessions, refs, threads).""" mem = os.path.join(root, "memory") cont_text = read_text(os.path.join(mem, "continuity.md")) cont = parse_footers(cont_text) pinned = pinned_open_threads(cont_text) + threads = load_thread_files(mem) + for _, ttext in threads: + cont.update(parse_footers(ttext)) + pinned |= pinned_open_threads(ttext) + archive_text = "" for f in glob.glob(os.path.join(mem, "archive", "*.md")): if os.path.basename(f).upper().startswith("INDEX"): @@ -168,7 +188,7 @@ def load_repo(root): sessions = sorted(glob.glob(os.path.join(mem, "sessions", "*.md"))) refs = [memref_ids(read_text(s)) for s in sessions] - return cont, pinned, arch, extra, sessions, refs + return cont, pinned, arch, extra, sessions, refs, threads def make_sslu(refs): @@ -264,7 +284,10 @@ def check_conflict_markers(root): out = [] mem = os.path.join(root, "memory") marker = re.compile(r"^(<{7}|>{7}|\|{7})(\s|$)") - for path in sorted(glob.glob(os.path.join(mem, "*.md"))): + live = sorted(glob.glob(os.path.join(mem, "*.md"))) + sorted( + glob.glob(os.path.join(mem, "open-threads", "*.md")) # thread files are live truth too (v4.39.0) + ) + for path in live: for i, line in enumerate(read_text(path).splitlines(), 1): if marker.match(line): rel = os.path.relpath(path, root) @@ -276,6 +299,88 @@ def check_conflict_markers(root): return out +def check_thread_files(threads): + # (12) the thread-file contract (v4.39.0): memory/open-threads/ holds ONE Open Thread + # per file, named thread-.md after its footer id. Filename = identity is what makes + # concurrent thread work merge-free (parallel branches touch different files), so drift + # here is an ERROR, not style: a wrong name or a second block re-creates the shared-file + # conflict surface this layout exists to remove. + out = [] + for name, text in threads: + rel = f"memory/open-threads/{name}" + footers = FOOTER_RE.findall(text) + if not footers: + out.append(f"[thread-file] {rel} has no fact footer — a thread file carries exactly one ``") + continue + if len(footers) > 1: + out.append(f"[thread-file] {rel} holds {len(footers)} footers — one thread per file; split it") + continue + fid = footers[0][0] + expect = f"thread-{fid}.md" + if name != expect: + out.append(f"[thread-file] {rel} should be named {expect} (filename = the footer id)") + first = next((ln for ln in text.splitlines() if ln.strip()), "") + if not first.startswith(("- [ ]", "- [x]", "- [X]")): + out.append(f"[thread-file] {rel} does not start with a `- [ ]`/`- [x]` bullet — file content is exactly the thread block") + return out + + +def check_duplicate_ids(cont_text, threads): + # (13) an id exists exactly ONCE across the live layer (continuity + thread files). + # Two live footers with one id is the silent-fork shape a same-id creation collision + # on parallel branches (or a bad hand-merge) produces — [both] covers live-vs-archive, + # this covers live-vs-live. Without it, parse_footers' id-keyed map hides the twin. + where = {} + surfaces = [("memory/continuity.md", cont_text)] + [ + (f"memory/open-threads/{n}", txt) for n, txt in threads + ] + for src, text in surfaces: + for m in FOOTER_RE.finditer(text): + where.setdefault(m.group(1), []).append(src) + return [ + f"[duplicate-id] {fid} has {len(srcs)} footers across the live layer " + f"({', '.join(srcs)}) — an id exists exactly once; merge the copies or re-id one" + for fid, srcs in sorted(where.items()) + if len(srcs) > 1 + ] + + +def check_duplicate_state_keys(root): + # (14) `## Project State` holds SCALARS — one value each, latest wins. This is the + # backstop for a union-style hand merge that kept both sides of a bumped scalar, or a + # hand-edited header. Deliberately scoped to `## Project State`: a repeated key anywhere + # else is a bullet, not a scalar, and repetition there is legitimate. + # (Absorbed from PR #27 — credit: Roland Heusser.) + out = [] + path = os.path.join(root, "memory", "continuity.md") + if not os.path.isfile(path): + return out + in_state = False + seen = {} + key_re = re.compile(r"^-\s+\*\*([a-z_]+):\*\*") + for i, line in enumerate(read_text(path).splitlines(), 1): + if line.startswith("## "): + if in_state: + break + in_state = line.strip() == "## Project State" + continue + if not in_state: + continue + m = key_re.match(line) + if not m: + continue + key = m.group(1) + if key in seen: + out.append( + f"[duplicate-state-key] memory/continuity.md:{i} '{key}' is set twice " + f"(also line {seen[key]}) — Project State fields are scalars. Usually a union " + f"merge keeping both sides: delete the stale line, keeping the later value." + ) + else: + seen[key] = i + return out + + def check_dangling(allf): # (4) supersession links resolve out = [] @@ -416,10 +521,13 @@ def closed_narrative_lines(cont_text): return count -def check_closed_thread_bloat(cont_text, cap): +def check_closed_thread_bloat(cont_text, cap, threads=()): # (11) advisory: completed threads should wait out archive_window as terse # stubs (3–6 lines), not full ship narratives — REVIEW.md condenses them. - n = closed_narrative_lines(cont_text) + # Measured across every live surface: continuity + the thread files (v4.39.0). + n = closed_narrative_lines(cont_text) + sum( + closed_narrative_lines(txt) for _, txt in threads + ) if n <= cap: return [] return [ @@ -633,6 +741,7 @@ def check_secret_material(root): mem = os.path.join(root, "memory") files = ( sorted(glob.glob(os.path.join(mem, "*.md"))) + + sorted(glob.glob(os.path.join(mem, "open-threads", "*.md"))) + sorted(glob.glob(os.path.join(mem, "sessions", "*.md"))) + sorted(glob.glob(os.path.join(mem, "archive", "*.md"))) ) @@ -658,7 +767,7 @@ def scan_secret_files(paths): def report(cont, arch, sessions, acw, aw, warns, errors, strict): print( - f"memory-lint: {len(cont)} continuity facts, {len(arch)} archived, " + f"memory-lint: {len(cont)} live facts (continuity + open-threads), {len(arch)} archived, " f"{len(sessions)} sessions; windows active={acw} archive={aw}" ) for line in warns: @@ -693,19 +802,21 @@ def main(): print("memory-lint: could not find memory/continuity.md", file=sys.stderr) return 2 - cont, pinned, arch, extra, sessions, refs = load_repo(root) + cont, pinned, arch, extra, sessions, refs, threads = load_repo(root) w = load_windows(root) aw, acw = w["archive_window"], w["active_window"] sslu = make_sslu(refs) cont_text = read_text(os.path.join(root, "memory", "continuity.md")) cont_lines = len(cont_text.splitlines()) - errors = ( check_duplicates(cont, arch) + check_over_archived(arch, sslu, aw) + check_version_manifest(root) + check_conflict_markers(root) + + check_thread_files(threads) + + check_duplicate_ids(cont_text, threads) + + check_duplicate_state_keys(root) ) stems = [os.path.basename(s)[:-3] for s in sessions] overdue = check_overdue(cont, pinned, sslu, aw) @@ -721,7 +832,7 @@ def main(): w["review_every"], w["continuity_max_facts"], w["continuity_max_lines"], pinned, archivable, ) - + check_closed_thread_bloat(cont_text, w["closed_narrative_max_lines"]) + + check_closed_thread_bloat(cont_text, w["closed_narrative_max_lines"], threads) + check_stale_metadata(cont, pinned, refs, stems, w["working_window"], acw, aw) + check_secret_material(root) ) diff --git a/agent-skills/memory-lint/scripts/test_memory_lint.mjs b/agent-skills/memory-lint/scripts/test_memory_lint.mjs index 221d474..476d320 100644 --- a/agent-skills/memory-lint/scripts/test_memory_lint.mjs +++ b/agent-skills/memory-lint/scripts/test_memory_lint.mjs @@ -22,6 +22,9 @@ import { closed_narrative_lines, check_closed_thread_bloat, load_windows, + check_thread_files, + check_duplicate_ids, + check_duplicate_state_keys, } from "./memory-lint.mjs"; // (8) advisory cadence/size triggers (v4.24.0). cont is a Map; cont.size is the fact count. @@ -897,3 +900,172 @@ test("shipped scripts: prose identifiers stay scanner-neutral", () => { } assert.deepEqual(offenders, []); }); + +// (12) the thread-file contract (v4.39.0): one Open Thread per file, named after its +// footer id. Filename = identity is what makes concurrent thread work merge-free. +const VALID_THREAD = `- [ ] **Ship it.** The plan. + +`; + +test("thread-file: valid file passes", () => { + assert.deepEqual(check_thread_files([["thread-ship-it.md", VALID_THREAD]]), []); +}); + +test("thread-file: misnamed file flagged", () => { + const out = check_thread_files([["thread-wrong-name.md", VALID_THREAD]]); + assert.equal(out.length, 1); + assert.ok(out[0].includes("[thread-file]")); + assert.ok(out[0].includes("should be named thread-ship-it.md")); +}); + +test("thread-file: missing footer flagged", () => { + const out = check_thread_files([["thread-x.md", "- [ ] no footer here\n"]]); + assert.equal(out.length, 1); + assert.ok(out[0].includes("no fact footer")); +}); + +test("thread-file: two footers flagged", () => { + const two = + VALID_THREAD + + "- [ ] second\n \n"; + const out = check_thread_files([["thread-ship-it.md", two]]); + assert.equal(out.length, 1); + assert.ok(out[0].includes("holds 2 footers")); +}); + +test("thread-file: non-bullet start flagged", () => { + const out = check_thread_files([["thread-ship-it.md", "# A heading instead of the block\n" + VALID_THREAD]]); + assert.equal(out.length, 1); + assert.ok(out[0].includes("does not start with")); +}); + +// (13) an id exists exactly once across the live layer — the backstop for a same-id +// creation collision on parallel branches (the silent-fork shape). +test("duplicate-id: unique ids pass", () => { + const cont = "- fact\n \n"; + const threads = [["thread-b-thread.md", "- [ ] t\n \n"]]; + assert.deepEqual(check_duplicate_ids(cont, threads), []); +}); + +test("duplicate-id: continuity + thread file flagged", () => { + const cont = "- fact\n \n"; + const threads = [["thread-same-id.md", "- [ ] t\n \n"]]; + const out = check_duplicate_ids(cont, threads); + assert.equal(out.length, 1); + assert.ok(out[0].includes("[duplicate-id] same-id has 2 footers")); + assert.ok(out[0].includes("memory/open-threads/thread-same-id.md")); +}); + +test("duplicate-id: two thread files flagged", () => { + const threads = [ + ["thread-same-id.md", "- [ ] t\n \n"], + ["thread-other.md", "- [ ] t2\n \n"], + ]; + const out = check_duplicate_ids("# Continuity\n", threads); + assert.equal(out.length, 1); + assert.ok(out[0].includes("same-id")); +}); + +// (14) Project State fields are scalars — absorbed from PR #27 (Roland Heusser): +// the backstop for a union-style hand merge that kept both sides of a bumped scalar. +function stateRoot(cont_text) { + const root = mkdtempSync(join(tmpdir(), "memlint-state-")); + mkdirSync(join(root, "memory"), { recursive: true }); + writeFileSync(join(root, "memory", "continuity.md"), cont_text); + return root; +} + +test("duplicate-state-key: duplicate scalar flagged with both lines", () => { + const root = stateRoot( + "# C\n\n## Project State\n\n- **project:** x\n- **last_review:** 2026-08-01\n" + + "- **last_review:** 2026-08-20\n\n## Key Decisions\n" + ); + try { + const out = check_duplicate_state_keys(root); + assert.equal(out.length, 1); + assert.ok(out[0].includes("[duplicate-state-key]")); + assert.ok(out[0].includes("'last_review' is set twice")); + assert.ok(out[0].includes("also line 6")); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("duplicate-state-key: repeated key outside Project State ok", () => { + const root = stateRoot( + "# C\n\n## Project State\n\n- **project:** x\n\n## Key Decisions\n\n" + + "- **project:** mention one\n- **project:** mention two\n" + ); + try { + assert.deepEqual(check_duplicate_state_keys(root), []); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("duplicate-state-key: unique scalars ok", () => { + const root = stateRoot("# C\n\n## Project State\n\n- **project:** x\n- **status:** y\n"); + try { + assert.deepEqual(check_duplicate_state_keys(root), []); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +// Thread files are live continuity-domain facts: load_repo merges their footers and +// checkbox pinning; conflict markers and closed-thread bloat see them too. +function threadLayer() { + const root = mkdtempSync(join(tmpdir(), "memlint-threads-")); + mkdirSync(join(root, "memory", "sessions"), { recursive: true }); + mkdirSync(join(root, "memory", "open-threads"), { recursive: true }); + writeFileSync(join(root, "memory", "continuity.md"), "# Continuity\n\n## Project State\n\n- **project:** t\n"); + return root; +} + +test("thread layer: load_repo merges thread facts and pins", () => { + const root = threadLayer(); + try { + writeFileSync( + join(root, "memory", "open-threads", "thread-live-gap.md"), + "- [ ] **Gap.** open work\n \n" + ); + writeFileSync( + join(root, "memory", "open-threads", "thread-done-gap.md"), + "- [x] **Done.** closed work\n \n" + ); + const { cont, pinned, threads } = load_repo(root); + assert.ok(cont.has("live-gap")); + assert.ok(cont.has("done-gap")); + assert.ok(pinned.has("live-gap")); // unchecked -> pinned, never decays + assert.ok(!pinned.has("done-gap")); // checked -> decay-eligible for the sweep + assert.equal(threads.length, 2); + assert.deepEqual(check_thread_files(threads), []); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("thread layer: conflict marker in a thread file is an error", () => { + const root = threadLayer(); + try { + writeFileSync( + join(root, "memory", "open-threads", "thread-t.md"), + "- [ ] t\n<<<<<<< HEAD\n \n" + ); + const out = check_conflict_markers(root); + assert.equal(out.length, 1); + assert.ok(out[0].includes("memory/open-threads/thread-t.md:2")); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("thread layer: closed bloat counts thread files", () => { + // 4 closed-record lines in continuity + 4 in a thread file > cap 6 -> flagged once. + const cont_text = "- [x] closed A\n line\n line\n \n"; + const threads = [["thread-b.md", "- [x] closed B\n line\n line\n \n"]]; + const out = check_closed_thread_bloat(cont_text, 6, threads); + assert.equal(out.length, 1); + assert.ok(out[0].includes("8 line(s)")); + assert.deepEqual(check_closed_thread_bloat(cont_text, 8, threads), []); +}); diff --git a/agent-skills/memory-lint/scripts/test_memory_lint.py b/agent-skills/memory-lint/scripts/test_memory_lint.py index 302f4d8..fa22f44 100644 --- a/agent-skills/memory-lint/scripts/test_memory_lint.py +++ b/agent-skills/memory-lint/scripts/test_memory_lint.py @@ -120,7 +120,7 @@ def test_supersession_target_in_vision_is_not_dangling(self): """) os.makedirs(os.path.join(mem, "sessions"), exist_ok=True) - cont, pinned, arch, extra, sessions, refs = memory_lint.load_repo(root) + cont, pinned, arch, extra, sessions, refs, threads = memory_lint.load_repo(root) # the vision fact is available for link resolution but NOT counted as a fact self.assertIn("new-fact", extra) self.assertNotIn("new-fact", cont) @@ -789,5 +789,141 @@ def test_scanner_neutral_identifiers(self): self.assertEqual(offenders, []) +class TestThreadFiles(unittest.TestCase): + # (12) the thread-file contract (v4.39.0): one Open Thread per file, named after its + # footer id. Filename = identity is what makes concurrent thread work merge-free. + VALID = """- [ ] **Ship it.** The plan. + +""" + + def test_valid_thread_file_passes(self): + self.assertEqual(memory_lint.check_thread_files([("thread-ship-it.md", self.VALID)]), []) + + def test_misnamed_file_flagged(self): + out = memory_lint.check_thread_files([("thread-wrong-name.md", self.VALID)]) + self.assertEqual(len(out), 1) + self.assertIn("[thread-file]", out[0]) + self.assertIn("should be named thread-ship-it.md", out[0]) + + def test_missing_footer_flagged(self): + out = memory_lint.check_thread_files([("thread-x.md", "- [ ] no footer here\n")]) + self.assertEqual(len(out), 1) + self.assertIn("no fact footer", out[0]) + + def test_two_footers_flagged(self): + two = self.VALID + "- [ ] second\n \n" + out = memory_lint.check_thread_files([("thread-ship-it.md", two)]) + self.assertEqual(len(out), 1) + self.assertIn("holds 2 footers", out[0]) + + def test_non_bullet_start_flagged(self): + text = "# A heading instead of the block\n" + self.VALID + out = memory_lint.check_thread_files([("thread-ship-it.md", text)]) + self.assertEqual(len(out), 1) + self.assertIn("does not start with", out[0]) + + +class TestDuplicateIds(unittest.TestCase): + # (13) an id exists exactly once across the live layer — the backstop for a same-id + # creation collision on parallel branches (the silent-fork shape). + def test_unique_ids_pass(self): + cont = "- fact\n \n" + threads = [("thread-b-thread.md", "- [ ] t\n \n")] + self.assertEqual(memory_lint.check_duplicate_ids(cont, threads), []) + + def test_id_in_continuity_and_thread_file_flagged(self): + cont = "- fact\n \n" + threads = [("thread-same-id.md", "- [ ] t\n \n")] + out = memory_lint.check_duplicate_ids(cont, threads) + self.assertEqual(len(out), 1) + self.assertIn("[duplicate-id] same-id has 2 footers", out[0]) + self.assertIn("memory/open-threads/thread-same-id.md", out[0]) + + def test_id_in_two_thread_files_flagged(self): + threads = [ + ("thread-same-id.md", "- [ ] t\n \n"), + ("thread-other.md", "- [ ] t2\n \n"), + ] + out = memory_lint.check_duplicate_ids("# Continuity\n", threads) + self.assertEqual(len(out), 1) + self.assertIn("same-id", out[0]) + + +class TestDuplicateStateKeys(unittest.TestCase): + # (14) Project State fields are scalars — absorbed from PR #27 (Roland Heusser): + # the backstop for a union-style hand merge that kept both sides of a bumped scalar. + def _root(self, cont_text): + d = tempfile.mkdtemp() + os.makedirs(os.path.join(d, "memory"), exist_ok=True) + with open(os.path.join(d, "memory", "continuity.md"), "w", encoding="utf-8") as f: + f.write(cont_text) + return d + + def test_duplicate_scalar_flagged_with_both_lines(self): + root = self._root( + "# C\n\n## Project State\n\n- **project:** x\n- **last_review:** 2026-08-01\n" + "- **last_review:** 2026-08-20\n\n## Key Decisions\n" + ) + out = memory_lint.check_duplicate_state_keys(root) + self.assertEqual(len(out), 1) + self.assertIn("[duplicate-state-key]", out[0]) + self.assertIn("'last_review' is set twice", out[0]) + self.assertIn("also line 6", out[0]) + + def test_repeated_key_outside_project_state_ok(self): + root = self._root( + "# C\n\n## Project State\n\n- **project:** x\n\n## Key Decisions\n\n" + "- **project:** mention one\n- **project:** mention two\n" + ) + self.assertEqual(memory_lint.check_duplicate_state_keys(root), []) + + def test_unique_scalars_ok(self): + root = self._root("# C\n\n## Project State\n\n- **project:** x\n- **status:** y\n") + self.assertEqual(memory_lint.check_duplicate_state_keys(root), []) + + +class TestThreadLayerIntegration(unittest.TestCase): + # Thread files are live continuity-domain facts: load_repo merges their footers and + # checkbox pinning; conflict markers and closed-thread bloat see them too. + def _layer(self): + root = tempfile.mkdtemp() + os.makedirs(os.path.join(root, "memory", "sessions"), exist_ok=True) + os.makedirs(os.path.join(root, "memory", "open-threads"), exist_ok=True) + with open(os.path.join(root, "memory", "continuity.md"), "w", encoding="utf-8") as f: + f.write("# Continuity\n\n## Project State\n\n- **project:** t\n") + return root + + def test_load_repo_merges_thread_facts_and_pins(self): + root = self._layer() + with open(os.path.join(root, "memory", "open-threads", "thread-live-gap.md"), "w", encoding="utf-8") as f: + f.write("- [ ] **Gap.** open work\n \n") + with open(os.path.join(root, "memory", "open-threads", "thread-done-gap.md"), "w", encoding="utf-8") as f: + f.write("- [x] **Done.** closed work\n \n") + cont, pinned, arch, extra, sessions, refs, threads = memory_lint.load_repo(root) + self.assertIn("live-gap", cont) + self.assertIn("done-gap", cont) + self.assertIn("live-gap", pinned) # unchecked -> pinned, never decays + self.assertNotIn("done-gap", pinned) # checked -> decay-eligible for the sweep + self.assertEqual(len(threads), 2) + self.assertEqual(memory_lint.check_thread_files(threads), []) + + def test_conflict_marker_in_thread_file_is_error(self): + root = self._layer() + with open(os.path.join(root, "memory", "open-threads", "thread-t.md"), "w", encoding="utf-8") as f: + f.write("- [ ] t\n<<<<<<< HEAD\n \n") + out = memory_lint.check_conflict_markers(root) + self.assertEqual(len(out), 1) + self.assertIn("memory/open-threads/thread-t.md:2", out[0]) + + def test_closed_bloat_counts_thread_files(self): + # 4 closed-record lines in continuity + 4 in a thread file > cap 6 -> flagged once. + cont_text = "- [x] closed A\n line\n line\n \n" + threads = [("thread-b.md", "- [x] closed B\n line\n line\n \n")] + out = memory_lint.check_closed_thread_bloat(cont_text, 6, threads) + self.assertEqual(len(out), 1) + self.assertIn("8 line(s)", out[0]) + self.assertEqual(memory_lint.check_closed_thread_bloat(cont_text, 8, threads), []) + + if __name__ == "__main__": unittest.main() diff --git a/agent-skills/refresh-metadata/SKILL.md b/agent-skills/refresh-metadata/SKILL.md index d3ae4d4..c6bfa97 100644 --- a/agent-skills/refresh-metadata/SKILL.md +++ b/agent-skills/refresh-metadata/SKILL.md @@ -8,7 +8,8 @@ provenance: agent-memory-builtin > in place — fork under a new name, or upstream a fix to the agent-memory project (see `SKILLS.md`). This skill performs **REVIEW.md steps 2–3 (apply events + re-tier)** as a **runnable script**. For every -fact in `continuity.md` it recomputes `last_used`, `uses`, and `tier` from the `## Memory References` across +fact in `continuity.md` **and every `memory/open-threads/thread-.md`** (v4.39.0) it recomputes +`last_used`, `uses`, and `tier` from the `## Memory References` across `memory/sessions/`, and writes the footers back. This is the **"full rebuild" path** `REVIEW.md` already calls *"deterministic and reproducible by any agent"* — pure arithmetic, no judgment — so it's safe to mechanize. **Agents routinely skip this pass** (they archive faded facts but don't re-tier the ones that @@ -41,7 +42,9 @@ node agent-skills/refresh-metadata/scripts/refresh-metadata.mjs [--dry-run] - `--dry-run` — print the footers that *would* change (tier / uses), change nothing. **Preview first.** -It reads `continuity.md` into memory and writes once (truncate-before-read is impossible). For each fact: +It reads each fact surface (`continuity.md` + the thread files) into memory and writes each once +(truncate-before-read is impossible); thread files refresh **in place**, so their merge-free +filenames never churn. For each fact: `uses` = number of sessions that reference it; `last_used` = the latest such session's date; `tier` = DECAY.md §5 applied to `sessions_since_last_used` (clamped at `archive-candidate`). Idempotent — a second run reports "nothing to refresh." diff --git a/agent-skills/refresh-metadata/scripts/refresh-metadata.mjs b/agent-skills/refresh-metadata/scripts/refresh-metadata.mjs index 8af844e..6ee3f08 100644 --- a/agent-skills/refresh-metadata/scripts/refresh-metadata.mjs +++ b/agent-skills/refresh-metadata/scripts/refresh-metadata.mjs @@ -103,16 +103,33 @@ export function expected_tier(fields, fid, sslu_val, uses_val, created_ago, pinn return "archive-candidate"; } +export function fact_surfaces(root) { + // Every live file carrying fact footers: continuity.md plus the one-thread-per-file + // memory/open-threads/ layer (v4.39.0). Footers refresh identically everywhere — + // thread files edit in place, so their merge-free filenames never churn. + const mem = join(root, "memory"); + const paths = [join(mem, "continuity.md")]; + const tdir = join(mem, "open-threads"); + if (existsSync(tdir)) { + for (const f of readdirSync(tdir).filter((x) => x.endsWith(".md")).sort()) { + paths.push(join(tdir, f)); + } + } + return paths; +} + export function refresh(root, dry_run) { - const cont_path = join(root, "memory", "continuity.md"); - const text = read_text(cont_path); + const surfaces = fact_surfaces(root).map((p) => [p, read_text(p)]); const { stems, refs } = load_sessions(root); - const pinned = pinned_open_threads(text); + const pinned = new Set(); + for (const [, text] of surfaces) { + for (const fid of pinned_open_threads(text)) pinned.add(fid); + } const w = load_windows(root); const ww = w.working_window, acw = w.active_window; const changes = []; - const new_text = text.replace(FOOTER_RE, (full, fid, blob) => { + const footer_repl = (full, fid, blob) => { const fields = parse_fields(blob); if (fields.tier === "core" || fields.tier === "superseded" || fields["superseded-by"]) return full; const hits = []; @@ -130,7 +147,14 @@ export function refresh(root, dry_run) { if ("tier" in fields && tier) next = next.replace(/(\btier:\s*)[a-z-]+/, `$1${tier}`); if (next !== full) changes.push([fid, fields.tier, tier, fields.uses, String(uses_val)]); return next; - }); + }; + + const writes = []; // [path, new_text] for surfaces whose footers changed + for (const [path, text] of surfaces) { + const before = changes.length; + const new_text = text.replace(FOOTER_RE, footer_repl); + if (changes.length > before) writes.push([path, new_text]); + } if (changes.length === 0) return { code: 0, msg: "all fact footers already match the reference log — nothing to refresh" }; @@ -143,7 +167,9 @@ export function refresh(root, dry_run) { const summary = lines.join("\n"); if (dry_run) return { code: 0, msg: `DRY-RUN — would refresh ${changes.length} footer(s):\n${summary}\n(no files changed)` }; - writeFileSync(cont_path, new_text, "utf-8"); // safe: new_text already in memory + for (const [path, new_text] of writes) { + writeFileSync(path, new_text, "utf-8"); // safe: new_text already in memory + } return { code: 0, msg: `refreshed ${changes.length} footer(s) from the reference log:\n${summary}\nNow run memory-lint to confirm.` }; } diff --git a/agent-skills/refresh-metadata/scripts/refresh-metadata.py b/agent-skills/refresh-metadata/scripts/refresh-metadata.py index 0a9f31e..3b95ac0 100644 --- a/agent-skills/refresh-metadata/scripts/refresh-metadata.py +++ b/agent-skills/refresh-metadata/scripts/refresh-metadata.py @@ -123,11 +123,26 @@ def load_windows(root): return w +def fact_surfaces(root): + """Every live file carrying fact footers: continuity.md plus the one-thread-per-file + memory/open-threads/ layer (v4.39.0). Footers refresh identically everywhere — + thread files edit in place, so their merge-free filenames never churn.""" + mem = os.path.join(root, "memory") + paths = [os.path.join(mem, "continuity.md")] + tdir = os.path.join(mem, "open-threads") + if os.path.isdir(tdir): + paths += sorted( + os.path.join(tdir, f) for f in os.listdir(tdir) if f.endswith(".md") + ) + return paths + + def refresh(root, dry_run): - cont_path = os.path.join(root, "memory", "continuity.md") - text = read_text(cont_path) + surfaces = [(p, read_text(p)) for p in fact_surfaces(root)] stems, refs = load_sessions(root) - pinned = pinned_open_threads(text) + pinned = set() + for _, text in surfaces: + pinned |= pinned_open_threads(text) w = load_windows(root) ww, acw = w["working_window"], w["active_window"] @@ -157,7 +172,12 @@ def footer_repl(m): return new changes = [] - new_text = FOOTER_RE.sub(footer_repl, text) + writes = [] # (path, new_text) for surfaces whose footers changed + for path, text in surfaces: + before = len(changes) + new_text = FOOTER_RE.sub(footer_repl, text) + if len(changes) > before: + writes.append((path, new_text)) if not changes: return 0, "all fact footers already match the reference log — nothing to refresh" @@ -174,8 +194,9 @@ def footer_repl(m): if dry_run: return 0, f"DRY-RUN — would refresh {len(changes)} footer(s):\n{summary}\n(no files changed)" - with open(cont_path, "w", encoding="utf-8") as f: # safe: new_text already in memory - f.write(new_text) + for path, new_text in writes: + with open(path, "w", encoding="utf-8") as f: # safe: new_text already in memory + f.write(new_text) return 0, f"refreshed {len(changes)} footer(s) from the reference log:\n{summary}\nNow run memory-lint to confirm." diff --git a/agent-skills/refresh-metadata/scripts/test_refresh_metadata.mjs b/agent-skills/refresh-metadata/scripts/test_refresh_metadata.mjs index 37ec7af..1bc4114 100644 --- a/agent-skills/refresh-metadata/scripts/test_refresh_metadata.mjs +++ b/agent-skills/refresh-metadata/scripts/test_refresh_metadata.mjs @@ -105,3 +105,60 @@ test("idempotent — second run is a no-op", () => { assert.ok(msg.includes("nothing to refresh")); } finally { rmSync(root, { recursive: true, force: true }); } }); + +// v4.39.0: thread files under memory/open-threads/ are fact surfaces too — their +// footers refresh in place (the merge-free filename never churns), and an unchecked +// thread in a FILE pins its id exactly as it did in continuity. +function threadSetup() { + const root = mkdtempSync(join(tmpdir(), "rmtest-threads-")); + mkdirSync(join(root, "memory", "sessions"), { recursive: true }); + mkdirSync(join(root, "memory", "open-threads"), { recursive: true }); + writeFileSync(join(root, "memory", "continuity.md"), "# Continuity\n\n## Project State\n\n- **project:** t\n"); + writeFileSync(join(root, "memory", "decay-policy.md"), POLICY); + writeFileSync( + join(root, "memory", "open-threads", "thread-open-gap.md"), + "- [ ] open gap\n \n" + ); + writeFileSync( + join(root, "memory", "open-threads", "thread-closed-gap.md"), + "- [x] closed gap\n \n" + ); + const sessions = { + "2026-06-01-000000": ["closed-gap"], + "2026-06-02-000000": [], "2026-06-03-000000": [], "2026-06-04-000000": [], + "2026-06-05-000000": [], "2026-06-06-000000": ["open-gap"], + }; + for (const [stem, ids] of Object.entries(sessions)) { + const body = "# Session\n\n## Memory References\n" + ids.map((i) => `- Referenced: ${i}\n`).join(""); + writeFileSync(join(root, "memory", "sessions", stem + ".md"), body); + } + return root; +} + +function threadFields(root, fname, fid) { + const text = readFileSync(join(root, "memory", "open-threads", fname), "utf-8"); + const m = text.match(new RegExp(String.raw``)); + const fields = {}; + for (const part of m[1].split("|")) { + const i = part.indexOf(":"); + if (i !== -1) fields[part.slice(0, i).trim()] = part.slice(i + 1).trim(); + } + return fields; +} + +test("thread-file footers refresh in place", () => { + const root = threadSetup(); + try { + const { code } = refresh(root, false); + assert.equal(code, 0); + // closed thread: not pinned -> re-tiered from the reference log (sslu 5 > aw 4) + assert.equal(threadFields(root, "thread-closed-gap.md", "closed-gap").tier, "archive-candidate"); + // open thread: pinned -> tier label preserved, factual fields still refresh + const opened = threadFields(root, "thread-open-gap.md", "open-gap"); + assert.equal(opened.tier, "working"); + assert.equal(opened.last_used, "2026-06-06"); + // idempotent second run + const { msg } = refresh(root, false); + assert.ok(msg.includes("nothing to refresh")); + } finally { rmSync(root, { recursive: true, force: true }); } +}); diff --git a/agent-skills/refresh-metadata/scripts/test_refresh_metadata.py b/agent-skills/refresh-metadata/scripts/test_refresh_metadata.py index a276835..6d66670 100644 --- a/agent-skills/refresh-metadata/scripts/test_refresh_metadata.py +++ b/agent-skills/refresh-metadata/scripts/test_refresh_metadata.py @@ -105,5 +105,60 @@ def test_idempotent(self): self.assertIn("nothing to refresh", msg) # second run is a no-op +class TestThreadFileRefresh(unittest.TestCase): + # v4.39.0: thread files under memory/open-threads/ are fact surfaces too — their + # footers refresh in place (the merge-free filename never churns), and an unchecked + # thread in a FILE pins its id exactly as it did in continuity. + def _setup(self): + root = tempfile.mkdtemp() + os.makedirs(os.path.join(root, "memory", "sessions")) + os.makedirs(os.path.join(root, "memory", "open-threads")) + with open(os.path.join(root, "memory", "continuity.md"), "w") as f: + f.write("# Continuity\n\n## Project State\n\n- **project:** t\n") + with open(os.path.join(root, "memory", "decay-policy.md"), "w") as f: + f.write(POLICY) + with open(os.path.join(root, "memory", "open-threads", "thread-open-gap.md"), "w") as f: + f.write("- [ ] open gap\n \n") + with open(os.path.join(root, "memory", "open-threads", "thread-closed-gap.md"), "w") as f: + f.write("- [x] closed gap\n \n") + sessions = { + "2026-06-01-000000": ["closed-gap"], + "2026-06-02-000000": [], "2026-06-03-000000": [], "2026-06-04-000000": [], + "2026-06-05-000000": [], "2026-06-06-000000": ["open-gap"], + } + for stem, ids in sessions.items(): + body = "# Session\n\n## Memory References\n" + "".join(f"- Referenced: {i}\n" for i in ids) + with open(os.path.join(root, "memory", "sessions", stem + ".md"), "w") as f: + f.write(body) + return root + + def _fields(self, root, fname, fid): + text = open(os.path.join(root, "memory", "open-threads", fname)).read() + m = re.search(r"", text) + fields = {} + for p in m.group(1).split("|"): + if ":" in p: + k, _, v = p.partition(":") + fields[k.strip()] = v.strip() + return fields + + def test_thread_file_footers_refresh_in_place(self): + root = self._setup() + code, msg = rm.refresh(root, False) + self.assertEqual(code, 0) + # closed thread: not pinned -> re-tiered from the reference log (sslu 5 > aw 4) + closed = self._fields(root, "thread-closed-gap.md", "closed-gap") + self.assertEqual(closed["tier"], "archive-candidate") + # open thread: pinned -> tier label preserved, factual fields still refresh + opened = self._fields(root, "thread-open-gap.md", "open-gap") + self.assertEqual(opened["tier"], "working") + self.assertEqual(opened["last_used"], "2026-06-06") + # filenames never churn + self.assertTrue(os.path.isfile(os.path.join(root, "memory", "open-threads", "thread-open-gap.md"))) + # idempotent second run + code, msg = rm.refresh(root, False) + self.assertIn("nothing to refresh", msg) + + if __name__ == "__main__": unittest.main() diff --git a/docs/DESIGN-merge-scale.md b/docs/DESIGN-merge-scale.md new file mode 100644 index 0000000..feebef3 --- /dev/null +++ b/docs/DESIGN-merge-scale.md @@ -0,0 +1,94 @@ +# Design — Merge-scale memory: threads as files (v4.39.0) + +> Design record for the merge-conflict reduction iteration. Serves the +> `bp-multi-user` Blueprint gap → `vision-agent-memory` ("multi-contributor by +> design"). Tool-only; read on demand. Origin: maintainer field reports of regular +> `continuity.md` merge conflicts as team adoption grew, plus PR #27's field +> artifact (two stacked branches, both appending an Open Thread and bumping +> `last_session`). + +## Problem + +`memory/continuity.md` is the one file every teammate edits on every branch, and its +`## Open Threads` section is where the churn concentrates: multi-line thread blocks +appended and edited at the same anchor collide constantly. `last_session` bumps on +nearly every session, so parallel branches almost always conflicted on it too. At +R&D scale this was an annoyance; at team scale it is a per-merge tax. + +## Decision: structure over machinery + +Two candidate shapes were evaluated against a maintainer preference for minimalist +design: + +1. **A custom git merge driver** (PR #27) that auto-applies MERGE.md's Tier 1 rules + (union the accreting sections, later-value the scalars). Rejected: verified + against the branch, whole-file `--union` silently merges Tier 2 semantic clashes + (both sides editing the *same* thread emerged as two checkbox lines sharing one + footer — no conflict, no Contradiction, invisible to `memory-lint`), and dateless + scalars (`status`) resolve as a direction-dependent silent winner-pick. Fixing + that honestly needs a diff3 hunk classifier, at which point the driver stops + being small — and it only runs on clones that registered it (`init.sh`), so merge + behavior would silently differ per clone. The `[duplicate-state-key]` lint check + is absorbed from the PR with credit; a scalars-only driver remains the pre-scoped + follow-up if field pain persists. +2. **A structural layout change** — remove the conflict surface instead of resolving + it. **Chosen**: no merge-time code, no per-clone registration, no partial-coverage + mode; pure file layout, the same construction that already makes `sessions/` + conflict-free. + +## The shape + +- **One thread, one file:** `memory/open-threads/thread-.md`, where `` is the + thread's existing kebab fact id. File content is exactly the bullet block that + previously sat in continuity — checkbox line, body, `serves:` trace, metadata + footer. Nothing else. Migration is a verbatim cut-paste per thread. +- **Stable id filenames, not timestamps.** A timestamped delete-and-recreate scheme + was considered (mirroring session logs) and rejected: session logs are immutable + events, threads are mutable state. Delete+recreate converts a visible same-thread + conflict into a silent fork (both branches recreate under different names, git + merges cleanly, two divergent copies survive), churns filenames on every review + metadata refresh, and divorces filename from fact identity. With stable names, + parallel work on *different* threads cannot conflict (different files), and both + sides editing the *same* thread conflicts per-file — which is MERGE.md Tier 2 + reaching a human, the required behavior, not a gap. +- **No index.** The directory is the index, exactly like `sessions/`: an index line + per thread in continuity would recreate the add/add conflict, one line at a time. + Discovery is `ls memory/open-threads/`; open vs closed is the checkbox in each + file (`grep -l '^- \[ \]' memory/open-threads/` lists the open ones). The + activation protocol lists the directory and reads unchecked threads — presence by + protocol, at parity with when threads sat in the always-imported continuity. +- **`last_session` is dropped** from Project State: it is fully derivable (newest + `sessions/` filename; the agent name is that log's `**Agent:**` header), and it was + the most frequent scalar conflict. The multi-agent continuity check reads the + newest log instead. Installed repos delete the line at upgrade (mechanical, safe). +- **Archive files union-merge:** `memory/archive/*.md merge=union` in + `.gitattributes`. These are append-mostly cold storage; concurrent review sweeps + appended at EOF and conflicted spuriously. Union is git-native (no driver). The + one unsafe case — a reactivation's line removal resurrected by union — is exactly + what the existing `[both]` / `[over-archived]` ERROR checks detect + deterministically. Continuity and thread files never get union: there a conflict + is signal. +- **Reviews serialize by protocol:** run from an up-to-date default branch, commit + promptly — the metadata refresh touches many footers and must not run tangled + with in-flight substantive edits. + +## Lifecycle semantics (unchanged rules, new location) + +Decay/pinning/supersession rules (`DECAY.md`) are location-independent: an unchecked +thread file is pinned; a checked one condenses to a 3–6-line stub and waits out +`archive_window`; the sweep *moves the file's block* to the quarter archive + INDEX +and deletes the file (`archive-fact` does this deterministically). `refresh-metadata` +rewrites footers in place — filenames never churn. New/Contradiction/Drift threads +are created as new files. Fact-id creation collisions across branches surface as +`[duplicate-id]` (new ERROR check), and `[thread-file]` pins the filename↔id +contract. + +## What was deliberately deferred + +- **Millisecond session filenames** — same-second add/add collisions across + contributors are vanishingly rare, and `date -u +%3N` is not portable (BSD/macOS); + a MERGE.md line covers the resolution (rename one file +1s). +- **A scalars-only merge driver** — pre-scoped in PR #27's close comment; build it + only if scalar-bump conflicts persist in the field after this layout ships. +- **Splitting `status` further** — the schema already mandates a short status line; + the fix is compliance, not new structure. diff --git a/examples/evolving-memory-example/README.md b/examples/evolving-memory-example/README.md index 10951f4..43664d1 100644 --- a/examples/evolving-memory-example/README.md +++ b/examples/evolving-memory-example/README.md @@ -1,5 +1,13 @@ # Example — Evolving Memory in Action +> **Layout note (v4.39.0).** This fixture predates the merge-scale thread layout: it shows +> Open Threads inline in `continuity.md` and a `last_session` field. As of v4.39.0, each +> Open Thread lives in its own `memory/open-threads/thread-.md` file (continuity keeps +> only a pointer note) and `last_session` no longer exists (derived from the newest session +> log). Every decay/review rule shown here is unchanged — only the threads' storage +> location moved. See `.agent/schema.md` and `docs/DESIGN-merge-scale.md`. + + A worked example of the evolving-memory layer (`DECAY.md` + `REVIEW.md`) on a **fictional** project, `taskflow-api`. It shows one review cycle end to end: diff --git a/examples/migrated-cursor-aider-project/ENABLE_OUTPUT.md b/examples/migrated-cursor-aider-project/ENABLE_OUTPUT.md index 0580e61..353ee28 100644 --- a/examples/migrated-cursor-aider-project/ENABLE_OUTPUT.md +++ b/examples/migrated-cursor-aider-project/ENABLE_OUTPUT.md @@ -1,5 +1,13 @@ # Example: AI Enable with Migration +> **Layout note (v4.39.0).** This fixture predates the merge-scale thread layout: it shows +> Open Threads inline in `continuity.md` and a `last_session` field. As of v4.39.0, each +> Open Thread lives in its own `memory/open-threads/thread-.md` file (continuity keeps +> only a pointer note) and `last_session` no longer exists (derived from the newest session +> log). Every decay/review rule shown here is unchanged — only the threads' storage +> location moved. See `.agent/schema.md` and `docs/DESIGN-merge-scale.md`. + + This file shows what the agent prints when you run: > "AI enable this repo `~/projects/orderbook-api`" diff --git a/examples/rust-event-bus/ENABLE_OUTPUT.md b/examples/rust-event-bus/ENABLE_OUTPUT.md index 0277342..b22e2ff 100644 --- a/examples/rust-event-bus/ENABLE_OUTPUT.md +++ b/examples/rust-event-bus/ENABLE_OUTPUT.md @@ -1,5 +1,13 @@ # Example: AI Enable Output (real run) +> **Layout note (v4.39.0).** This fixture predates the merge-scale thread layout: it shows +> Open Threads inline in `continuity.md` and a `last_session` field. As of v4.39.0, each +> Open Thread lives in its own `memory/open-threads/thread-.md` file (continuity keeps +> only a pointer note) and `last_session` no longer exists (derived from the newest session +> log). Every decay/review rule shown here is unchanged — only the threads' storage +> location moved. See `.agent/schema.md` and `docs/DESIGN-merge-scale.md`. + + Unlike a mockup, this is the **actual** output of enabling a real repository — a small Rust async event-bus demo — captured when running: diff --git a/memory/PROTOCOL.md b/memory/PROTOCOL.md index 8462858..212e83f 100644 --- a/memory/PROTOCOL.md +++ b/memory/PROTOCOL.md @@ -30,9 +30,13 @@ Before responding or doing substantive work: skill adapters and activates the committed hook dispatchers. Git cannot activate hooks on clone. 2. **Read, in order:** `memory/instructions.md`, `memory/continuity.md`, - `memory/vision.md`, then the newest 2–3 files in `memory/sessions/`. -3. **Honor multi-agent continuity.** Read `last_session` in `continuity.md`; when it names - another agent family, read that named session log in full before proceeding. + `memory/vision.md`, then list `memory/open-threads/` and read the unchecked (`- [ ]`) + thread files (one thread per file — the live workstreams), then the newest 2–3 files + in `memory/sessions/`. +3. **Honor multi-agent continuity.** The newest session log *is* the last session — its + `**Agent:**` header names the previous agent; when that is another agent family, read + that log in full before proceeding. (There is no `last_session` field to consult — + it was derivable and merge-hot, dropped in v4.39.0.) 4. **Retrieve before declaring context absent.** Search `memory/archive/INDEX.md` and follow a fact's `origin` when the topic is unfamiliar. Retrieval is lexical and indexed; archived facts are never deleted (`DECAY.md` §11). @@ -122,14 +126,16 @@ usage. ### Update continuity for a full log -1. Set `last_session` to today's date, agent name, and log stem. Keep `status` a short - current-state line, never a version history; history belongs in logs and `UPGRADE.md`. - Keep one fact per line; when merging, take the later state of the same fact and keep - unrelated facts from both sides. +1. Keep `status` a short current-state line, never a version history; history belongs in + logs and `UPGRADE.md`. Keep one fact per line; when merging, take the later state of + the same fact and keep unrelated facts from both sides. (The session log you just + wrote is itself the last-session record — there is no `last_session` field to bump.) 2. Update changed fact substance, not usage metadata. Mark completed Open Threads `[x]` - and condense each to a 3–6-line close record — outcome, PR/commit/release refs, one - durable lesson, `origin:` pointer; the full narrative belongs in this session's log — - then leave them for review to sweep. Add newly surfaced Open Threads. + in their `memory/open-threads/thread-.md` files and condense each to a 3–6-line + close record — outcome, PR/commit/release refs, one durable lesson, `origin:` pointer; + the full narrative belongs in this session's log — then leave the files for review to + sweep. Create newly surfaced Open Threads as new `thread-.md` files (filename = + the fact id; content = the single bullet block with its footer). 3. Before adding a fact, check existing and archived facts (`DECAY.md` §10). A new fact gets a kebab id and footer: `created`, `tier: working` (or `core` for an invariant), `origin: `, `last_used: today`, `uses: 1`. Raise an unchecked `Contradiction:` thread for diff --git a/memory/continuity.md b/memory/continuity.md index c9eeb4a..f9149cf 100644 --- a/memory/continuity.md +++ b/memory/continuity.md @@ -7,9 +7,8 @@ ## Project State - **project:** agent-memory -- **status:** v4.38.1 — a vendor-neutral, no-code (markdown) shared-AI-memory + AI-enablement tool. Three shared layers: **backward memory** (v3.x — fact metadata + ids, decay/review/archive), a **forward VBDI cognitive loop** (v4.0 — Vision→Blueprint→Design→Impl over the memory substrate), and a **cross-vendor skills layer** (v4.1+ — neutral committed `agent-skills/` + a runnable `sync-adapters`; six adapter targets: Claude/Gemini/Cursor/Kiro/Copilot/Antigravity). Agent-as-runtime; `memory/` is committed + shared; activation enters through a one-line root `AGENTS.md` shim to the canonical `memory/PROTOCOL.md` (v4.37.0; consumer-product repos may carry the sanctioned contributor/consumer fork, v4.38.0); enable/upgrade converge **declaratively** against a `MANIFEST.md` target state via the reconcile helper — O(diff), not O(steps/rungs) (v4.35.0). Built-in skills: `memory-lint`, `second-opinion`+`apply-critique`, `sync-adapters`, `harvest-knowledge`, `archive-fact`, `refresh-metadata`. Vendor-neutral, **forge-aware** ritual triggers (composable pre/post-commit **fragment dispatchers** over `.githooks/.d/*` — pre-commit secret guard (v4.34.0) + post-commit ritual capture as managed `50-` fragments (v4.36.0) — plus a CI floor matched to the hosting forge: GitHub Actions, GitLab CI, or Azure Pipelines, v4.31.0–v4.32.0) with first-run self-init; Windows LF hardening. **Per-version history lives in `UPGRADE.md` (the version ladder) + `memory/sessions/` — kept OUT of this line by design (v4.22.0): `status` is a short current-state descriptor, not a changelog, so this shared line doesn't become a merge-conflict hotspot.** `.agent/version.md` is the canonical version. Validated across six vendors (Claude, Gemini, Cursor, Kiro, Copilot CLI, Antigravity). +- **status:** v4.39.0 — a vendor-neutral, no-code (markdown) shared-AI-memory + AI-enablement tool: backward memory (decay/review/archive), forward VBDI loop, cross-vendor skills layer, declarative enable/upgrade (MANIFEST reconcile), forge-aware ritual triggers (GitHub/GitLab/AzDO), and the merge-scale thread layout (`memory/open-threads/`). Detail: What's Been Built below; per-version history: `UPGRADE.md` + session logs. - **last_enabled:** 2026-06-12 -- **last_session:** 2026-08-26 | agent: Claude Code (2026-08-26-000208) - **last_review:** 2026-08-22 | through 2026-08-22-174047 - **last_invariant_check:** 2026-08-22 | through 2026-08-22-174808 (all 6 confirmed by Eric — walkthrough with live-tree evidence; no-build-step wording refreshed) - **vision:** `memory/vision.md` (north star; Blueprint gaps in Open Threads below) @@ -100,6 +99,10 @@ GitHub Copilot, GPT/Codex agents, Zed AI, Gemini CLI. - Contradictions between vendors surface as Open Threads — the tool never picks a winner - Three modes: Fresh Enable (A), Already Ours (B, idempotent), Migrate Vendor (C) - Dry-run support so users can preview before committing +- **`origin` is GitHub; assume GitHub for git ops** (public repo `acn-ericlaw/agent-memory`, + Apache-2.0 — migrated from GitLab 2026-06-18; extracted from the publish-github thread at + the v4.39.0 thread migration) + - Git hook entrypoints dispatch ordered fragments (ADR-0007) — `.githooks/pre-commit` and `.githooks/post-commit` stay minimal and stable; executable `.githooks/.d/*` fragments run in C-locale filename order, all fragments run, and the first non-zero status is returned. @@ -109,273 +112,13 @@ GitHub Copilot, GPT/Codex agents, Zed AI, Gemini CLI. ## Open Threads -- [x] **Shipped v4.38.1 (PATCH) — scanner-neutral guidance constant** ([PR #26](https://github.com/acn-ericlaw/agent-memory/pull/26)). - Downstream field FP (Snyk, enterprise Java deployment): memory-lint's prose guidance - constant carried the hardcoded-secret detector shape (identifier-contains-SECRET + - string-literal assignment) and rejected builds. Renamed `GUIDANCE` (byte-identical - outputs, both runtimes); runtime-assembled hygiene self-check per suite (53 ×2, - red-verified first); SKILLS.md house rule. Lesson: the tool's subject matter names its - own identifiers into scanner FPs — prose constants stay scanner-neutral, now - suite-enforced. → serves: vision-agent-memory - +> Open Threads live **one per file** in `memory/open-threads/` (`thread-.md`; +> filename = the thread's fact id) so concurrent thread work never merge-conflicts +> (v4.39.0 — `docs/DESIGN-merge-scale.md`). List that directory to see them; unchecked +> `- [ ]` threads are the live workstreams and never decay. Mark a completed thread +> `- [x]` in its file and leave it — the review sweeps it to the archive once older than +> `archive_window` sessions. Don't archive by hand. See `.agent/schema.md`. -- [x] **Shipped v4.38.0 (MINOR) — consumer routing + close-record economy.** From the - mercury-composable onboarding handoff (ratified 2026-08-21): sanctioned root fork - (`fork-ok`; the live artifact pinned verbatim in both suites), consumers-exit-here - step-0, 3–6-line close records + review-time condensation + `[closed-thread-bloat]` - (knob 150), optional ready-to-work checkpoint; first exercise of the protocol-propagation - semantic row. Lesson: my ≤8-line bound from the handoff's *paraphrase* rejected the live - 11-line fork — verbatim fixtures, third strike. → serves: vision-agent-memory - - -- [x] **Shipped v4.36.0 (MINOR) — composable Git hook dispatchers.** Hook entrypoints became - fragment dispatchers over `.githooks/.d/*`; guard + ritual capture moved intact to - managed `50-` fragments (PR #22, first external contribution; ADR-0007). Lesson: the id - keeps its dev-numbered slug after a rebase renumber — origin logs are immutable. Full - detail: origin log + the `4.35.0→4.36.0` rung. → serves: vision-agent-memory - - -- [x] **(blueprint — SHIPPED v4.35.0 MINOR, 2026-08-20) Target-state reconcile — enable/upgrade - in O(diff), not O(steps/rungs).** From the >10-min AI-hackathon enable field report: - `MANIFEST.md` declares the target state; `scripts/reconcile.py`/`.mjs` (byte-parity twins) - converge a repo against it — dry-run consent artifact, one mechanical `--apply`, judgment - stays agent-owned. Pre-ship adversarial review fixed 2 blockers (silent downgrade; mjs - symlink escape). Lesson: mechanize arithmetic, never judgment. Full detail: origin log + - the `4.34.2→4.35.0` rung + `docs/DESIGN-reconcile.md`. → serves: vision-agent-memory - - -- [x] **(blueprint — SHIPPED v4.37.0 MINOR, 2026-08-21) Protocol behind a one-line root - shim.** Root `AGENTS.md` (tool + template) became a 61-byte pointer to the canonical - `memory/PROTOCOL.md` (dual-mode tool copy; target-only template), killing the ~20 KB - auto-load tax; PRE-APPLY fail-closed gating protects customized roots (PR #24, eugenelim; - 3 review findings fixed pre-merge). Lesson: the consent artifact must agree with apply. - Full detail: origin log + spec under `docs/specs/memory-protocol-pointer/` + the - `4.36.0→4.37.0` rung. → serves: vision-agent-memory - - -- [ ] **Protocol-text propagation is a per-release obligation, not a mechanism.** - `memory/PROTOCOL.md` is `seed-copy`, so an installed protocol is never re-copied — deliberate - (the v4.37.0 rung merges the old root's local directives into it, and a re-copy could drop - them irrecoverably), but it ends the automatic propagation the old `verbatim` `AGENTS.md` hub - had, and ~half of recent releases edited protocol text. Now stated in `MANIFEST.md`'s row - notes + the `UPGRADE.md` manifest-lockstep rule: any release editing - `templates/memory/PROTOCOL.md` ships a Semantic steps row (re-copy a still-stock protocol, - arbitrate a customized one per §5i). **Open for the maintainer:** keep that cost, or move the - row to `verbatim` and lean on §5i drift arbitration — truer to the house model, but it weakens - AC-MP-07/08's "preserved without loss". → serves: vision-agent-memory (a protocol fix must - reach the repos running it) - - -- [ ] **Field canary: AGENTS.md-native activation is unproven.** Codex, Kiro, and Antigravity - auto-load root `AGENTS.md`, so v4.37.0 trades their inline protocol for a 61-byte imperative - plus one read-action — the v4.29.0 failure class (pointer prose, skipped under task pressure) - reintroduced for exactly the enterprise-IDE class this targets. Mitigated by the single - unambiguous imperative and the activation-first rewrite, motivated by a real ~20 KB/session - tax, but never observed on a live Kiro or Codex session: treat activation there as unvalidated - until a field canary confirms it (the v4.29.0 attestation pattern). → serves: - vision-agent-memory (cross-vendor activation is a claim to observe, not assume) - - -- [x] **Shipped v4.34.2 (PATCH) — the guard's own opt-down knob is not a credential.** - mercury-composable field FP: documenting the guard's advisory knob tripped the guard; - fixed with their endorsed exact-key, value-constrained exemption (trailing-punctuation - variant caught pre-ship by the verbatim-fixture rule). Lesson: a guard must never block - the documentation of its own controls. Full detail: origin log + the `4.34.1→4.34.2` - rung. → serves: vision-agent-memory - - -- [x] **Shipped v4.34.0 (MINOR) — pre-commit secret guard on both surfaces (memory + config).** - Staged-content scan blocks committed secrets by default (the deliberate, scoped exception - to the advisory doctrine — secrets carry irreversible cost); knob opts down, `--no-verify` - bypasses once; probe-tuned to 0 FPs on a 661-file live corpus. Fold-up v4.34.1: guidance - prints once per run. Full detail: origin log + the `4.33.4→4.34.0` rung. - → serves: vision-agent-memory - - -- [x] **Re-verify invariants — all six confirmed by Eric, 2026-08-22** (one-by-one - walkthrough with live-tree evidence: scope guards + boundary tests, MIGRATE legacy rule, - fail-closed arbitration, reconcile guarantees, Vision vs v4.38.0 state). One enrichment - at his direction: `no-build-step-agent-run` wording now names the grown helper family - (substance and footer unchanged); no supersessions. Detail: 2026-08-22-174808. - - -- [x] **Hardened `memory-lint` test fixtures against repo secret-scanner FPs (no release - bump).** Suites construct shape-constrained dummy secrets at runtime instead of - committing signatures; full coverage retained (46/46 ×2). Lesson: security tooling must - not make its own repository noisy to enterprise scanners. Full detail: origin log. - → serves: vision-agent-memory - - -- [x] **Shipped v4.33.4 (PATCH) — reject non-empty `${…}` defaults in `[secret-material]`.** - v4.33.3's placeholder exemption also trusted literal fallbacks; template forms stay safe - while values with real-looking defaults flag without echo (46 mirror tests ×2). Full - detail: origin log. → serves: vision-agent-memory - - -- [x] **Shipped v4.33.3 (PATCH) — security-review hardening for `[secret-material]`.** A - fresh-context review's four gaps fixed (real strict gate in CI wrappers; key-scoped enum - exemption; quoted/header/embedded forms flag; Mode C redaction triage); a pre-tag - live-target probe caught 2 template-pattern FPs the review missed. Lesson: always probe - the live target, not just suites. Full detail: origin log. → serves: vision-agent-memory - - -- [x] **Shipped v4.33.2 (PATCH) — `[secret-material]`: backtick is a value delimiter.** The - v4.33.1 fix missed the field line's real markdown-inline-code form; backtick joined the - delimiter set and the mirror test now quotes the field line verbatim. Lesson (origin of - the verbatim-fixture doctrine): a field fix's fixture must quote the motivating artifact - verbatim, never a paraphrase. Full detail: origin log + the `4.33.1→4.33.2` rung. - → serves: vision-agent-memory - - -- [x] **Shipped v4.33.1 (PATCH) — ALL-CAPS enum constants are not credentials.** Check 10's - first field contact was one FP (a documented enum value); the detector now treats - ALL-CAPS identifiers as config constants while token shapes stay covered. Lesson - (honest-signal doctrine): fix the detector, never sprinkle waivers through client repos. - Full detail: origin log + the `4.33.0→4.33.1` rung. → serves: vision-agent-memory - - -- [x] **Shipped v4.33.0 (MINOR) — session-log secret redaction: ritual rule + - `[secret-material]` advisory.** From a client-side DLP incident (live OAuth secret in a - committed session log): the redaction rule joined the after-session ritual and lint - check 10 became the deterministic backstop (never echoes values; scans sessions + - archive). Lesson: shared memory must be safe to share — a committed secret is exposed, - rotate it. Full detail: origin log + the `4.32.1→4.33.0` rung. - → serves: vision-agent-memory - - -- [ ] **(backlog) Bitbucket forge support — trigger-gated; mechanics pre-verified (2026-07-27).** From a - maintainer question during the v4.31.0 GitLab release ("investigate viability to include Bitbucket"). - Verified against Atlassian docs (July 2026) so a future field report can act immediately: **(1) the - clean win** — Bitbucket CLOUD supports a committed `.bitbucket/pull_request_template.md` (KB Apr - 2026; read from the PR's SOURCE branch; overrides the settings field) — the What/Why template is - seedable file-based. **(2) The CI floor cannot keep its promise there** — committing - `bitbucket-pipelines.yml` runs nothing until a repo ADMIN enables Pipelines (`repository:admin`, a - forge setting the agent must never touch); free tier = 50 build-min/month; and there is NO additive - include seam (the `import` mechanism is Premium **and** whole-pipeline replacement — adding to an - existing file means inserting a step into their sequential list, against add-only). Advisory - semantics do exist (`on-fail: strategy: ignore`); PR pipelines carry base-SHA vars - (`BITBUCKET_PR_DESTINATION_COMMIT`); clone needs `depth: full`. **(3) Cloud-only** — Data Center has - NO native Pipelines and NO committed template file (settings-UI only): enterprise Bitbucket gets - nothing. **(4) Attribution** — squash message not templatable on Cloud, trailers mangle into bullet - lists, UI ignores Co-authored-by (BSERV-10529 closed won't-fix) — the PR-description footer is the - only durable record. **Decision (maintainer, 2026-07-27): defer — don't build speculatively.** The - pre-scoped viable shape when a Bitbucket team reports the failure class: - `.bitbucket/pull_request_template.md` (clean) + an optional fresh-file-only pipelines floor carrying - the admin-toggle honest limit + never editing an existing pipelines file (report a recommendation - instead). → serves: vision-agent-memory - - -- [ ] **(backlog) Before-session presence for Cursor/Kiro — path-scoped steering imports.** From a - maintainer question after v4.29.0 shipped ("what about other vendors' entry points?"). v4.29.0 - covered the only two entry files with native import syntax (`CLAUDE.md` `@path`, `GEMINI.md` - `@./path.md`); AGENTS.md-native runtimes (Codex, Kiro, Antigravity) auto-load the one-line shim — - only the `AGENTS.md → memory/PROTOCOL.md` hop stays voluntary; Copilot/`.cursorrules`/`.windsurfrules` have - no import mechanism (the v4.20.1 front-load pattern is the one inline lever — small stable - snippets only, never protocol copies). **Two real levers exist, blocked by our own `.gitignore` - stance, not the vendors:** Cursor modern rules (`.cursor/rules/*.mdc`) attach files via - `@`-references; Kiro steering (`.kiro/steering/*.md`) supports `#[[file:…]]` inclusion. Both dirs - are gitignored-personal wholesale today; adopting needs a **path-scoped carve-out** (the - `.github/skills/`-inside-tracked-`.github/` pattern) + a committed steering file per vendor. - **Don't build speculatively** — trigger is a Cursor or Kiro team reporting the context-read - failure class (complaints = adoption signal). → serves: vision-agent-memory - - -### Evolving long-term memory layer (v3.0.0) — BUILT 2026-06-13 -- [ ] **Dogfood backfill (optional):** this repo adopted the layer — added - Architectural Invariants (core), `memory/decay-policy.md`, `memory/archive/INDEX.md`, - `last_review`, and Memory References in session logs going forward. Legacy facts in - What's Been Built / Key Decisions are grandfathered as `active` (no metadata footers - yet); backfill them with ids/metadata if/when desired (or let the first review do it). -- [ ] Optionally update `examples/` to mention the mercury upgrade as a real Mode B - upgrade fixture (analogous to rust-event-bus being a real Mode A). - -### Next major iteration — Vision → Blueprint → Design → Implementation (VBDI) lifecycle -> Set 2026-06-14. The vNext backlog (P1–P5) is complete; this is the next headline direction. -> Paused at the framing stage by mutual agreement — to be tackled methodically next. - -- [ ] **Build a VBDI lifecycle layer** — *forward*-looking intent traceability to complement - the *backward*-looking memory layer. Mission: empower AI to deliver **predictable - innovation** with human partnership — a deterministic *process* + an enforceable *trace* - from intent → delivery, with a human gate at each altitude change (Vision → Blueprint → - Design → Implementation). "Predictable" = the process/trace, not the ideas. Reuses - existing primitives: id/origin/supersedes linkage (trace across altitudes), §10 - contradiction-check (drift detection: impl vs design, design vs blueprint vs vision), - §9 supersession (intent changes ripple down), the human-gate pattern - (never-pick-a-winner / invariant-verify prompt), and the smoke test (acceptance vs - design criteria). Stays no-code/markdown + deterministic. **Plan:** dogfood it — write - the Vision (human's to set), then `DESIGN-vbdi-lifecycle.md` (sibling to - `DESIGN-evolving-memory.md`), settle forks via structured decisions, build on the - version ladder. **Hard parts:** drift across altitudes needs real structure (not - hand-waving); keep gates lightweight (Open-Thread-like, not Jira); the trace must be - enforceable (grep/review), not just documented. Validated motivation: the Node→Rust - rewrite delivered deterministically with no drift — VBDI generalizes that to creation. - - -### Shipped — v4.1.0: cross-vendor skills layer (2026-06-15) - -> _v4.2.0 ("sync skill adapters", `sync-adapters-v420`) archived faded → `archive/2026-Q2.md` (2026-06-18 review)._ - -### Shipped — v4.4.0 / v4.5.0 + the hello-world dogfood (reactivated 2026-06-18) - -> Reactivated from `archive/2026-Q2.md` 2026-06-18 — the 2026-06-18-051933 review (GitHub Copilot) -> swept these while they were still referenced in the window (low sslu); restored to `active`. - -### Shipped — v4.7.0–v4.7.1: cross-vendor refinements (2026-06-17) - -### Blueprint — gaps from Current State (v4.10.0) to the Vision (serves: vision-agent-memory) -> Derived 2026-06-15 from `memory/vision.md` (maintainer-confirmed). Typed Open Threads -> `(blueprint)`: each is a Vision↔reality gap that closes when delivered. The *backward* -> memory layer is not here — it's done; every gap is *forward*. These operationalize the -> `vbdi-lifecycle-direction` thread above. First real VBDI loop, dogfooded on the tool itself. - -- [ ] **(blueprint)** Greenfield path — the tool handles brownfield (enable/migrate) well - but has no "start from a Vision, no code yet" flow. → serves: vision-agent-memory - -- [ ] **(blueprint)** Multi-user concurrent contribution — mostly supported (shared - committed `memory/`, multi-agent continuity, collision-safe session filenames); validate - and harden for *simultaneous* contributors. → serves: vision-agent-memory - -- [ ] **(blueprint)** *(optional)* SDLC overlay for targets — a scrum-inspired profile a - target *owner* can opt into: a `(sprint)` tag over Blueprint gaps + a sprint-boundary - review, **no points/velocity/ceremony**. Not core; only if a real target wants it. The - memory design is already **process-neutral** and survives an overlay (`DECAY.md` §12 / - `docs/DESIGN-vbdi-lifecycle.md` §13): ceremony + scoring live in the target's own space, - never in `memory/`. → serves: vision-agent-memory - -### Backlog — vNext (temporal & supersession) + beyond -> From the 2026-06-13 industry-alignment assessment: -> `docs/assessments/2026-06-13-industry-alignment.md`. Verdict: on track; distinctive -> on event-sourcing/determinism/governance; one real gap = temporal/supersession. -> Re-run the assessment after meaningful iterations and compare its scorecard. - -- [ ] **Dogfooding on real work (ongoing).** Already delivering: the simple-proxy - enable surfaced v3.1.0 (`.gitignore`), and the simple-proxy Node→Rust refactor's - field report drove v3.2.0 (protocol clarifications). Keep feeding real-work insights - back into this backlog. (Stated 2026-06-13.) - - -- [ ] ~~**Knowledge graph layer — SurrealDB for long-term memory.**~~ **Set aside** - (2026-06-13) in favor of the markdown-native evolving-memory layer above. Not - deleted — revisit if the markdown layer hits limits. Original open questions: - replace vs supplement markdown; entity/relation schema; agent interaction - (SurrealQL/REST/SDK); fit with no-code philosophy; single- vs multi-agent access; - Cloud vs self-hosted. - -### Pre-existing -- [ ] Test migration on a real repo with Cursor + Aider footprint -- [ ] Test migration on a Claude Code repo with .claude/projects/*.jsonl -- [ ] Test Continue.dev session JSON migration end-to-end -- [ ] Add example for migrating a Continue.dev project -- [ ] Consider a `DISABLE.md` protocol for cleanly removing AI memory -- [x] **Publish to GitHub — DONE 2026-06-18.** Migrated GitLab → public GitHub - (`acn-ericlaw/agent-memory`, Apache-2.0, full history; the `-tool` suffix dropped; old - GitLab retired; publication gate satisfied with maintainer approval). **`origin` is - GitHub; assume GitHub for git ops.** Full detail: sessions of 2026-06-18. -- [ ] **Remaining: migrate to enterprise GitHub** for the official repo (one more hop after this - public-GitHub staging step). (Set 2026-06-18.) -- [ ] Keep root `CLAUDE.md` architecture section in sync when file shapes or - vendor support change (also touches `templates/`, `MIGRATE.md`, `README.md`, - `examples/`) ## User Preferences diff --git a/memory/open-threads/thread-backlog-real-work-dogfood.md b/memory/open-threads/thread-backlog-real-work-dogfood.md new file mode 100644 index 0000000..d583e9c --- /dev/null +++ b/memory/open-threads/thread-backlog-real-work-dogfood.md @@ -0,0 +1,5 @@ +- [ ] **Dogfooding on real work (ongoing).** Already delivering: the simple-proxy + enable surfaced v3.1.0 (`.gitignore`), and the simple-proxy Node→Rust refactor's + field report drove v3.2.0 (protocol clarifications). Keep feeding real-work insights + back into this backlog. (Stated 2026-06-13.) + diff --git a/memory/open-threads/thread-bp-greenfield.md b/memory/open-threads/thread-bp-greenfield.md new file mode 100644 index 0000000..3f65e81 --- /dev/null +++ b/memory/open-threads/thread-bp-greenfield.md @@ -0,0 +1,3 @@ +- [ ] **(blueprint)** Greenfield path — the tool handles brownfield (enable/migrate) well + but has no "start from a Vision, no code yet" flow. → serves: vision-agent-memory + diff --git a/memory/open-threads/thread-bp-multi-user.md b/memory/open-threads/thread-bp-multi-user.md new file mode 100644 index 0000000..9a90664 --- /dev/null +++ b/memory/open-threads/thread-bp-multi-user.md @@ -0,0 +1,8 @@ +- [ ] **(blueprint)** Multi-user concurrent contribution — mostly supported (shared + committed `memory/`, multi-agent continuity, collision-safe session filenames); validate + and harden for *simultaneous* contributors. → serves: vision-agent-memory + Progress (v4.39.0, merge-scale layout): threads-as-files + archive union-merge + + serialized reviews + derived last_session removed the structural conflict surfaces + (docs/DESIGN-merge-scale.md). Remaining: live simultaneous-contributor validation + in the field. + diff --git a/memory/open-threads/thread-bp-reconcile-target-state.md b/memory/open-threads/thread-bp-reconcile-target-state.md new file mode 100644 index 0000000..10b9935 --- /dev/null +++ b/memory/open-threads/thread-bp-reconcile-target-state.md @@ -0,0 +1,8 @@ +- [x] **(blueprint — SHIPPED v4.35.0 MINOR, 2026-08-20) Target-state reconcile — enable/upgrade + in O(diff), not O(steps/rungs).** From the >10-min AI-hackathon enable field report: + `MANIFEST.md` declares the target state; `scripts/reconcile.py`/`.mjs` (byte-parity twins) + converge a repo against it — dry-run consent artifact, one mechanical `--apply`, judgment + stays agent-owned. Pre-ship adversarial review fixed 2 blockers (silent downgrade; mjs + symlink escape). Lesson: mechanize arithmetic, never judgment. Full detail: origin log + + the `4.34.2→4.35.0` rung + `docs/DESIGN-reconcile.md`. → serves: vision-agent-memory + diff --git a/memory/open-threads/thread-bp-sdlc-overlay.md b/memory/open-threads/thread-bp-sdlc-overlay.md new file mode 100644 index 0000000..298221f --- /dev/null +++ b/memory/open-threads/thread-bp-sdlc-overlay.md @@ -0,0 +1,7 @@ +- [ ] **(blueprint)** *(optional)* SDLC overlay for targets — a scrum-inspired profile a + target *owner* can opt into: a `(sprint)` tag over Blueprint gaps + a sprint-boundary + review, **no points/velocity/ceremony**. Not core; only if a real target wants it. The + memory design is already **process-neutral** and survives an overlay (`DECAY.md` §12 / + `docs/DESIGN-vbdi-lifecycle.md` §13): ceremony + scoring live in the target's own space, + never in `memory/`. → serves: vision-agent-memory + diff --git a/memory/open-threads/thread-hook-dispatchers-v4350.md b/memory/open-threads/thread-hook-dispatchers-v4350.md new file mode 100644 index 0000000..728c314 --- /dev/null +++ b/memory/open-threads/thread-hook-dispatchers-v4350.md @@ -0,0 +1,6 @@ +- [x] **Shipped v4.36.0 (MINOR) — composable Git hook dispatchers.** Hook entrypoints became + fragment dispatchers over `.githooks/.d/*`; guard + ritual capture moved intact to + managed `50-` fragments (PR #22, first external contribution; ADR-0007). Lesson: the id + keeps its dev-numbered slug after a rebase renumber — origin logs are immutable. Full + detail: origin log + the `4.35.0→4.36.0` rung. → serves: vision-agent-memory + diff --git a/memory/open-threads/thread-merge-scale-v4390.md b/memory/open-threads/thread-merge-scale-v4390.md new file mode 100644 index 0000000..7b974be --- /dev/null +++ b/memory/open-threads/thread-merge-scale-v4390.md @@ -0,0 +1,10 @@ +- [x] **Shipped v4.39.0 (MINOR) — merge-scale memory: threads as files** (PR pending + maintainer merge). From Eric's field report (team-scale `continuity.md` conflicts) + + PR #27's artifact: one thread per `memory/open-threads/thread-.md` (no index — the + directory is it), `last_session` dropped (derived), archive `merge=union`, serialized + reviews; PR #27 reviewed (3 verified never-pick-a-winner findings), closed with credit, + `[duplicate-state-key]` + rehearsal-test pattern absorbed (Roland Heusser). Lesson: + structure beats merge-time machinery — the rejected driver silently unioned the one case + that must reach a human. Detail: docs/DESIGN-merge-scale.md + the 4.38.1→4.39.0 rung. + → serves: vision-agent-memory (via bp-multi-user) + diff --git a/memory/open-threads/thread-onboarding-efficiency-v4380.md b/memory/open-threads/thread-onboarding-efficiency-v4380.md new file mode 100644 index 0000000..80972d2 --- /dev/null +++ b/memory/open-threads/thread-onboarding-efficiency-v4380.md @@ -0,0 +1,8 @@ +- [x] **Shipped v4.38.0 (MINOR) — consumer routing + close-record economy.** From the + mercury-composable onboarding handoff (ratified 2026-08-21): sanctioned root fork + (`fork-ok`; the live artifact pinned verbatim in both suites), consumers-exit-here + step-0, 3–6-line close records + review-time condensation + `[closed-thread-bloat]` + (knob 150), optional ready-to-work checkpoint; first exercise of the protocol-propagation + semantic row. Lesson: my ≤8-line bound from the handoff's *paraphrase* rejected the live + 11-line fork — verbatim fixtures, third strike. → serves: vision-agent-memory + diff --git a/memory/open-threads/thread-ot-agents-native-activation-canary.md b/memory/open-threads/thread-ot-agents-native-activation-canary.md new file mode 100644 index 0000000..f4a4b8a --- /dev/null +++ b/memory/open-threads/thread-ot-agents-native-activation-canary.md @@ -0,0 +1,9 @@ +- [ ] **Field canary: AGENTS.md-native activation is unproven.** Codex, Kiro, and Antigravity + auto-load root `AGENTS.md`, so v4.37.0 trades their inline protocol for a 61-byte imperative + plus one read-action — the v4.29.0 failure class (pointer prose, skipped under task pressure) + reintroduced for exactly the enterprise-IDE class this targets. Mitigated by the single + unambiguous imperative and the activation-first rewrite, motivated by a real ~20 KB/session + tax, but never observed on a live Kiro or Codex session: treat activation there as unvalidated + until a field canary confirms it (the v4.29.0 attestation pattern). → serves: + vision-agent-memory (cross-vendor activation is a claim to observe, not assume) + diff --git a/memory/open-threads/thread-ot-before-session-cursor-kiro-backlog.md b/memory/open-threads/thread-ot-before-session-cursor-kiro-backlog.md new file mode 100644 index 0000000..ce25d28 --- /dev/null +++ b/memory/open-threads/thread-ot-before-session-cursor-kiro-backlog.md @@ -0,0 +1,14 @@ +- [ ] **(backlog) Before-session presence for Cursor/Kiro — path-scoped steering imports.** From a + maintainer question after v4.29.0 shipped ("what about other vendors' entry points?"). v4.29.0 + covered the only two entry files with native import syntax (`CLAUDE.md` `@path`, `GEMINI.md` + `@./path.md`); AGENTS.md-native runtimes (Codex, Kiro, Antigravity) auto-load the one-line shim — + only the `AGENTS.md → memory/PROTOCOL.md` hop stays voluntary; Copilot/`.cursorrules`/`.windsurfrules` have + no import mechanism (the v4.20.1 front-load pattern is the one inline lever — small stable + snippets only, never protocol copies). **Two real levers exist, blocked by our own `.gitignore` + stance, not the vendors:** Cursor modern rules (`.cursor/rules/*.mdc`) attach files via + `@`-references; Kiro steering (`.kiro/steering/*.md`) supports `#[[file:…]]` inclusion. Both dirs + are gitignored-personal wholesale today; adopting needs a **path-scoped carve-out** (the + `.github/skills/`-inside-tracked-`.github/` pattern) + a committed steering file per vendor. + **Don't build speculatively** — trigger is a Cursor or Kiro team reporting the context-read + failure class (complaints = adoption signal). → serves: vision-agent-memory + diff --git a/memory/open-threads/thread-ot-bitbucket-forge-backlog.md b/memory/open-threads/thread-ot-bitbucket-forge-backlog.md new file mode 100644 index 0000000..28a364f --- /dev/null +++ b/memory/open-threads/thread-ot-bitbucket-forge-backlog.md @@ -0,0 +1,21 @@ +- [ ] **(backlog) Bitbucket forge support — trigger-gated; mechanics pre-verified (2026-07-27).** From a + maintainer question during the v4.31.0 GitLab release ("investigate viability to include Bitbucket"). + Verified against Atlassian docs (July 2026) so a future field report can act immediately: **(1) the + clean win** — Bitbucket CLOUD supports a committed `.bitbucket/pull_request_template.md` (KB Apr + 2026; read from the PR's SOURCE branch; overrides the settings field) — the What/Why template is + seedable file-based. **(2) The CI floor cannot keep its promise there** — committing + `bitbucket-pipelines.yml` runs nothing until a repo ADMIN enables Pipelines (`repository:admin`, a + forge setting the agent must never touch); free tier = 50 build-min/month; and there is NO additive + include seam (the `import` mechanism is Premium **and** whole-pipeline replacement — adding to an + existing file means inserting a step into their sequential list, against add-only). Advisory + semantics do exist (`on-fail: strategy: ignore`); PR pipelines carry base-SHA vars + (`BITBUCKET_PR_DESTINATION_COMMIT`); clone needs `depth: full`. **(3) Cloud-only** — Data Center has + NO native Pipelines and NO committed template file (settings-UI only): enterprise Bitbucket gets + nothing. **(4) Attribution** — squash message not templatable on Cloud, trailers mangle into bullet + lists, UI ignores Co-authored-by (BSERV-10529 closed won't-fix) — the PR-description footer is the + only durable record. **Decision (maintainer, 2026-07-27): defer — don't build speculatively.** The + pre-scoped viable shape when a Bitbucket team reports the failure class: + `.bitbucket/pull_request_template.md` (clean) + an optional fresh-file-only pipelines floor carrying + the admin-toggle honest limit + never editing an existing pipelines file (report a recommendation + instead). → serves: vision-agent-memory + diff --git a/memory/open-threads/thread-ot-claude-md-sync.md b/memory/open-threads/thread-ot-claude-md-sync.md new file mode 100644 index 0000000..73eb8c0 --- /dev/null +++ b/memory/open-threads/thread-ot-claude-md-sync.md @@ -0,0 +1,4 @@ +- [ ] Keep root `CLAUDE.md` architecture section in sync when file shapes or + vendor support change (also touches `templates/`, `MIGRATE.md`, `README.md`, + `examples/`) + diff --git a/memory/open-threads/thread-ot-disable-protocol.md b/memory/open-threads/thread-ot-disable-protocol.md new file mode 100644 index 0000000..8b8ef29 --- /dev/null +++ b/memory/open-threads/thread-ot-disable-protocol.md @@ -0,0 +1,2 @@ +- [ ] Consider a `DISABLE.md` protocol for cleanly removing AI memory + diff --git a/memory/open-threads/thread-ot-dogfood-backfill-optional.md b/memory/open-threads/thread-ot-dogfood-backfill-optional.md new file mode 100644 index 0000000..a467382 --- /dev/null +++ b/memory/open-threads/thread-ot-dogfood-backfill-optional.md @@ -0,0 +1,6 @@ +- [ ] **Dogfood backfill (optional):** this repo adopted the evolving-memory layer 2026-06-13 — + Architectural Invariants (core), `memory/decay-policy.md`, `memory/archive/INDEX.md`, + `last_review`, and Memory References in session logs going forward. Legacy facts in + What's Been Built / Key Decisions are grandfathered as `active` (no metadata footers + yet); backfill them with ids/metadata if/when desired (or let the first review do it). + diff --git a/memory/open-threads/thread-ot-enterprise-github-migration.md b/memory/open-threads/thread-ot-enterprise-github-migration.md new file mode 100644 index 0000000..e5c263b --- /dev/null +++ b/memory/open-threads/thread-ot-enterprise-github-migration.md @@ -0,0 +1,3 @@ +- [ ] **Remaining: migrate to enterprise GitHub** for the official repo (one more hop after the + public-GitHub staging step). (Set 2026-06-18.) + diff --git a/memory/open-threads/thread-ot-example-continue-migration.md b/memory/open-threads/thread-ot-example-continue-migration.md new file mode 100644 index 0000000..f505435 --- /dev/null +++ b/memory/open-threads/thread-ot-example-continue-migration.md @@ -0,0 +1,2 @@ +- [ ] Add example for migrating a Continue.dev project + diff --git a/memory/open-threads/thread-ot-examples-mode-b-fixture.md b/memory/open-threads/thread-ot-examples-mode-b-fixture.md new file mode 100644 index 0000000..22fbe1b --- /dev/null +++ b/memory/open-threads/thread-ot-examples-mode-b-fixture.md @@ -0,0 +1,3 @@ +- [ ] Optionally update `examples/` to mention the mercury upgrade as a real Mode B + upgrade fixture (analogous to rust-event-bus being a real Mode A). + diff --git a/memory/open-threads/thread-ot-protocol-text-propagation.md b/memory/open-threads/thread-ot-protocol-text-propagation.md new file mode 100644 index 0000000..4911f60 --- /dev/null +++ b/memory/open-threads/thread-ot-protocol-text-propagation.md @@ -0,0 +1,12 @@ +- [ ] **Protocol-text propagation is a per-release obligation, not a mechanism.** + `memory/PROTOCOL.md` is `seed-copy`, so an installed protocol is never re-copied — deliberate + (the v4.37.0 rung merges the old root's local directives into it, and a re-copy could drop + them irrecoverably), but it ends the automatic propagation the old `verbatim` `AGENTS.md` hub + had, and ~half of recent releases edited protocol text. Now stated in `MANIFEST.md`'s row + notes + the `UPGRADE.md` manifest-lockstep rule: any release editing + `templates/memory/PROTOCOL.md` ships a Semantic steps row (re-copy a still-stock protocol, + arbitrate a customized one per §5i). **Open for the maintainer:** keep that cost, or move the + row to `verbatim` and lean on §5i drift arbitration — truer to the house model, but it weakens + AC-MP-07/08's "preserved without loss". → serves: vision-agent-memory (a protocol fix must + reach the repos running it) + diff --git a/memory/open-threads/thread-ot-publish-github-done.md b/memory/open-threads/thread-ot-publish-github-done.md new file mode 100644 index 0000000..2ae970c --- /dev/null +++ b/memory/open-threads/thread-ot-publish-github-done.md @@ -0,0 +1,6 @@ +- [x] **Publish to GitHub — DONE 2026-06-18.** Migrated GitLab → public GitHub + (`acn-ericlaw/agent-memory`, Apache-2.0, full history; the `-tool` suffix dropped; old + GitLab retired; publication gate satisfied with maintainer approval). The durable + "`origin` is GitHub; assume GitHub for git ops" fact moved to Key Decisions + (`github-origin-git-ops`) at the v4.39.0 thread migration. Full detail: sessions of 2026-06-18. + diff --git a/memory/open-threads/thread-ot-rerun-industry-assessment.md b/memory/open-threads/thread-ot-rerun-industry-assessment.md new file mode 100644 index 0000000..dcd747e --- /dev/null +++ b/memory/open-threads/thread-ot-rerun-industry-assessment.md @@ -0,0 +1,4 @@ +- [ ] **Re-run the industry-alignment assessment** (`docs/assessments/2026-06-13-industry-alignment.md`) + after meaningful iterations and compare its scorecard. 2026-06-13 verdict: on track; distinctive + on event-sourcing/determinism/governance; the one real gap (temporal/supersession) shipped v3.3.0. + diff --git a/memory/open-threads/thread-ot-reverify-invariants-20260814.md b/memory/open-threads/thread-ot-reverify-invariants-20260814.md new file mode 100644 index 0000000..a9d5f44 --- /dev/null +++ b/memory/open-threads/thread-ot-reverify-invariants-20260814.md @@ -0,0 +1,6 @@ +- [x] **Re-verify invariants — all six confirmed by Eric, 2026-08-22** (one-by-one + walkthrough with live-tree evidence: scope guards + boundary tests, MIGRATE legacy rule, + fail-closed arbitration, reconcile guarantees, Vision vs v4.38.0 state). One enrichment + at his direction: `no-build-step-agent-run` wording now names the grown helper family + (substance and footer unchanged); no supersessions. Detail: 2026-08-22-174808. + diff --git a/memory/open-threads/thread-ot-surrealdb-set-aside.md b/memory/open-threads/thread-ot-surrealdb-set-aside.md new file mode 100644 index 0000000..10bf2cb --- /dev/null +++ b/memory/open-threads/thread-ot-surrealdb-set-aside.md @@ -0,0 +1,7 @@ +- [ ] ~~**Knowledge graph layer — SurrealDB for long-term memory.**~~ **Set aside** + (2026-06-13) in favor of the markdown-native evolving-memory layer. Not + deleted — revisit if the markdown layer hits limits. Original open questions: + replace vs supplement markdown; entity/relation schema; agent interaction + (SurrealQL/REST/SDK); fit with no-code philosophy; single- vs multi-agent access; + Cloud vs self-hosted. + diff --git a/memory/open-threads/thread-ot-test-claude-jsonl-migration.md b/memory/open-threads/thread-ot-test-claude-jsonl-migration.md new file mode 100644 index 0000000..223d782 --- /dev/null +++ b/memory/open-threads/thread-ot-test-claude-jsonl-migration.md @@ -0,0 +1,2 @@ +- [ ] Test migration on a Claude Code repo with `.claude/projects/*.jsonl` + diff --git a/memory/open-threads/thread-ot-test-continue-migration.md b/memory/open-threads/thread-ot-test-continue-migration.md new file mode 100644 index 0000000..f2673a1 --- /dev/null +++ b/memory/open-threads/thread-ot-test-continue-migration.md @@ -0,0 +1,2 @@ +- [ ] Test Continue.dev session JSON migration end-to-end + diff --git a/memory/open-threads/thread-ot-test-cursor-aider-migration.md b/memory/open-threads/thread-ot-test-cursor-aider-migration.md new file mode 100644 index 0000000..72c002b --- /dev/null +++ b/memory/open-threads/thread-ot-test-cursor-aider-migration.md @@ -0,0 +1,2 @@ +- [ ] Test migration on a real repo with Cursor + Aider footprint + diff --git a/memory/open-threads/thread-pre-commit-secret-guard-v4340.md b/memory/open-threads/thread-pre-commit-secret-guard-v4340.md new file mode 100644 index 0000000..daa297d --- /dev/null +++ b/memory/open-threads/thread-pre-commit-secret-guard-v4340.md @@ -0,0 +1,7 @@ +- [x] **Shipped v4.34.0 (MINOR) — pre-commit secret guard on both surfaces (memory + config).** + Staged-content scan blocks committed secrets by default (the deliberate, scoped exception + to the advisory doctrine — secrets carry irreversible cost); knob opts down, `--no-verify` + bypasses once; probe-tuned to 0 FPs on a 661-file live corpus. Fold-up v4.34.1: guidance + prints once per run. Full detail: origin log + the `4.33.4→4.34.0` rung. + → serves: vision-agent-memory + diff --git a/memory/open-threads/thread-protocol-pointer-enterprise-activation.md b/memory/open-threads/thread-protocol-pointer-enterprise-activation.md new file mode 100644 index 0000000..cbd3a09 --- /dev/null +++ b/memory/open-threads/thread-protocol-pointer-enterprise-activation.md @@ -0,0 +1,8 @@ +- [x] **(blueprint — SHIPPED v4.37.0 MINOR, 2026-08-21) Protocol behind a one-line root + shim.** Root `AGENTS.md` (tool + template) became a 61-byte pointer to the canonical + `memory/PROTOCOL.md` (dual-mode tool copy; target-only template), killing the ~20 KB + auto-load tax; PRE-APPLY fail-closed gating protects customized roots (PR #24, eugenelim; + 3 review findings fixed pre-merge). Lesson: the consent artifact must agree with apply. + Full detail: origin log + spec under `docs/specs/memory-protocol-pointer/` + the + `4.36.0→4.37.0` rung. → serves: vision-agent-memory + diff --git a/memory/open-threads/thread-secret-fp-backtick-v4332.md b/memory/open-threads/thread-secret-fp-backtick-v4332.md new file mode 100644 index 0000000..dc82830 --- /dev/null +++ b/memory/open-threads/thread-secret-fp-backtick-v4332.md @@ -0,0 +1,7 @@ +- [x] **Shipped v4.33.2 (PATCH) — `[secret-material]`: backtick is a value delimiter.** The + v4.33.1 fix missed the field line's real markdown-inline-code form; backtick joined the + delimiter set and the mirror test now quotes the field line verbatim. Lesson (origin of + the verbatim-fixture doctrine): a field fix's fixture must quote the motivating artifact + verbatim, never a paraphrase. Full detail: origin log + the `4.33.1→4.33.2` rung. + → serves: vision-agent-memory + diff --git a/memory/open-threads/thread-secret-fp-enum-constants-v4331.md b/memory/open-threads/thread-secret-fp-enum-constants-v4331.md new file mode 100644 index 0000000..acfd692 --- /dev/null +++ b/memory/open-threads/thread-secret-fp-enum-constants-v4331.md @@ -0,0 +1,6 @@ +- [x] **Shipped v4.33.1 (PATCH) — ALL-CAPS enum constants are not credentials.** Check 10's + first field contact was one FP (a documented enum value); the detector now treats + ALL-CAPS identifiers as config constants while token shapes stay covered. Lesson + (honest-signal doctrine): fix the detector, never sprinkle waivers through client repos. + Full detail: origin log + the `4.33.0→4.33.1` rung. → serves: vision-agent-memory + diff --git a/memory/open-threads/thread-secret-fp-guidance-ident-v4381.md b/memory/open-threads/thread-secret-fp-guidance-ident-v4381.md new file mode 100644 index 0000000..92331c5 --- /dev/null +++ b/memory/open-threads/thread-secret-fp-guidance-ident-v4381.md @@ -0,0 +1,9 @@ +- [x] **Shipped v4.38.1 (PATCH) — scanner-neutral guidance constant** ([PR #26](https://github.com/acn-ericlaw/agent-memory/pull/26)). + Downstream field FP (Snyk, enterprise Java deployment): memory-lint's prose guidance + constant carried the hardcoded-secret detector shape (identifier-contains-SECRET + + string-literal assignment) and rejected builds. Renamed `GUIDANCE` (byte-identical + outputs, both runtimes); runtime-assembled hygiene self-check per suite (53 ×2, + red-verified first); SKILLS.md house rule. Lesson: the tool's subject matter names its + own identifiers into scanner FPs — prose constants stay scanner-neutral, now + suite-enforced. → serves: vision-agent-memory + diff --git a/memory/open-threads/thread-secret-fp-self-knob-v4342.md b/memory/open-threads/thread-secret-fp-self-knob-v4342.md new file mode 100644 index 0000000..7b8b51e --- /dev/null +++ b/memory/open-threads/thread-secret-fp-self-knob-v4342.md @@ -0,0 +1,7 @@ +- [x] **Shipped v4.34.2 (PATCH) — the guard's own opt-down knob is not a credential.** + mercury-composable field FP: documenting the guard's advisory knob tripped the guard; + fixed with their endorsed exact-key, value-constrained exemption (trailing-punctuation + variant caught pre-ship by the verbatim-fixture rule). Lesson: a guard must never block + the documentation of its own controls. Full detail: origin log + the `4.34.1→4.34.2` + rung. → serves: vision-agent-memory + diff --git a/memory/open-threads/thread-secret-redaction-lint-v4330.md b/memory/open-threads/thread-secret-redaction-lint-v4330.md new file mode 100644 index 0000000..6b43133 --- /dev/null +++ b/memory/open-threads/thread-secret-redaction-lint-v4330.md @@ -0,0 +1,8 @@ +- [x] **Shipped v4.33.0 (MINOR) — session-log secret redaction: ritual rule + + `[secret-material]` advisory.** From a client-side DLP incident (live OAuth secret in a + committed session log): the redaction rule joined the after-session ritual and lint + check 10 became the deterministic backstop (never echoes values; scans sessions + + archive). Lesson: shared memory must be safe to share — a committed secret is exposed, + rotate it. Full detail: origin log + the `4.32.1→4.33.0` rung. + → serves: vision-agent-memory + diff --git a/memory/open-threads/thread-secret-review-hardening-v4333.md b/memory/open-threads/thread-secret-review-hardening-v4333.md new file mode 100644 index 0000000..b158082 --- /dev/null +++ b/memory/open-threads/thread-secret-review-hardening-v4333.md @@ -0,0 +1,6 @@ +- [x] **Shipped v4.33.3 (PATCH) — security-review hardening for `[secret-material]`.** A + fresh-context review's four gaps fixed (real strict gate in CI wrappers; key-scoped enum + exemption; quoted/header/embedded forms flag; Mode C redaction triage); a pre-tag + live-target probe caught 2 template-pattern FPs the review missed. Lesson: always probe + the live target, not just suites. Full detail: origin log. → serves: vision-agent-memory + diff --git a/memory/open-threads/thread-secret-template-default-v4334.md b/memory/open-threads/thread-secret-template-default-v4334.md new file mode 100644 index 0000000..45cf1eb --- /dev/null +++ b/memory/open-threads/thread-secret-template-default-v4334.md @@ -0,0 +1,5 @@ +- [x] **Shipped v4.33.4 (PATCH) — reject non-empty `${…}` defaults in `[secret-material]`.** + v4.33.3's placeholder exemption also trusted literal fallbacks; template forms stay safe + while values with real-looking defaults flag without echo (46 mirror tests ×2). Full + detail: origin log. → serves: vision-agent-memory + diff --git a/memory/open-threads/thread-secret-test-fixtures-env.md b/memory/open-threads/thread-secret-test-fixtures-env.md new file mode 100644 index 0000000..adcdc39 --- /dev/null +++ b/memory/open-threads/thread-secret-test-fixtures-env.md @@ -0,0 +1,6 @@ +- [x] **Hardened `memory-lint` test fixtures against repo secret-scanner FPs (no release + bump).** Suites construct shape-constrained dummy secrets at runtime instead of + committing signatures; full coverage retained (46/46 ×2). Lesson: security tooling must + not make its own repository noisy to enterprise scanners. Full detail: origin log. + → serves: vision-agent-memory + diff --git a/memory/open-threads/thread-vbdi-lifecycle-direction.md b/memory/open-threads/thread-vbdi-lifecycle-direction.md new file mode 100644 index 0000000..d129a37 --- /dev/null +++ b/memory/open-threads/thread-vbdi-lifecycle-direction.md @@ -0,0 +1,20 @@ +- [ ] **Build a VBDI lifecycle layer** — *forward*-looking intent traceability to complement + the *backward*-looking memory layer. Mission: empower AI to deliver **predictable + innovation** with human partnership — a deterministic *process* + an enforceable *trace* + from intent → delivery, with a human gate at each altitude change (Vision → Blueprint → + Design → Implementation). "Predictable" = the process/trace, not the ideas. Reuses + existing primitives: id/origin/supersedes linkage (trace across altitudes), §10 + contradiction-check (drift detection: impl vs design, design vs blueprint vs vision), + §9 supersession (intent changes ripple down), the human-gate pattern + (never-pick-a-winner / invariant-verify prompt), and the smoke test (acceptance vs + design criteria). Stays no-code/markdown + deterministic. **Plan:** dogfood it — write + the Vision (human's to set), then `DESIGN-vbdi-lifecycle.md` (sibling to + `DESIGN-evolving-memory.md`), settle forks via structured decisions, build on the + version ladder. **Hard parts:** drift across altitudes needs real structure (not + hand-waving); keep gates lightweight (Open-Thread-like, not Jira); the trace must be + enforceable (grep/review), not just documented. Validated motivation: the Node→Rust + rewrite delivered deterministically with no drift — VBDI generalizes that to creation. + (Group note folded at the v4.39.0 thread migration: set 2026-06-14 after the vNext + backlog P1–P5 completed; paused at the framing stage by mutual agreement — to be + tackled methodically next.) + diff --git a/memory/sessions/2026-09-01-185108.md b/memory/sessions/2026-09-01-185108.md new file mode 100644 index 0000000..6d25b90 --- /dev/null +++ b/memory/sessions/2026-09-01-185108.md @@ -0,0 +1,79 @@ +# Session (2026-09-01T18:51:08.000Z) + +**Agent:** Claude Code + +## Summary + +Shipped **v4.39.0 (MINOR) — merge-scale memory: threads as files**, from Eric's field +report that `continuity.md` merge conflicts became regular business as team adoption grew +(the hotspots: `## Open Threads` and the `last_session` scalar). Design record: +`docs/DESIGN-merge-scale.md`; realizes part of the `bp-multi-user` Blueprint gap. + +**PR #27 reviewed, absorbed, closed (Eric-gated).** Roland Heusser's merge-driver PR +targeted the same pain. Verified against the live branch before judging (three findings, +each reproduced): (1) whole-file `git merge-file --union` silently merged a Tier 2 +same-thread edit into two checkbox lines sharing one footer — no conflict, no +Contradiction, invisible to lint's id-keyed footer map; (2) dateless Project State scalars +(`status`) resolved as a direction-dependent silent ours-wins — `never-pick-a-winner` in +mechanical form; (3) `|| true` on merge-file masked the empty-output truncation path. +Closed with the full evidence + credit; absorbed his `[duplicate-state-key]` check +(essentially verbatim, Co-Authored-By trailer) and his merge-rehearsal test pattern. +Lesson: a merge driver that "applies the mechanical rules" must classify conflict hunks or +it over-applies rule 1 to the one case that must reach a human; structure beat machinery +here (Eric's minimalist preference decided the direction — the chosen layout has no +runtime code, no per-clone registration, no partial-coverage mode). + +**Shipped:** + +- **One thread per file** — `memory/open-threads/thread-.md`; filename = the fact id + (stable identity, never timestamp-churned), content = exactly the thread's bullet block. + No index file: the directory is the index (an index line per thread would recreate the + add/add conflict one line at a time). Same-thread edits still conflict per-file — the + preserved Tier 2 human gate, pinned by a mutation-checked rehearsal test. +- **`last_session` dropped** (derivable: newest session log + its `**Agent:**` header); + **archive union-merge** (`memory/archive/*.md merge=union`, git-native, `[both]`/ + `[over-archived]` backstop the resurrect case); **reviews serialized** (REVIEW.md). +- **Scripts:** all three built-in pairs treat thread files as fact surfaces — + memory-lint (new `[thread-file]`, `[duplicate-id]`, `[duplicate-state-key]`; 67 tests + ×2, live output byte-identical across runtimes), refresh-metadata (in-place footer + refresh; 6 ×2), archive-fact (sweep moves the block + deletes the file; 9 ×2). +- **Contract test** `tests/test_thread_layout_merge.sh` — 4 real-git-merge cases: + different-threads clean, same-thread conflicts, archive union keeps both, and the + mutation check (stripping the union attribute restores the conflict). +- **Lockstep:** DECAY §1/§12, REVIEW, MERGE, schema, ENABLE 5b/5c/7b + thread-location + phrases, both PROTOCOL copies (activation reads open-threads; close ritual creates + thread files; no last_session), all four bootstrap "still scan" notes, templates + (continuity pointer note, open-threads/.gitkeep, gitattributes union block), MANIFEST + (open-threads row + 2 semantic steps incl. the mandated protocol-propagation row), + UPGRADE ladder row + `4.38.1 → 4.39.0` rung, VERSION, CHANGELOG, README (table + re-trimmed to 10), MIGRATE §D, smoke-tests, example fixture layout notes (real fixtures + left unfalsified), mkdocs nav. +- **Dogfood migration:** all 23 footered threads extracted verbatim to files; 12 legacy + footerless items minted ids per the 3.0.0 backfill rule (origin omitted — their true + origins predate the ids); the vbdi group note and bp-multi-user progress folded into + their files; stale "### Shipped —" group tombstones dropped (content lives in archive/ + logs); the durable "origin is GitHub" line extracted from the done publish thread into + Key Decisions (`github-origin-git-ops`); `status` cut 1,779 → 427 chars (schema + compliance); continuity 387 → ~130 lines. +- Gates: lint 0 errors + cross-runtime byte parity on the live tree; suites 67/6/9 ×2; + reconcile 38 ×2 + `--check-manifest` OK (19 semantic rows ≤ 4.39.0); both hook/merge + contract tests PASS. + +Deferred by design (recorded in the design doc): millisecond session filenames (BSD date +portability; MERGE.md carries the +1s rename rule instead) and a scalars-only merge +driver (pre-scoped in PR #27's close comment; build only on renewed field pain). + +## Memory References + +- Referenced: bp-multi-user (partially realized — progress noted in its thread file), + vision-agent-memory (serves), never-pick-a-winner (drove all three PR #27 findings), + no-build-step-agent-run (structure-over-machinery decision), ot-protocol-text-propagation + (the propagation obligation row shipped again), backlog-real-work-dogfood (field-report + flywheel: adoption pain → design → release) +- Created: merge-scale-v4390 (tier: working — the ship record), + github-origin-git-ops (tier: working — extracted from ot-publish-github-done), + ot-dogfood-backfill-optional, ot-examples-mode-b-fixture, ot-rerun-industry-assessment, + ot-surrealdb-set-aside, ot-test-cursor-aider-migration, ot-test-claude-jsonl-migration, + ot-test-continue-migration, ot-example-continue-migration, ot-disable-protocol, + ot-publish-github-done, ot-enterprise-github-migration, ot-claude-md-sync + (tier: working — legacy threads' ids minted at the thread migration, 3.0.0 backfill rule) diff --git a/memory/smoke-test.md b/memory/smoke-test.md index 1819efa..29fbcf7 100644 --- a/memory/smoke-test.md +++ b/memory/smoke-test.md @@ -23,7 +23,7 @@ review. Don't edit the questions to make them pass. 2. What is the stack — language, key dependencies, versions? *(→ continuity "Stack & Tools")* 3. What are the architectural invariants — things that must never change? *(→ continuity "Architectural Invariants")* 4. What were the last 2–3 key decisions, and **why**? *(→ continuity "Key Decisions" / recent sessions)* -5. What is in progress right now? *(→ continuity "Open Threads")* +5. What is in progress right now? *(→ `memory/open-threads/` — the unchecked thread files)* 6. What conventions should new code follow? *(→ instructions / continuity "Conventions")* 7. Any recorded user preferences or team / agent assignments? *(→ continuity — explicit only)* 8. Has any past decision been reversed or **superseded** — and by what? *(→ continuity superseded facts / `archive/INDEX.md`)* diff --git a/mkdocs.yml b/mkdocs.yml index e7c0aff..d81f901 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -135,6 +135,7 @@ nav: - "Design: Ritual Triggers": DESIGN-ritual-triggers.md - "Design: Target-State Reconcile": DESIGN-reconcile.md - "Design: Fresh-Context Review": DESIGN-fresh-context-review.md + - "Design: Merge-Scale Memory": DESIGN-merge-scale.md - Agent Instruction & Memory Security: architecture/security.md - Agent Cognitive Framework: agent-cognitive-framework.md - Architecture Decision Record: arch-decisions/ADR.md diff --git a/templates/.agent/schema.md b/templates/.agent/schema.md index 6e3ad1b..c2cce65 100644 --- a/templates/.agent/schema.md +++ b/templates/.agent/schema.md @@ -52,7 +52,6 @@ Live project state. Update every session. - project: string - status: string - last_enabled: YYYY-MM-DD -- last_session: YYYY-MM-DD | agent: string (or "none yet" — legacy: pre-4.32.1 enables only; a fresh enable points this at its first enable session log) - last_review: YYYY-MM-DD | through (or "none yet") - last_invariant_check: YYYY-MM-DD | through (or "none yet") — see REVIEW.md step 6 - last_harvest: YYYY-MM-DD | through (optional; omit until first run) — when the `harvest-knowledge` skill last folded docs into memory; it reads this to scope the next harvest and stamps it on completion @@ -62,11 +61,17 @@ Live project state. Update every session. ## Stack & Tools canonical live home for language/deps/tool versions (key: value) ## Key Decisions bullet list, present tense ## Conventions bullet list -## Open Threads - [ ] incomplete / - [x] complete (leave [x] for the review to sweep) +## Open Threads pointer note only — threads live one-per-file in memory/open-threads/ (v4.39.0) ## User Preferences bullet list — record ONLY what the user explicitly states; never infer ## Team / Members name: preferred agent ``` +There is deliberately **no `last_session` field** (dropped in v4.39.0): it is fully +derivable — the newest `memory/sessions/` filename is the last session, and that log's +`**Agent:**` header names the agent — and as a scalar that changed every session it was +the most frequent merge conflict in the file. A pre-4.39.0 repo may still carry the +line; treat it as legacy-informational (latest wins on conflict; safe to delete). + `## Stack & Tools` is the single canonical home for the current stack — language version, dependencies, tool versions. `instructions.md` gives only an enduring high-level descriptor and points here; don't maintain the dep list in both. @@ -82,23 +87,25 @@ lives in the project's own `CHANGELOG`/release notes and the **session logs** (e ### Concurrency & merge-friendliness (continuity.md is a shared file) `continuity.md` is committed and edited by every teammate, on any vendor — so author it to -**merge cleanly**. Session logs avoid conflicts by construction (timestamped filenames); -`continuity.md` cannot, so follow these conventions: +**merge cleanly**. Session logs and Open Thread files avoid conflicts by construction +(one file per event / per thread); the rest of `continuity.md` cannot, so follow these +conventions: - **One fact per line.** No monster lines. A short line that two people change is a trivial conflict; a 20 KB line is an unresolvable one. -- **Append-only sections are independent facts.** `## Open Threads`, `## Key Decisions`, - `## Conventions` accrete bullets that don't depend on each other. +- **Append-only sections are independent facts.** `## Key Decisions` and `## Conventions` + accrete bullets that don't depend on each other. (Open Threads no longer accrete here — + each is its own file under `memory/open-threads/`, so concurrent thread work can't + conflict at all.) - **Conflict resolution = keep both, by default.** When two branches both added to an append-only section, the merge is a **union** — keep every side's bullets (they're independent facts); never drop one to "resolve" faster. -- **Scalar bumps take the later value.** `last_session` / `last_review` / - `last_invariant_check` / the `status` version token: on conflict, keep the **later date / - higher version**. (`.agent/version.md` is the canonical version; `status`'s token is just a - human cue.) `last_session` is also derivable from the newest `sessions/` filename, so it's - informational — never block on it. +- **Scalar bumps take the later value.** `last_review` / `last_invariant_check` / the + `status` version token: on conflict, keep the **later date / higher version**. + (`.agent/version.md` is the canonical version; `status`'s token is just a human cue.) - **Same-thread edits need a human.** Only a genuine semantic clash — both sides editing the - *same* Open Thread, or a `[ ]`→`[x]` race — warrants judgment; everything else is mechanical. + *same* Open Thread file, or a `[ ]`→`[x]` race — warrants judgment; everything else is + mechanical. Thread files make that clash visible as a per-file conflict. - A left-behind conflict marker (`<<<<<<<`, `=======`, `>>>>>>>`) corrupts memory; `memory-lint` flags it as an ERROR. @@ -128,11 +135,11 @@ ordinary fact, `core` for an Architectural Invariant — and seeds `last_used: < recomputed by the review from session-log `## Memory References` (see `DECAY.md` §1). `## Architectural Invariants` facts and unchecked Open Threads (`- [ ]`) never decay. -Completed threads (`- [x]`) stay in place until the review sweeps them (see below / -`REVIEW.md`) — don't archive them by hand. A completed thread's record is a **3–6-line -close stub** — outcome, PR/commit/release refs, one durable lesson, `origin:` pointer — -never a full ship narrative: that belongs in the origin session log, and reviews condense -oversized records (`[closed-thread-bloat]`, v4.38.0). +Completed threads (`- [x]`) stay in their thread file until the review sweeps them (see +`memory/open-threads/` below / `REVIEW.md`) — don't archive them by hand. A completed +thread's record is a **3–6-line close stub** — outcome, PR/commit/release refs, one durable +lesson, `origin:` pointer — never a full ship narrative: that belongs in the origin session +log, and reviews condense oversized records (`[closed-thread-bloat]`, v4.38.0). When a fact becomes **false** (a decision reversed, a dependency dropped), don't just delete it: set its footer to `tier: superseded` + `superseded-by: ` (omit the @@ -141,6 +148,39 @@ and let the review archive it flagged "superseded." See `DECAY.md` §9. --- +## memory/open-threads/thread-.md + +**One Open Thread per file** (v4.39.0). `` is the thread's kebab fact id — the +filename is the identity and **never changes** for the thread's lifetime; updates edit the +file in place. This is what makes concurrent thread work merge-free: parallel branches +touching *different* threads touch different files (no conflict possible), and both sides +editing the *same* thread conflict per-file — a genuine Tier 2 semantic clash correctly +reaching a human (`MERGE.md`). + +File content is **exactly the thread's bullet block**, nothing else — the same shape that +previously sat under continuity's `## Open Threads`: + +``` +- [ ] **.** <body — what the thread is, why it's open, next action> + → serves: <vision-id or blueprint-id> (VBDI trace, where applicable) + <!-- id: <same-as-filename> | created: YYYY-MM-DD | last_used: YYYY-MM-DD | uses: N | tier: working --> +``` + +- **No index file.** The directory is the index, like `sessions/`: list + `memory/open-threads/` to discover threads; the checkbox in each file is its state + (`grep -l '^- \[ \]' memory/open-threads/` lists the open ones). An index would + recreate the add/add merge conflict one line at a time. +- **Lifecycle is unchanged, only the location moved.** An unchecked thread is pinned + (never decays); a completed one flips to `- [x]`, condenses to a 3–6-line stub, and + waits out `archive_window`; the review sweep moves the block to the quarter archive + + `INDEX.md` and deletes the file (`archive-fact` handles thread files). Contradiction / + Drift / new threads are created as new files. +- `memory-lint` pins the contract: `[thread-file]` (filename must match the footer id; + exactly one thread block per file) and `[duplicate-id]` (an id must exist once across + continuity + thread files — the backstop for creation collisions on parallel branches). + +--- + ## memory/sessions/YYYY-MM-DD-HHMMSS.md **A "session" is one write of a session-log file** — the unit of work since the last @@ -240,8 +280,8 @@ invariant-verification cadence (a vision can go stale). Created at enable/upgrad ⚠️ DRAFT stub — Current-state context inferred, target left for the human — **never fabricated**. See `DECAY.md` §12 and `docs/DESIGN-vbdi-lifecycle.md`. -The **Blueprint** (the Vision↔Current-State gap) is *not* a separate file — it is a set -of typed Open Threads in `continuity.md`: +The **Blueprint** (the Vision↔Current-State gap) is *not* a separate file — it is the set +of typed Open Threads (one file each under `memory/open-threads/`): `- [ ] (blueprint) <gap> → serves: <vision-id>`. Designs (Key Decisions) and Implementations (commits/sessions) trace up the altitude chain by `id`; a missing or broken link is drift, and it's grep-detectable. @@ -295,10 +335,17 @@ deleted; reactivation moves a fact back into `continuity.md` (see `REVIEW.md`). ``` archive/ - YYYY-QN.md facts (with their metadata footers) moved out of continuity.md, grouped by quarter + YYYY-QN.md facts (with their metadata footers) moved out of the live layer, grouped by quarter INDEX.md one line per archived fact: `id — one-line summary — <quarter file>` (greppable) ``` +Archive files are append-mostly, so `.gitattributes` marks them `merge=union` +(v4.39.0): concurrent review sweeps appending at end-of-file merge without conflict. +The one case union gets wrong — a reactivation's removed lines resurrected by the other +branch — is deterministically caught by `memory-lint`'s `[both]` / `[over-archived]` +ERRORs. Live files (`continuity.md`, thread files) never get union: there a conflict is +signal. + --- ## agent-skills/ (capability layer — cross-vendor) diff --git a/templates/.gitattributes b/templates/.gitattributes index dd1a726..d233c83 100644 --- a/templates/.gitattributes +++ b/templates/.gitattributes @@ -4,3 +4,11 @@ *.sh text eol=lf .githooks/* text eol=lf .githooks/*.d/* text eol=lf + +# agent-memory — archive files are append-mostly cold storage (review sweeps append at +# EOF), so let git union-merge them instead of raising spurious add/add conflicts +# (v4.39.0). Safe here because appends dominate; the one wrong case — a reactivation's +# removed lines resurrected by the other branch — is caught deterministically by +# memory-lint ([both] / [over-archived] ERRORs). Live memory files (continuity.md, +# open-threads/) deliberately do NOT get union: there a conflict is signal (MERGE.md). +memory/archive/*.md merge=union diff --git a/templates/CLAUDE.md b/templates/CLAUDE.md index a981cbd..8187f2f 100644 --- a/templates/CLAUDE.md +++ b/templates/CLAUDE.md @@ -8,8 +8,8 @@ universal discovery shim. The protocol and core memory files are imported below, so they are structurally present at session start (presence is guaranteed; *attending* to them is still the protocol). Imports -can't express dynamic paths — still scan the newest 2–3 logs in `memory/sessions/` per the -protocol. +can't express dynamic paths — still list `memory/open-threads/` (read the unchecked +thread files) and scan the newest 2–3 logs in `memory/sessions/` per the protocol. {{BOOTSTRAP_IMPORTS}} diff --git a/templates/GEMINI.md b/templates/GEMINI.md index 6865d84..0f49d4b 100644 --- a/templates/GEMINI.md +++ b/templates/GEMINI.md @@ -8,8 +8,8 @@ universal discovery shim. The protocol and core memory files are imported below, so they are structurally present at session start (presence is guaranteed; *attending* to them is still the protocol). Imports -can't express dynamic paths — still scan the newest 2–3 logs in `memory/sessions/` per the -protocol. +can't express dynamic paths — still list `memory/open-threads/` (read the unchecked +thread files) and scan the newest 2–3 logs in `memory/sessions/` per the protocol. {{BOOTSTRAP_IMPORTS}} diff --git a/templates/memory/PROTOCOL.md b/templates/memory/PROTOCOL.md index 109a82c..48d99cc 100644 --- a/templates/memory/PROTOCOL.md +++ b/templates/memory/PROTOCOL.md @@ -25,9 +25,13 @@ Before responding or doing substantive work: skill adapters and activates the committed hook dispatchers. Git cannot activate hooks on clone. 3. **Read, in order:** `memory/instructions.md`, `memory/continuity.md`, - `memory/vision.md`, then the newest 2–3 files in `memory/sessions/`. -4. **Honor multi-agent continuity.** Read `last_session` in `continuity.md`; when it names - another agent family, read that named session log in full before proceeding. + `memory/vision.md`, then list `memory/open-threads/` and read the unchecked (`- [ ]`) + thread files (one thread per file — the live workstreams), then the newest 2–3 files + in `memory/sessions/`. +4. **Honor multi-agent continuity.** The newest session log *is* the last session — its + `**Agent:**` header names the previous agent; when that is another agent family, read + that log in full before proceeding. (There is no `last_session` field to consult — + it was derivable and merge-hot, dropped in v4.39.0.) 5. **Retrieve before declaring context absent.** Search `memory/archive/INDEX.md` and follow a fact's `origin` when the topic is unfamiliar. Retrieval is lexical and indexed; archived facts are never deleted (`DECAY.md` §11). @@ -116,14 +120,16 @@ usage. ### Update continuity for a full log -1. Set `last_session` to today's date, agent name, and log stem. Keep `status` a short - current-state line, never a version history; history belongs in logs and changelogs. - Keep one fact per line; when merging, take the later state of the same fact and keep - unrelated facts from both sides. +1. Keep `status` a short current-state line, never a version history; history belongs in + logs and changelogs. Keep one fact per line; when merging, take the later state of the + same fact and keep unrelated facts from both sides. (The session log you just wrote is + itself the last-session record — there is no `last_session` field to bump.) 2. Update changed fact substance, not usage metadata. Mark completed Open Threads `[x]` - and condense each to a 3–6-line close record — outcome, PR/commit/release refs, one - durable lesson, `origin:` pointer; the full narrative belongs in this session's log — - then leave them for review to sweep. Add newly surfaced Open Threads. + in their `memory/open-threads/thread-<id>.md` files and condense each to a 3–6-line + close record — outcome, PR/commit/release refs, one durable lesson, `origin:` pointer; + the full narrative belongs in this session's log — then leave the files for review to + sweep. Create newly surfaced Open Threads as new `thread-<id>.md` files (filename = + the fact id; content = the single bullet block with its footer). 3. Before adding a fact, check existing and archived facts (`DECAY.md` §10). A new fact gets a kebab id and footer: `created`, `tier: working` (or `core` for an invariant), `origin: <this session's file>`, `last_used: today`, `uses: 1`. Raise an unchecked `Contradiction:` thread for diff --git a/templates/memory/continuity.md b/templates/memory/continuity.md index 05fa944..2776fd6 100644 --- a/templates/memory/continuity.md +++ b/templates/memory/continuity.md @@ -15,7 +15,6 @@ - **project:** {{PROJECT_NAME}} - **status:** {{PROJECT_STATUS}} - **last_enabled:** {{TODAY}} -- **last_session:** {{LAST_SESSION}} - **last_review:** (none yet) - **last_invariant_check:** (none yet) - **repo:** {{REPO_PATH}} @@ -44,10 +43,12 @@ ## Open Threads -> Mark completed items `- [x]` and leave them in place — the review sweeps them to -> the archive once older than `archive_window` sessions. Don't archive them by hand. - -{{OPEN_THREADS}} +> Open Threads live **one per file** in `memory/open-threads/` (`thread-<id>.md`; +> filename = the thread's fact id) so concurrent thread work never merge-conflicts. +> List that directory to see them; unchecked `- [ ]` threads are the live workstreams +> and never decay. Mark a completed thread `- [x]` in its file and leave it — the +> review sweeps it to the archive once older than `archive_window` sessions. Don't +> archive by hand. See `.agent/schema.md` → `memory/open-threads/`. ## User Preferences diff --git a/templates/memory/open-threads/.gitkeep b/templates/memory/open-threads/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/templates/memory/smoke-test.md b/templates/memory/smoke-test.md index dbad52e..52f912f 100644 --- a/templates/memory/smoke-test.md +++ b/templates/memory/smoke-test.md @@ -23,7 +23,7 @@ review. Don't edit the questions to make them pass. 2. What is the stack — language, key dependencies, versions? *(→ continuity "Stack & Tools")* 3. What are the architectural invariants — things that must never change? *(→ continuity "Architectural Invariants")* 4. What were the last 2–3 key decisions, and **why**? *(→ continuity "Key Decisions" / recent sessions)* -5. What is in progress right now? *(→ continuity "Open Threads")* +5. What is in progress right now? *(→ `memory/open-threads/` — the unchecked thread files)* 6. What conventions should new code follow? *(→ instructions / continuity "Conventions")* 7. Any recorded user preferences or team / agent assignments? *(→ continuity — explicit only)* 8. Has any past decision been reversed or **superseded** — and by what? *(→ continuity superseded facts / `archive/INDEX.md`)* diff --git a/tests/test_thread_layout_merge.sh b/tests/test_thread_layout_merge.sh new file mode 100755 index 0000000..83f4cb8 --- /dev/null +++ b/tests/test_thread_layout_merge.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# Contract tests for the merge-scale memory layout (v4.39.0). +# +# The layout claims three merge behaviors, pinned here with real git merges rather than +# asserted (the rehearsal pattern is absorbed from PR #27 — credit: Roland Heusser): +# (1) parallel work on DIFFERENT threads cannot conflict — one file per thread; +# (2) both sides editing the SAME thread file still conflicts — the MERGE.md Tier 2 +# human gate is load-bearing, so a clean merge here would be a regression; +# (3) archive files union-merge via the committed `.gitattributes` (git-native +# `merge=union`, no driver, no per-clone registration) — concurrent review +# sweeps' appends keep both sides. +# +# Uses only temporary git repositories; no repository Git metadata is changed. +set -u + +repo_root="$(CDPATH= cd "$(dirname "$0")/.." 2>/dev/null && pwd)" || exit 1 +scratch="$(mktemp -d "${TMPDIR:-/tmp}/agent-memory-thread-layout.XXXXXX")" || exit 1 +trap 'rm -rf "$scratch"' EXIT + +fail() { printf 'FAIL: %s\n' "$1" >&2; exit 1; } + +# A repo with the new layout: continuity (pointer note only), open-threads/, archive/, +# and the shipped .gitattributes (the union rule under test comes from the template). +make_repo() { + case_dir="$scratch/$1" + mkdir -p "$case_dir/memory/open-threads" "$case_dir/memory/archive" + cp "$repo_root/templates/.gitattributes" "$case_dir/.gitattributes" + printf '# Continuity — t\n\n## Project State\n\n- **project:** t\n\n## Open Threads\n\n> Threads live one per file in memory/open-threads/.\n' \ + > "$case_dir/memory/continuity.md" + printf -- '- [ ] **Base thread.** exists on base\n <!-- id: base-thread | created: 2026-09-01 | last_used: 2026-09-01 | uses: 1 | tier: working -->\n' \ + > "$case_dir/memory/open-threads/thread-base-thread.md" + printf -- '# Archive Index\n\n- old-fact — an archived fact — faded — 2026-Q2.md\n' \ + > "$case_dir/memory/archive/INDEX.md" + ( cd "$case_dir" || exit 1 + git init -q -b base . && git config user.email t@t && git config user.name t + git add -A && git commit -q -m base ) +} + +thread_file() { printf -- '- [ ] **Thread %s.** work by %s\n <!-- id: gap-%s | created: 2026-09-01 | last_used: 2026-09-01 | uses: 1 | tier: working -->\n' "$1" "$1" "$1"; } + +# (1) Different threads: two branches each create their own thread file -> clean merge, +# both files survive, no markers anywhere. +make_repo different +( cd "$scratch/different" || exit 1 + git checkout -q -b A + thread_file a > memory/open-threads/thread-gap-a.md + git add -A && git commit -q -m A + git checkout -q base && git checkout -q -b B + thread_file b > memory/open-threads/thread-gap-b.md + git add -A && git commit -q -m B + git merge -q A >/dev/null 2>&1 ) || fail "different-threads: merge did not complete cleanly" +[ -f "$scratch/different/memory/open-threads/thread-gap-a.md" ] || fail "different-threads: lost A's thread" +[ -f "$scratch/different/memory/open-threads/thread-gap-b.md" ] || fail "different-threads: lost B's thread" +if grep -rq '^<<<<<<<' "$scratch/different/memory"; then fail "different-threads: conflict markers left behind"; fi + +# (2) Same thread, both sides edit the body -> MUST conflict (Tier 2 human gate). +# A clean merge here would silently fork the thread — the regression this layout must never have. +make_repo same +( cd "$scratch/same" || exit 1 + git checkout -q -b A + perl -pi -e 's/exists on base/REWRITTEN BY A/' memory/open-threads/thread-base-thread.md + git commit -q -am A + git checkout -q base && git checkout -q -b B + perl -pi -e 's/exists on base/REWRITTEN BY B/' memory/open-threads/thread-base-thread.md + git commit -q -am B ) +if ( cd "$scratch/same" && git merge -q A >/dev/null 2>&1 ); then + fail "same-thread: both-sides edit merged silently; expected a conflict (Tier 2 human gate)" +fi +grep -q '^<<<<<<<' "$scratch/same/memory/open-threads/thread-base-thread.md" \ + || fail "same-thread: expected conflict markers in the thread file" + +# (3) Archive union: both branches append a different line to INDEX.md at EOF -> +# merges cleanly via the template's `memory/archive/*.md merge=union`, keeping both. +make_repo archive +( cd "$scratch/archive" || exit 1 + git checkout -q -b A + printf -- '- fact-a — swept by A — faded — 2026-Q3.md\n' >> memory/archive/INDEX.md + git commit -q -am A + git checkout -q base && git checkout -q -b B + printf -- '- fact-b — swept by B — faded — 2026-Q3.md\n' >> memory/archive/INDEX.md + git commit -q -am B + git merge -q A >/dev/null 2>&1 ) || fail "archive-union: merge did not complete cleanly" +grep -q 'fact-a' "$scratch/archive/memory/archive/INDEX.md" || fail "archive-union: lost A's line" +grep -q 'fact-b' "$scratch/archive/memory/archive/INDEX.md" || fail "archive-union: lost B's line" +if grep -q '^<<<<<<<' "$scratch/archive/memory/archive/INDEX.md"; then fail "archive-union: markers left behind"; fi + +# Mutation checks (a suite that cannot fail proves nothing): strip the union attribute +# and the archive append case must degrade to an ordinary conflict. +make_repo noattr +( cd "$scratch/noattr" || exit 1 + : > .gitattributes && git commit -q -am "drop attributes" + git checkout -q -b A + printf -- '- fact-a — swept by A — faded — 2026-Q3.md\n' >> memory/archive/INDEX.md + git commit -q -am A + git checkout -q base && git checkout -q -b B + printf -- '- fact-b — swept by B — faded — 2026-Q3.md\n' >> memory/archive/INDEX.md + git commit -q -am B ) +if ( cd "$scratch/noattr" && git merge -q A >/dev/null 2>&1 ); then + fail "mutation: without the union attribute the archive append should conflict" +fi + +printf 'PASS: thread layout merge contract — different-threads clean, same-thread conflicts, archive union (4 cases)\n'