diff --git a/.agent/schema.md b/.agent/schema.md index 6e3ad1b..c2cce65 100644 --- a/.agent/schema.md +++ b/.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/.agent/version.md b/.agent/version.md index 528fe8a..2bccc41 100644 --- a/.agent/version.md +++ b/.agent/version.md @@ -4,7 +4,7 @@ > Mode B can detect drift and upgrade in place (see the tool's `UPGRADE.md`). > `version` gates the upgrade ladder — don't hand-edit it unless you mean to. -- **version:** 4.38.1 +- **version:** 4.39.0 - **enabled_with:** 4.38.0 -- **last_upgraded:** 2026-08-25 +- **last_upgraded:** 2026-09-01 - **mode:** A diff --git a/.gitattributes b/.gitattributes index dd1a726..d235dfd 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,6 +1,7 @@ # agent-memory — keep executable scripts + git hooks LF so bash runs them on Windows (Git Bash / WSL). # Git for Windows defaults to core.autocrlf=true; without this, *.sh and .githooks/* would be rewritten # to CRLF on checkout and bash would fail with "bad interpreter: /usr/bin/env bash^M". +memory/archive/*.md merge=union *.sh text eol=lf .githooks/* text eol=lf .githooks/*.d/* text eol=lf 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-<id>.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-<id>.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/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-<id>.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/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-<id>.md`), then apply the Referenced bump. - `Superseded: <old> → <new>` (or `<old> (invalidated)`): confirm the old fact is marked `tier: superseded` + `superseded-by: <new>` (the agent marks it at write time — `DECAY.md` §9; set it here if missing) and the successor carries `supersedes: <old>`. -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/<quarter>.md` back into `continuity.md`, +- move the fact from its `archive/<quarter>.md` back into the live layer + (`continuity.md`; a thread back to `memory/open-threads/thread-<id>.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/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-<id>.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(/<!--\s*id:\s*([a-z0-9-]+)\s*\|/); + if (m) out.set(m[1], path); + } + return out; +} + export function archive_facts(root, ids, reason, quarter, note, dry_run) { const mem = join(root, "memory"); const cont_path = join(mem, "continuity.md"); const lines = read_text(cont_path).split("\n"); + const threads = thread_files(mem); const archived_ids = new Set(); const arch_dir = join(mem, "archive"); @@ -81,15 +98,18 @@ export function archive_facts(root, ids, reason, quarter, note, dry_run) { } } - const plans = []; + // An id lives either in continuity.md or in its own open-threads file — check both. + const cont_plans = []; // [fid, top, bot] in continuity.md + const thread_plans = []; // [fid, path] — the whole file is the block for (const fid of ids) { if (archived_ids.has(fid)) return { code: 1, msg: `refused: '${fid}' is already in the archive — nothing moved` }; const fidx = footer_line_index(lines, fid); - if (fidx === null) return { code: 1, msg: `refused: '${fid}' has no footer in continuity.md — nothing moved` }; - plans.push([fid, block_top(lines, fidx), fidx]); + if (fidx !== null) cont_plans.push([fid, block_top(lines, fidx), fidx]); + else if (threads.has(fid)) thread_plans.push([fid, threads.get(fid)]); + else return { code: 1, msg: `refused: '${fid}' has no footer in continuity.md or memory/open-threads/ — nothing moved` }; } - const plans_sorted = [...plans].sort((a, b) => b[1] - a[1]); // bottom-up removal + const plans_sorted = [...cont_plans].sort((a, b) => b[1] - a[1]); // bottom-up removal const now = new Date(); quarter = quarter || derive_quarter(now.getUTCFullYear(), now.getUTCMonth() + 1); @@ -115,19 +135,34 @@ export function archive_facts(root, ids, reason, quarter, note, dry_run) { archive_chunks.reverse(); index_chunks.reverse(); + const removals = []; // thread files deleted after the archive/INDEX writes land + for (const [fid, path] of thread_plans) { + const block = read_text(path).split("\n"); + while (block.length && block[block.length - 1].trim() === "") block.pop(); + const n = note && ids.length === 1 ? note : derive_note(block); + moved.push([fid, n]); + archive_chunks.push(block.join("\n")); + index_chunks.push(`- ${fid} — ${n} — ${reason} — ${quarter}.md`); + removals.push(path); + } + const heading = `\n## ${isoDate} — archived via archive-fact (${reason})\n\n### Faded facts\n\n`; const archive_block = heading + archive_chunks.join("\n\n") + "\n"; const index_block = index_chunks.join("\n") + "\n"; const new_cont = kept.join("\n"); - if (new_cont.trim() === "") return { code: 1, msg: "refused: removing those blocks would empty continuity.md — nothing moved" }; + if (cont_plans.length && new_cont.trim() === "") return { code: 1, msg: "refused: removing those blocks would empty continuity.md — nothing moved" }; const summary = moved.map(([fid, n]) => ` ${fid} → ${quarter}.md (${n})`).join("\n"); if (dry_run) return { code: 0, msg: `DRY-RUN — would move ${moved.length} fact(s):\n${summary}\n(no files changed)` }; + // Thread files delete LAST, after their content is safely in the archive. appendFileSync(arch_path, archive_block, "utf-8"); appendFileSync(index_path, index_block, "utf-8"); - writeFileSync(cont_path, new_cont, "utf-8"); // safe: new_cont already in memory + if (cont_plans.length) { + writeFileSync(cont_path, new_cont, "utf-8"); // safe: new_cont already in memory + } + for (const path of removals) unlinkSync(path); return { code: 0, msg: `moved ${moved.length} fact(s) to ${quarter}.md + INDEX:\n${summary}\nNow run memory-lint to confirm.` }; } diff --git a/agent-skills/archive-fact/scripts/archive-fact.py b/agent-skills/archive-fact/scripts/archive-fact.py index e3fcdc3..bbd2297 100644 --- a/agent-skills/archive-fact/scripts/archive-fact.py +++ b/agent-skills/archive-fact/scripts/archive-fact.py @@ -82,11 +82,30 @@ def derive_quarter(today): return f"{today.year}-Q{(today.month - 1) // 3 + 1}" +def 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).""" + out = {} + tdir = os.path.join(mem, "open-threads") + if not os.path.isdir(tdir): + return out + for name in sorted(os.listdir(tdir)): + if not name.endswith(".md"): + continue + path = os.path.join(tdir, name) + m = re.search(r"<!--\s*id:\s*([a-z0-9-]+)\s*\|", read_text(path)) + if m: + out[m.group(1)] = path + return out + + def archive_facts(root, ids, reason, quarter, note, dry_run): mem = os.path.join(root, "memory") cont_path = os.path.join(mem, "continuity.md") cont = read_text(cont_path) lines = cont.split("\n") + threads = thread_files(mem) # Archived-already guard: scan existing archive footers (any *.md but INDEX). archived_ids = set() @@ -97,19 +116,24 @@ def archive_facts(root, ids, reason, quarter, note, dry_run): for m in re.finditer(r"<!--\s*id:\s*([a-z0-9-]+)\s*\|", read_text(os.path.join(arch_dir, name))): archived_ids.add(m.group(1)) - # Validate ALL ids before touching anything (all-or-nothing). - plans = [] + # Validate ALL ids before touching anything (all-or-nothing). An id lives either in + # continuity.md or in its own open-threads file — check both surfaces. + cont_plans = [] # (fid, top, bot) in continuity.md + thread_plans = [] # (fid, path) — the whole file is the block for fid in ids: if fid in archived_ids: return 1, f"refused: '{fid}' is already in the archive — nothing moved" fidx = footer_line_index(lines, fid) - if fidx is None: - return 1, f"refused: '{fid}' has no footer in continuity.md — nothing moved" - top, bot = block_span(lines, fidx) - plans.append((fid, top, bot)) + if fidx is not None: + top, bot = block_span(lines, fidx) + cont_plans.append((fid, top, bot)) + elif fid in threads: + thread_plans.append((fid, threads[fid])) + else: + return 1, f"refused: '{fid}' has no footer in continuity.md or memory/open-threads/ — nothing moved" - # Order removals bottom-up so earlier indices stay valid. - plans_sorted = sorted(plans, key=lambda p: p[1], reverse=True) + # Order continuity removals bottom-up so earlier indices stay valid. + plans_sorted = sorted(cont_plans, key=lambda p: p[1], reverse=True) today = datetime.datetime.now(datetime.timezone.utc).date() quarter = quarter or derive_quarter(today) @@ -136,6 +160,17 @@ def archive_facts(root, ids, reason, quarter, note, dry_run): archive_chunks.reverse() index_chunks.reverse() + removals = [] # thread files deleted after the archive/INDEX writes land + for fid, path in thread_plans: + block = [ln for ln in read_text(path).split("\n")] + while block and not block[-1].strip(): + block.pop() + n = note if (note and len(ids) == 1) else derive_note(block) + moved.append((fid, n)) + archive_chunks.append("\n".join(block)) + index_chunks.append(f"- {fid} — {n} — {reason} — {quarter}.md") + removals.append(path) + heading = ( f"\n## {today.isoformat()} — archived via archive-fact ({reason})\n\n### Faded facts\n\n" ) @@ -143,20 +178,24 @@ def archive_facts(root, ids, reason, quarter, note, dry_run): index_block = "\n".join(index_chunks) + "\n" new_cont = "\n".join(kept) - if not new_cont.strip(): + if cont_plans and not new_cont.strip(): return 1, "refused: removing those blocks would empty continuity.md — nothing moved" summary = "\n".join(f" {fid} → {quarter}.md ({n})" for fid, n in moved) if dry_run: return 0, f"DRY-RUN — would move {len(moved)} fact(s):\n{summary}\n(no files changed)" - # WRITES — append-mode for the archive/INDEX (never truncates); single write for continuity. + # WRITES — append-mode for the archive/INDEX (never truncates); single write for + # continuity; thread files delete LAST, after their content is safely in the archive. with open(arch_path, "a", encoding="utf-8") as f: f.write(archive_block) with open(index_path, "a", encoding="utf-8") as f: f.write(index_block) - with open(cont_path, "w", encoding="utf-8") as f: # safe: new_cont already in memory - f.write(new_cont) + if cont_plans: + with open(cont_path, "w", encoding="utf-8") as f: # safe: new_cont already in memory + f.write(new_cont) + for path in removals: + os.remove(path) return 0, f"moved {len(moved)} fact(s) to {quarter}.md + INDEX:\n{summary}\nNow run memory-lint to confirm." diff --git a/agent-skills/archive-fact/scripts/test_archive_fact.mjs b/agent-skills/archive-fact/scripts/test_archive_fact.mjs index c91d0cb..ee26b5b 100644 --- a/agent-skills/archive-fact/scripts/test_archive_fact.mjs +++ b/agent-skills/archive-fact/scripts/test_archive_fact.mjs @@ -2,7 +2,7 @@ // Same fixtures, same expectations: the cross-runtime parity contract. Run: node --test <file> import { test } from "node:test"; import assert from "node:assert/strict"; -import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs"; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { archive_facts } from "./archive-fact.mjs"; @@ -113,3 +113,56 @@ test("note override (single id)", () => { rmSync(root, { recursive: true, force: true }); } }); + +// v4.39.0: a completed thread lives in its own memory/open-threads/thread-<id>.md; +// archiving moves the block to the quarter file + INDEX and DELETES the file — the +// sweep for the merge-free thread layout. +const GAMMA_THREAD = `- [x] **Shipped vZ — gamma.** Did gamma. + <!-- id: gamma-thread | created: 2026-01-04 | last_used: 2026-01-04 | uses: 1 | tier: working --> +`; + +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 <!-- id: stay-fact | tier: active -->\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-<id>.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. + <!-- id: gamma-thread | created: 2026-01-04 | last_used: 2026-01-04 | uses: 1 | tier: working --> +""" + + 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 <!-- id: stay-fact | tier: active -->\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-<id>.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-<id>.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 \`<!-- id: … -->\``); + 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-<id>.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 `<!-- id: … -->`") + 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. + <!-- id: ship-it | created: 2026-09-01 | last_used: 2026-09-01 | uses: 1 | tier: working --> +`; + +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 <!-- id: other | created: 2026-09-01 | last_used: 2026-09-01 | uses: 1 | tier: working -->\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 <!-- id: a-fact | tier: active -->\n"; + const threads = [["thread-b-thread.md", "- [ ] t\n <!-- id: b-thread | tier: working -->\n"]]; + assert.deepEqual(check_duplicate_ids(cont, threads), []); +}); + +test("duplicate-id: continuity + thread file flagged", () => { + const cont = "- fact\n <!-- id: same-id | tier: active -->\n"; + const threads = [["thread-same-id.md", "- [ ] t\n <!-- id: same-id | tier: working -->\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 <!-- id: same-id | tier: working -->\n"], + ["thread-other.md", "- [ ] t2\n <!-- id: same-id | tier: working -->\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 <!-- id: live-gap | created: 2026-09-01 | last_used: 2026-09-01 | uses: 1 | tier: working -->\n" + ); + writeFileSync( + join(root, "memory", "open-threads", "thread-done-gap.md"), + "- [x] **Done.** closed work\n <!-- id: done-gap | created: 2026-09-01 | last_used: 2026-09-01 | uses: 1 | tier: working -->\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 <!-- id: t | tier: working -->\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 <!-- id: a | tier: working -->\n"; + const threads = [["thread-b.md", "- [x] closed B\n line\n line\n <!-- id: b | tier: working -->\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. + <!-- id: ship-it | created: 2026-09-01 | last_used: 2026-09-01 | uses: 1 | tier: working --> +""" + + 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 <!-- id: other | created: 2026-09-01 | last_used: 2026-09-01 | uses: 1 | tier: working -->\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 <!-- id: a-fact | tier: active -->\n" + threads = [("thread-b-thread.md", "- [ ] t\n <!-- id: b-thread | tier: working -->\n")] + self.assertEqual(memory_lint.check_duplicate_ids(cont, threads), []) + + def test_id_in_continuity_and_thread_file_flagged(self): + cont = "- fact\n <!-- id: same-id | tier: active -->\n" + threads = [("thread-same-id.md", "- [ ] t\n <!-- id: same-id | tier: working -->\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 <!-- id: same-id | tier: working -->\n"), + ("thread-other.md", "- [ ] t2\n <!-- id: same-id | tier: working -->\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 <!-- id: live-gap | created: 2026-09-01 | last_used: 2026-09-01 | uses: 1 | tier: working -->\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 <!-- id: done-gap | created: 2026-09-01 | last_used: 2026-09-01 | uses: 1 | tier: working -->\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 <!-- id: t | tier: working -->\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 <!-- id: a | tier: working -->\n" + threads = [("thread-b.md", "- [x] closed B\n line\n line\n <!-- id: b | tier: working -->\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-<id>.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 <!-- id: open-gap | created: 2026-01-01 | last_used: 2026-01-01 | uses: 1 | tier: working -->\n" + ); + writeFileSync( + join(root, "memory", "open-threads", "thread-closed-gap.md"), + "- [x] closed gap\n <!-- id: closed-gap | created: 2026-01-01 | last_used: 2026-01-01 | uses: 1 | tier: working -->\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`<!--\s*id:\s*${fid}\s*\|([^\n]*?)-->`)); + 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 <!-- id: open-gap | created: 2026-01-01 | last_used: 2026-01-01 | uses: 1 | tier: working -->\n") + with open(os.path.join(root, "memory", "open-threads", "thread-closed-gap.md"), "w") as f: + f.write("- [x] closed gap\n <!-- id: closed-gap | created: 2026-01-01 | last_used: 2026-01-01 | uses: 1 | tier: working -->\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"<!--\s*id:\s*" + re.escape(fid) + r"\s*\|([^\n]*?)-->", 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/memory/PROTOCOL.md b/memory/PROTOCOL.md index 109a82c..48d99cc 100644 --- a/memory/PROTOCOL.md +++ b/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/memory/continuity.md b/memory/continuity.md index b9098a3..6617e6a 100644 --- a/memory/continuity.md +++ b/memory/continuity.md @@ -18,7 +18,6 @@ polyglot initiative: a lightweight Event-over-HTTP function host + thin client, repurposed August 2026 (legacy language pack in git history only) - **last_enabled:** 2026-08-22 -- **last_session:** 2026-09-01 | agent: Claude Code (2026-09-01-022620) - **last_review:** (none yet) - **last_invariant_check:** (none yet) - **repo:** ~/sandbox/mercury-python (origin: github.com/Accenture/mercury-python) @@ -31,15 +30,15 @@ - Python ≥ 3.10; build backend **hatchling**; package `mercury-composable` v4.12.0 (merged 2026-08-30, engine lock-step version line; PyPI publish pending), wheel from `src/mercury_composable` - <!-- id: stack-python-hatchling | created: 2026-08-22 | last_used: 2026-08-22 | uses: 1 | tier: working | origin: 2026-08-22-171555 --> + <!-- id: stack-python-hatchling | created: 2026-08-22 | last_used: 2026-09-01 | uses: 3 | tier: active | origin: 2026-08-22-171555 --> - Runtime deps: `aiohttp` >=3.10,<4 (Event API host), `msgpack` >=1,<2 (envelope codec), `PyYAML` >=6,<7 (config); dev: `pytest` >=8 + `pytest-asyncio` >=0.23 (`asyncio_mode=auto`); optional extras: `llm` = `anthropic` >=1,<2 + `google-genai` >=2,<3 (the AI-node provider SDKs — `pip install 'mercury-composable[llm]'`, added 2026-09-01) - <!-- id: stack-deps-aiohttp-msgpack | created: 2026-08-22 | last_used: 2026-08-22 | uses: 1 | tier: working | origin: 2026-08-22-171555 --> + <!-- id: stack-deps-aiohttp-msgpack | created: 2026-08-22 | last_used: 2026-09-01 | uses: 2 | tier: active | origin: 2026-08-22-171555 --> - Developer runner: `mercury-serve` console script (`mercury_composable.cli:main`); examples run via `mercury-serve app.py --port <n>` with `-D` overrides - <!-- id: stack-mercury-serve-cli | created: 2026-08-22 | last_used: 2026-08-22 | uses: 1 | tier: working | origin: 2026-08-22-171555 --> + <!-- id: stack-mercury-serve-cli | created: 2026-08-22 | last_used: 2026-09-01 | uses: 2 | tier: active | origin: 2026-08-22-171555 --> ## Architectural Invariants @@ -72,12 +71,12 @@ - **Polyglot reboot (August 2026):** instead of re-porting the full composable foundation, this repo restarts as a lightweight Event-over-HTTP wrapper; the pre-composable websocket-based language pack remains in git history only (CHANGELOG 0.1.0). - <!-- id: decision-polyglot-reboot | created: 2026-08-22 | last_used: 2026-08-22 | uses: 1 | tier: working | origin: 2026-08-22-171555 --> + <!-- id: decision-polyglot-reboot | created: 2026-08-22 | last_used: 2026-08-22 | uses: 2 | tier: archive-candidate | origin: 2026-08-22-171555 --> - **Two-audience root fork (Eric, 2026-08-22):** root `AGENTS.md` routes contributors to `memory/PROTOCOL.md` and consumers (developers writing polyglot functions — the "AI grammar" path) to `README.md`, which carries the quick start, function contract, and wire-format guide. - <!-- id: decision-consumer-fork-readme | created: 2026-08-22 | last_used: 2026-08-22 | uses: 1 | tier: working | origin: 2026-08-22-171555 --> + <!-- id: decision-consumer-fork-readme | created: 2026-08-22 | last_used: 2026-08-22 | uses: 1 | tier: archive-candidate | origin: 2026-08-22-171555 --> ## Conventions @@ -89,118 +88,21 @@ upstream). Run: `uvx ruff check .` / `uvx basedpyright` / `.venv/bin/pytest -q`. Unused contract params take the underscore prefix; deliberate suppressions carry rationale comments (PyBroadException / noqa only where the rule actually fires). - <!-- id: conv-python-quality-gates | created: 2026-08-23 | last_used: 2026-08-23 | uses: 1 | tier: working | origin: 2026-08-23-005709 --> + <!-- id: conv-python-quality-gates | created: 2026-08-23 | last_used: 2026-08-24 | uses: 3 | tier: active | origin: 2026-08-23-005709 --> - Engine-mirrored configuration/logging/trace conventions (see the invariant above and `instructions.md`); GitHub flow with tests + a CHANGELOG entry per change (CONTRIBUTING.md). - <!-- id: conv-github-flow-changelog | created: 2026-08-22 | last_used: 2026-08-22 | uses: 1 | tier: working | origin: 2026-08-22-171555 --> + <!-- id: conv-github-flow-changelog | created: 2026-08-22 | last_used: 2026-08-24 | uses: 2 | tier: active | origin: 2026-08-22-171555 --> ## Open Threads -- [x] (feature — **MERGED 2026-09-01 as - [PR #22](https://github.com/Accenture/mercury-python/pull/22) true merge `44caf9a6` - carrying `1fa5f70`; tree verified; branches deleted both ends) **AI nodes llm.chat + - llm.stream — provider-neutral LLM adapters (agent-orchestration E0).** One contract, two - editions (Anthropic + Gemini as optional extras `mercury-composable[llm]`); - schema-constrained verdicts for graph decision routing; token streams over the - multi-shot reply contract; Gemini AFC opted out (no tool surface — the graph decides, - the model advises). Live-proven from the engine's support-triage graph (its PR #304). - Lesson: PyCharm validates monkeypatch attr-name literals regardless of target typing — - route the name through a helper parameter. origin: 2026-09-01-022620. - <!-- id: ot-llm-ai-nodes | created: 2026-09-01 | last_used: 2026-09-01 | uses: 1 | tier: working | origin: 2026-09-01-022620 --> - -- [x] (feature — **MERGED 2026-08-30 as - [PR #21](https://github.com/Accenture/mercury-python/pull/21) true merge `bfca7e4` - carrying `d50986a`; tree verified; v4.12.0 milestone, all four repos lock-step) - **The progressive-rendering round: event streaming (engines' envelope-mode SSE - contract, reply_to bus mechanism, stream/stream_to consumers), business - correlation-id continuity, full span lineage with the engines' distributed-trace - dataset on stdout, app-log-context with the packaged default template, sender - attribution.** Lessons: detach long-lived workers from the creating task's - contextvars; install the log-context config before its own warning logs; RPC legs - emit no dataset (engine parity). origin: 2026-08-30-045556. - <!-- id: ot-streaming-telemetry-round-20260830 | created: 2026-08-30 | last_used: 2026-08-30 | uses: 1 | tier: working | origin: 2026-08-30-045556 --> - -- [x] (P4 docs — **SHIPPED and LIVE 2026-08-24**, same day as plan ratification) - **Documentation site "Composable for Python"** — engine Material theme, 13 files - incl. the one-page AI agent guide + llms.txt; live at accenture.github.io/mercury-python. - [PR #20](https://github.com/Accenture/mercury-python/pull/20) merge `0bc97f7` carrying - `a8ebde2` (tree verified, branches deleted); ci.yml maiden run green — the wrapper-CI - gap is closed. Lesson: mermaid on a new site verifies structurally against the - engine's live pages when the sandbox can't render CDN JS. Remaining P4 = engine - repos (polyglot chapter, ADR-0016, interop extension). Relates - [[bp-publish-interop-gate]]. origin: 2026-08-24-152125 - <!-- id: thread-docs-site | created: 2026-08-24 | last_used: 2026-08-24 | uses: 1 | tier: working | origin: 2026-08-24-152125 --> - -- [x] (feature — Eric's three loose ends 2026-08-24; **MERGED same day as - [PR #19](https://github.com/Accenture/mercury-python/pull/19), true merge `035b636` - carrying `da60593` (tree verified, branches deleted both ends); node twin merged in - its quality PR #88**) **Actuator polish: engine-parity index page, pretty - JSON, host error shape.** `GET /` = the engines' minimal Welcome page (embedded — no - static file service by design); actuator JSON pretty-printed (SimpleMapper default); - unknown paths/non-GET → `{"status", "message", "type": "error"}` with - `Resource not found` (SimpleHttpUtility signature, Java insertion order). Live-proven - byte-symmetric with node. Relates [[thread-actuator-endpoints]]. - <!-- id: thread-actuator-polish | created: 2026-08-24 | last_used: 2026-08-24 | uses: 1 | tier: working | origin: 2026-08-24-015208 --> - -- [x] (feature — Eric's directive 2026-08-24 after ratifying the sync-vs-async design; - **MERGED same day as [PR #18](https://github.com/Accenture/mercury-python/pull/18), - true merge `1888a48` carrying branch head `af039db` (4 commits: bridge + import hoist - + static _run_sync + unshadow); tree verified identical, branches deleted both - ends**) **Sync bridge: `PostOffice.request_sync()/send_sync()` from plain-def - handlers.** The bus stamps the host loop into a contextvar before dispatching sync - handlers; the bridge submits the same coroutines via `run_coroutine_threadsafe`, - blocking only the worker thread. Durable subtlety: **contextvars do not cross - run_coroutine_threadsafe** — the bridge re-establishes the caller's TraceInfo inside - the submitted task (same object), keeping the trace chain unbroken. Teaching errors: - on-loop call → "await request() instead"; off-host → use asyncio.run. Rationale docs - (requests/NumPy named, virtual-threads analog) in README + registry.py per Eric. - 4 pins + hello.sync.chain wire proof. Relates [[thread-primitive-event-bus]]. - <!-- id: thread-sync-bridge | created: 2026-08-24 | last_used: 2026-08-24 | uses: 1 | tier: working | origin: 2026-08-24-004715 --> - -- [x] (feature — RATIFIED + IMPLEMENTED + **MERGED 2026-08-23 as - [PR #17](https://github.com/Accenture/mercury-python/pull/17), true merge `f38ac17` - carrying branch head `1931f01`; tree verified identical, branches deleted both ends; - one PR with [[thread-actuator-endpoints]]**) **Primitive in-process event bus — the - single dispatch pipeline.** `instances`/`private` faithful; deliver + publish only; the - HTTP host and local PostOffice = thin ingress adapters. Durable ruling: NO spill tier / - NO queue cap — back-pressure belongs to the engines' flows/graphs (scope fence: - instructions.md). Full design, pins and wire proofs: origin log. - <!-- id: thread-primitive-event-bus | created: 2026-08-23 | last_used: 2026-08-23 | uses: 1 | tier: working | origin: 2026-08-23-005709 --> - -- [x] (feature — Eric's directive, IMPLEMENTED + **MERGED 2026-08-23 in the same - [PR #17](https://github.com/Accenture/mercury-python/pull/17) as the bus**) - **Actuator endpoints — the engines' operational surface for Kubernetes PODs.** - /info, /info/routes, /env, /health, /livenessprobe; health check functions speak the - engines' `type=info`/`type=health` contract through the bus; UP 200 / DOWN 400; - liveness follows the last health outcome. Durable lesson: engine `log.format` json = - PRETTY-printed, compact = single-line JSONL (the JsonAppender/CompactAppender pair). - Detail: origin log. Relates [[thread-primitive-event-bus]]. - <!-- id: thread-actuator-endpoints | created: 2026-08-23 | last_used: 2026-08-23 | uses: 1 | tier: working | origin: 2026-08-23-031558 --> -> 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. - -- [ ] **(blueprint) Publish behind the interop gate.** The wrapper is complete and green - and now versioned **v4.12.0 on main** (the milestone merge, 2026-08-30 — the version - aligns with the engine lock-step line, superseding the 0.1.0 plan), with the interop - gate green (the progressive-rendering interop report). The Vision's "releasable on its - own cadence" is unmet until it ships to PyPI; publishing is SEQUENCED (Eric, - 2026-08-30): after the first iteration of the AI SDLC feature completes; it stays - Eric-gated (ownership, cadence, supply-chain posture; design P5/D6). - → serves: vision-mercury-python - <!-- id: bp-publish-interop-gate | created: 2026-08-22 | last_used: 2026-08-22 | uses: 1 | tier: working | origin: 2026-08-22-173136 --> - -- [x] **(vision-bootstrap)** Vision ratified by Eric, 2026-08-22 — drafted from the - ratified polyglot design (D0–D8 + same-day refinements): tiny Event-over-HTTP wrapper, - engines own orchestration, protocol-compat releases, the scope fence as non-goals. - First Blueprint gap derived (publish behind the interop gate). Detail: - 2026-08-22-173136. - <!-- id: ot-vision-bootstrap | created: 2026-08-22 | last_used: 2026-08-22 | uses: 1 | tier: working | origin: 2026-08-22-171555 --> -- [ ] **Dedicated consumer AI surface (optional).** The root fork points consumers at - `README.md` for now (Eric, 2026-08-22). If the team wants a dedicated version-matched - surface later (family pattern: mercury-composable's `system/AGENTS.md`), author it and - retarget the fork's consumer link. - <!-- id: ot-consumer-surface | created: 2026-08-22 | last_used: 2026-08-22 | uses: 1 | tier: working | origin: 2026-08-22-171555 --> +> 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 +> (v4.39.0). 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`. + ## User Preferences diff --git a/memory/open-threads/thread-bp-publish-interop-gate.md b/memory/open-threads/thread-bp-publish-interop-gate.md new file mode 100644 index 0000000..bd98ef6 --- /dev/null +++ b/memory/open-threads/thread-bp-publish-interop-gate.md @@ -0,0 +1,9 @@ +- [ ] **(blueprint) Publish behind the interop gate.** The wrapper is complete and green + and now versioned **v4.12.0 on main** (the milestone merge, 2026-08-30 — the version + aligns with the engine lock-step line, superseding the 0.1.0 plan), with the interop + gate green (the progressive-rendering interop report). The Vision's "releasable on its + own cadence" is unmet until it ships to PyPI; publishing is SEQUENCED (Eric, + 2026-08-30): after the first iteration of the AI SDLC feature completes; it stays + Eric-gated (ownership, cadence, supply-chain posture; design P5/D6). + → serves: vision-mercury-python + <!-- id: bp-publish-interop-gate | created: 2026-08-22 | last_used: 2026-08-30 | uses: 6 | tier: working | origin: 2026-08-22-173136 --> diff --git a/memory/open-threads/thread-ot-consumer-surface.md b/memory/open-threads/thread-ot-consumer-surface.md new file mode 100644 index 0000000..180f94c --- /dev/null +++ b/memory/open-threads/thread-ot-consumer-surface.md @@ -0,0 +1,5 @@ +- [ ] **Dedicated consumer AI surface (optional).** The root fork points consumers at + `README.md` for now (Eric, 2026-08-22). If the team wants a dedicated version-matched + surface later (family pattern: mercury-composable's `system/AGENTS.md`), author it and + retarget the fork's consumer link. + <!-- id: ot-consumer-surface | created: 2026-08-22 | last_used: 2026-08-22 | uses: 1 | tier: working | origin: 2026-08-22-171555 --> diff --git a/memory/open-threads/thread-ot-llm-ai-nodes.md b/memory/open-threads/thread-ot-llm-ai-nodes.md new file mode 100644 index 0000000..86f649e --- /dev/null +++ b/memory/open-threads/thread-ot-llm-ai-nodes.md @@ -0,0 +1,11 @@ +- [x] (feature — **MERGED 2026-09-01 as + [PR #22](https://github.com/Accenture/mercury-python/pull/22) true merge `44caf9a6` + carrying `1fa5f70`; tree verified; branches deleted both ends) **AI nodes llm.chat + + llm.stream — provider-neutral LLM adapters (agent-orchestration E0).** One contract, two + editions (Anthropic + Gemini as optional extras `mercury-composable[llm]`); + schema-constrained verdicts for graph decision routing; token streams over the + multi-shot reply contract; Gemini AFC opted out (no tool surface — the graph decides, + the model advises). Live-proven from the engine's support-triage graph (its PR #304). + Lesson: PyCharm validates monkeypatch attr-name literals regardless of target typing — + route the name through a helper parameter. origin: 2026-09-01-022620. + <!-- id: ot-llm-ai-nodes | created: 2026-09-01 | last_used: 2026-09-01 | uses: 1 | tier: working | origin: 2026-09-01-022620 --> diff --git a/memory/open-threads/thread-ot-streaming-telemetry-round-20260830.md b/memory/open-threads/thread-ot-streaming-telemetry-round-20260830.md new file mode 100644 index 0000000..2b12427 --- /dev/null +++ b/memory/open-threads/thread-ot-streaming-telemetry-round-20260830.md @@ -0,0 +1,11 @@ +- [x] (feature — **MERGED 2026-08-30 as + [PR #21](https://github.com/Accenture/mercury-python/pull/21) true merge `bfca7e4` + carrying `d50986a`; tree verified; v4.12.0 milestone, all four repos lock-step) + **The progressive-rendering round: event streaming (engines' envelope-mode SSE + contract, reply_to bus mechanism, stream/stream_to consumers), business + correlation-id continuity, full span lineage with the engines' distributed-trace + dataset on stdout, app-log-context with the packaged default template, sender + attribution.** Lessons: detach long-lived workers from the creating task's + contextvars; install the log-context config before its own warning logs; RPC legs + emit no dataset (engine parity). origin: 2026-08-30-045556. + <!-- id: ot-streaming-telemetry-round-20260830 | created: 2026-08-30 | last_used: 2026-09-01 | uses: 3 | tier: active | origin: 2026-08-30-045556 --> diff --git a/memory/open-threads/thread-ot-vision-bootstrap.md b/memory/open-threads/thread-ot-vision-bootstrap.md new file mode 100644 index 0000000..49b3faf --- /dev/null +++ b/memory/open-threads/thread-ot-vision-bootstrap.md @@ -0,0 +1,6 @@ +- [x] **(vision-bootstrap)** Vision ratified by Eric, 2026-08-22 — drafted from the + ratified polyglot design (D0–D8 + same-day refinements): tiny Event-over-HTTP wrapper, + engines own orchestration, protocol-compat releases, the scope fence as non-goals. + First Blueprint gap derived (publish behind the interop gate). Detail: + 2026-08-22-173136. + <!-- id: ot-vision-bootstrap | created: 2026-08-22 | last_used: 2026-08-30 | uses: 3 | tier: active | origin: 2026-08-22-171555 --> diff --git a/memory/open-threads/thread-thread-actuator-endpoints.md b/memory/open-threads/thread-thread-actuator-endpoints.md new file mode 100644 index 0000000..7d5cfaa --- /dev/null +++ b/memory/open-threads/thread-thread-actuator-endpoints.md @@ -0,0 +1,9 @@ +- [x] (feature — Eric's directive, IMPLEMENTED + **MERGED 2026-08-23 in the same + [PR #17](https://github.com/Accenture/mercury-python/pull/17) as the bus**) + **Actuator endpoints — the engines' operational surface for Kubernetes PODs.** + /info, /info/routes, /env, /health, /livenessprobe; health check functions speak the + engines' `type=info`/`type=health` contract through the bus; UP 200 / DOWN 400; + liveness follows the last health outcome. Durable lesson: engine `log.format` json = + PRETTY-printed, compact = single-line JSONL (the JsonAppender/CompactAppender pair). + Detail: origin log. Relates [[thread-primitive-event-bus]]. + <!-- id: thread-actuator-endpoints | created: 2026-08-23 | last_used: 2026-08-24 | uses: 2 | tier: active | origin: 2026-08-23-031558 --> diff --git a/memory/open-threads/thread-thread-actuator-polish.md b/memory/open-threads/thread-thread-actuator-polish.md new file mode 100644 index 0000000..a717442 --- /dev/null +++ b/memory/open-threads/thread-thread-actuator-polish.md @@ -0,0 +1,10 @@ +- [x] (feature — Eric's three loose ends 2026-08-24; **MERGED same day as + [PR #19](https://github.com/Accenture/mercury-python/pull/19), true merge `035b636` + carrying `da60593` (tree verified, branches deleted both ends); node twin merged in + its quality PR #88**) **Actuator polish: engine-parity index page, pretty + JSON, host error shape.** `GET /` = the engines' minimal Welcome page (embedded — no + static file service by design); actuator JSON pretty-printed (SimpleMapper default); + unknown paths/non-GET → `{"status", "message", "type": "error"}` with + `Resource not found` (SimpleHttpUtility signature, Java insertion order). Live-proven + byte-symmetric with node. Relates [[thread-actuator-endpoints]]. + <!-- id: thread-actuator-polish | created: 2026-08-24 | last_used: 2026-08-24 | uses: 1 | tier: active | origin: 2026-08-24-015208 --> diff --git a/memory/open-threads/thread-thread-docs-site.md b/memory/open-threads/thread-thread-docs-site.md new file mode 100644 index 0000000..1cea161 --- /dev/null +++ b/memory/open-threads/thread-thread-docs-site.md @@ -0,0 +1,10 @@ +- [x] (P4 docs — **SHIPPED and LIVE 2026-08-24**, same day as plan ratification) + **Documentation site "Composable for Python"** — engine Material theme, 13 files + incl. the one-page AI agent guide + llms.txt; live at accenture.github.io/mercury-python. + [PR #20](https://github.com/Accenture/mercury-python/pull/20) merge `0bc97f7` carrying + `a8ebde2` (tree verified, branches deleted); ci.yml maiden run green — the wrapper-CI + gap is closed. Lesson: mermaid on a new site verifies structurally against the + engine's live pages when the sandbox can't render CDN JS. Remaining P4 = engine + repos (polyglot chapter, ADR-0016, interop extension). Relates + [[bp-publish-interop-gate]]. origin: 2026-08-24-152125 + <!-- id: thread-docs-site | created: 2026-08-24 | last_used: 2026-08-24 | uses: 1 | tier: active | origin: 2026-08-24-152125 --> diff --git a/memory/open-threads/thread-thread-primitive-event-bus.md b/memory/open-threads/thread-thread-primitive-event-bus.md new file mode 100644 index 0000000..3291732 --- /dev/null +++ b/memory/open-threads/thread-thread-primitive-event-bus.md @@ -0,0 +1,9 @@ +- [x] (feature — RATIFIED + IMPLEMENTED + **MERGED 2026-08-23 as + [PR #17](https://github.com/Accenture/mercury-python/pull/17), true merge `f38ac17` + carrying branch head `1931f01`; tree verified identical, branches deleted both ends; + one PR with [[thread-actuator-endpoints]]**) **Primitive in-process event bus — the + single dispatch pipeline.** `instances`/`private` faithful; deliver + publish only; the + HTTP host and local PostOffice = thin ingress adapters. Durable ruling: NO spill tier / + NO queue cap — back-pressure belongs to the engines' flows/graphs (scope fence: + instructions.md). Full design, pins and wire proofs: origin log. + <!-- id: thread-primitive-event-bus | created: 2026-08-23 | last_used: 2026-08-24 | uses: 4 | tier: active | origin: 2026-08-23-005709 --> diff --git a/memory/open-threads/thread-thread-sync-bridge.md b/memory/open-threads/thread-thread-sync-bridge.md new file mode 100644 index 0000000..092597c --- /dev/null +++ b/memory/open-threads/thread-thread-sync-bridge.md @@ -0,0 +1,14 @@ +- [x] (feature — Eric's directive 2026-08-24 after ratifying the sync-vs-async design; + **MERGED same day as [PR #18](https://github.com/Accenture/mercury-python/pull/18), + true merge `1888a48` carrying branch head `af039db` (4 commits: bridge + import hoist + + static _run_sync + unshadow); tree verified identical, branches deleted both + ends**) **Sync bridge: `PostOffice.request_sync()/send_sync()` from plain-def + handlers.** The bus stamps the host loop into a contextvar before dispatching sync + handlers; the bridge submits the same coroutines via `run_coroutine_threadsafe`, + blocking only the worker thread. Durable subtlety: **contextvars do not cross + run_coroutine_threadsafe** — the bridge re-establishes the caller's TraceInfo inside + the submitted task (same object), keeping the trace chain unbroken. Teaching errors: + on-loop call → "await request() instead"; off-host → use asyncio.run. Rationale docs + (requests/NumPy named, virtual-threads analog) in README + registry.py per Eric. + 4 pins + hello.sync.chain wire proof. Relates [[thread-primitive-event-bus]]. + <!-- id: thread-sync-bridge | created: 2026-08-24 | last_used: 2026-08-24 | uses: 1 | tier: active | origin: 2026-08-24-004715 --> diff --git a/memory/sessions/2026-09-01-202744.md b/memory/sessions/2026-09-01-202744.md new file mode 100644 index 0000000..7fb7fe2 --- /dev/null +++ b/memory/sessions/2026-09-01-202744.md @@ -0,0 +1,23 @@ +# Session (2026-09-01T20:27:45.000Z) + +**Agent:** Claude Code + +## Summary + +Mode B upgrade **4.38.1 → 4.39.0 (merge-scale memory: threads as files)** at Eric's +direction, via reconcile + the two 4.39.0 semantic steps. Mechanical: DECAY/REVIEW/MERGE/ +schema re-copied; the memory-lint, archive-fact, and refresh-metadata built-ins re-copied +(thread files are fact surfaces; new checks [thread-file]/[duplicate-id]/ +[duplicate-state-key]); `memory/archive/*.md merge=union` merged into .gitattributes +(renormalize check: staged diff stayed within agent-memory files). Semantic: all 10 Open +Thread blocks moved verbatim from continuity.md into memory/open-threads/thread-<id>.md +(every thread had a footer — no minting; only the old template note remained, replaced by +the pointer note); the last_session line deleted (derivable from the newest session log). +memory/PROTOCOL.md was byte-identical to the 4.38.0 template → re-copied per the +propagation row; the consumer-fork root routing untouched. Adapters re-synced (gitignored). + +Gates: memory-lint clean after migration ([thread-file]/[duplicate-id] gates pass). + +## Memory References + +(none — layout migration only; no fact substance changed)