diff --git a/packages/extension/AGENTS.md b/packages/extension/AGENTS.md index 88544aa5..def32d81 100644 --- a/packages/extension/AGENTS.md +++ b/packages/extension/AGENTS.md @@ -168,4 +168,5 @@ answers like a well-written engineering doc, not a terminal log: > - fleet: `~/.amico/ops/fleet/fleet.json` and `~/.amico/ops/fleet-status.json`. > - profile, problems, demos, mounts, memory: the personal Armonia mount (first > `kind = "personal"` dir under `~/.amico/vaults/`) — its `amicode/PROFILE.md`, -> `amicode/KNOWLEDGE.md`, `amicode/DEMOS.md`, and `amicode/memory/MEMORY.md`. +> `amicode/problems/` (problem-card frontmatter), `amicode/DEMOS.md`, and +> `amicode/memory/MEMORY.md`. diff --git a/packages/extension/DISTILLER.md b/packages/extension/DISTILLER.md index 88aff9a0..be7b58cb 100644 --- a/packages/extension/DISTILLER.md +++ b/packages/extension/DISTILLER.md @@ -172,9 +172,10 @@ New `-v` ONLY when fidelity strictly improves on the card's ### `KNOWLEDGE.md` — FROZEN (read-only; superseded by the typed memory store) -`KNOWLEDGE.md` is **no longer written**. It stays readable for back-compat — the -session bootstrap still splices its existing lines as "Your recent problems" — -but the distiller does not append to it. Problem cards are still written to +`KNOWLEDGE.md` is **no longer written**. It is also no longer read: the session +bootstrap derives "Your recent problems" from the problem cards' frontmatter +(`problems/*.md`) directly, so the frozen index's lines reach no session — +but the distiller does not append to it either. Problem cards are still written to `problems/` (the card frontmatter is the source of truth, Hard rule 7); only the flat index is frozen. Durable *facts* now live in the typed memory store (`memory/`, see below). Historical line shape, for reading only: diff --git a/packages/extension/opencode-plugin/stack_state.ts b/packages/extension/opencode-plugin/stack_state.ts index d735e4bc..13b801f9 100644 --- a/packages/extension/opencode-plugin/stack_state.ts +++ b/packages/extension/opencode-plugin/stack_state.ts @@ -526,6 +526,114 @@ function readIndexLines(vaultDir: string, file: string, cap: number): string[] { .slice(0, cap); } +// ── Problem-card frontmatter (the recent-problems source of truth) ─────────── +// +// "Your recent problems" used to be spliced from the personal vault's +// amicode/KNOWLEDGE.md — a flat index the distiller FROZE (it no longer writes +// it), so its lines went stale the day they were written: the index claimed a +// problem was "solved 20×" while the card it links recorded 29 solves. The +// cards themselves (amicode/problems/*.md frontmatter) are the maintained +// source of truth — derive the bullets from them instead. + +/** Scalar frontmatter fields of a problem card, parsed leniently: top-level + * `key: value` lines between `---` delimiters, quotes unquoted, inline + * comments stripped (outside quotes). Block scalars and lists are ignored — + * only the scalar fields the splice renders matter here. Returns undefined + * when the file has no well-formed frontmatter block; call sites skip such + * cards rather than rendering them. Deliberately hand-rolled: stack_state.ts + * stays node-builtin-only (the plugin's Bun runtime has no `yaml` package — + * same reason parseMarker above is a regex-lite TOML parse). */ +function parseCardFrontmatter(text: string): Record | undefined { + const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/); + if (!m) return undefined; + const fm: Record = {}; + for (const line of m[1].split(/\r?\n/)) { + const kv = line.match(/^([A-Za-z_][\w-]*):(?:\s+(.*))?$/); + if (!kv) continue; // blank lines, list items, indented blocks (sys_params…) + let v = kv[2] ?? ""; + const quoted = v.match(/^"((?:[^"\\]|\\.)*)"(?:\s+#.*)?$/) ?? v.match(/^'([^']*)'(?:\s+#.*)?$/); + if (quoted) v = quoted[1]; + else v = v.replace(/\s+#.*$/, "").trim(); + fm[kv[1]] = v; + } + return fm; +} + +/** Frontmatter scalar absent per the distiller contract: missing, empty, or a + * literal YAML null (which parses here as the string "null"). */ +function fmAbsent(v: string | undefined): boolean { + return v === undefined || v === "" || v === "null"; +} + +/** Render one problem card as the reader-facing bullet — the same line shape + * the frozen KNOWLEDGE.md index carried (DISTILLER.md's "historical line + * shape"), now derived from the card's own frontmatter so the numbers can + * never go stale: `- [slug](problems/slug.md) — platform kind target, status + * (+count), best F=…, pulse: …`. best_fidelity is rendered verbatim from the + * card — the splice never re-rounds a number. */ +function renderProblemCardLine(slug: string, fm: Record): string { + const n = Number(fm.solve_count); + const count = Number.isFinite(n) && !fmAbsent(fm.solve_count) ? Math.trunc(n) : undefined; + let statusText: string; + switch (fm.status) { + case "solved": + statusText = count !== undefined ? `solved ${count}×` : "solved"; + break; + case "attempted": + statusText = count !== undefined ? `ATTEMPTED (${count} failed)` : "ATTEMPTED"; + break; + case "failed": + statusText = count !== undefined ? `FAILED (${count} failed)` : "FAILED"; + break; + default: + statusText = fmAbsent(fm.status) ? "" : (fm.status ?? ""); + } + const desc = [fm.platform, fm.problem_kind, fm.target] + .filter((v) => !fmAbsent(v)) + .join(" "); + const tail: string[] = []; + if (desc || statusText) tail.push([desc, statusText].filter(Boolean).join(", ")); + if (!fmAbsent(fm.best_fidelity)) tail.push(`best F=${fm.best_fidelity}`); + if (!fmAbsent(fm.pulse_ref)) tail.push(`pulse: ${fm.pulse_ref.replace(/^pulses\//, "")}`); + else if (fm.status === "attempted" || fm.status === "failed") tail.push("no pulse yet"); + let line = `- [${slug}](problems/${slug}.md)`; + if (tail.length > 0) line += ` — ${tail.join(", ")}`; + return line; +} + +/** Recent-problem bullets from the personal vault's problem cards + * (amicode/problems/*.md frontmatter — the distiller-maintained source of + * truth), NOT the frozen KNOWLEDGE.md index. Sorted by last_seen, freshest + * first (missing last_seen sorts last; ties keep filename order), and capped + * at the same line budget the index read used — so the 50 most recently + * touched cards survive the cut. */ +function readRecentProblemLines(vault: string, cap: number): string[] { + const dir = path.join(vault, "amicode", "problems"); + let files: string[]; + try { + files = fs.readdirSync(dir).filter((f) => f.endsWith(".md")).sort(); + } catch { + return []; // no problems dir (or unreadable) → section omitted + } + const cards: { line: string; lastSeen: string }[] = []; + for (const file of files) { + try { + const text = fs.readFileSync(path.join(dir, file), "utf8"); + const fm = parseCardFrontmatter(text); + if (!fm) continue; // no well-formed frontmatter block: skip, don't render + const slug = !fmAbsent(fm.slug) ? fm.slug : file.replace(/\.md$/, ""); + cards.push({ line: renderProblemCardLine(slug, fm), lastSeen: fm.last_seen ?? "" }); + } catch { + continue; // unreadable card: skip — never crash the splice on one bad file + } + } + // last_seen values are ISO dates: lexicographic order is chronological. + // Empty string (missing last_seen) sorts below any date. Array#sort is + // stable (ES2019+), so same-last_seen cards keep their filename order. + cards.sort((a, b) => b.lastSeen.localeCompare(a.lastSeen)); + return cards.slice(0, cap).map((c) => c.line); +} + /** Section builders — text pinned by test/stack_state.test.ts (golden strings). */ function buildAboutUserSection(profileMd: string): string { @@ -639,7 +747,7 @@ export function buildStackStateBlock(): string | null { if (vault) { const about = buildAboutUserSection(readProfileMd(vault)); if (about) parts.push(about); - const recent = buildRecentProblemsSection(readIndexLines(vault, "KNOWLEDGE.md", 50)); + const recent = buildRecentProblemsSection(readRecentProblemLines(vault, 50)); if (recent) parts.push(recent); const demos = buildReferenceDemosSection(readIndexLines(vault, "DEMOS.md", 30)); if (demos) parts.push(demos); diff --git a/packages/extension/src/substrate/vault_store.ts b/packages/extension/src/substrate/vault_store.ts index 5016d1a7..0015c5d8 100644 --- a/packages/extension/src/substrate/vault_store.ts +++ b/packages/extension/src/substrate/vault_store.ts @@ -1,11 +1,12 @@ /** Vault resolution + onboarding-stream readers (spec-20260705-002847 §2, §3 - * routing). The user-memory section BUILDERS + index readers (KNOWLEDGE / - * DEMOS / memory index) moved to the amicode_context plugin's live - * stack_state.ts (injected per-prompt); what remains here is what prep-time - * code still needs — the personal-vault resolver, PROFILE.md presence (the - * onboarding routing predicate), and the onboarding-stream marker. Everything - * is read-only and failure-tolerant: a missing vault, file, or stream simply - * yields the empty value and the session proceeds unpersonalized. */ + * routing). The user-memory section BUILDERS + index readers (problem-card + * frontmatter / DEMOS / memory index) moved to the amicode_context plugin's + * live stack_state.ts (injected per-prompt); what remains here is what + * prep-time code still needs — the personal-vault resolver, PROFILE.md + * presence (the onboarding routing predicate), and the onboarding-stream + * marker. Everything is read-only and failure-tolerant: a missing vault, + * file, or stream simply yields the empty value and the session proceeds + * unpersonalized. */ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; diff --git a/packages/extension/test/stack_state.test.ts b/packages/extension/test/stack_state.test.ts index e3e70a50..ae867fdd 100644 --- a/packages/extension/test/stack_state.test.ts +++ b/packages/extension/test/stack_state.test.ts @@ -33,12 +33,39 @@ function mkFixtureVault(root: string): string { const v = mkVault(root, "armonia-fixture", "personal"); fs.mkdirSync(path.join(v, "amicode", "memory"), { recursive: true }); fs.writeFileSync(path.join(v, "amicode", "PROFILE.md"), "# Profile — Fixture\n- Role: researcher\n"); - fs.writeFileSync(path.join(v, "amicode", "KNOWLEDGE.md"), "- [p1](problems/p1.md) — thing one\n"); + mkProblemCard(v, "p1", { + type: "amicode-problem", + slug: "p1", + platform: "transmon", + problem_kind: "gate_synthesis", + target: "X", + status: "solved", + best_fidelity: "0.99995", + solve_count: 8, + first_seen: "2026-07-03", + last_seen: "2026-07-04", + pulse_ref: "pulses/p1-v1", + }); fs.writeFileSync(path.join(v, "amicode", "DEMOS.md"), "- [d1](demos/d1.md) — demo one\n"); fs.writeFileSync(path.join(v, "amicode", "memory", "MEMORY.md"), "- [m1](m1.md) — fact one\n"); return v; } +/** Write a distiller-shaped problem card (frontmatter + body) into the vault. */ +function mkProblemCard( + vault: string, + slug: string, + frontmatter: Record, + body = `# ${slug}\n`, +): void { + const dir = path.join(vault, "amicode", "problems"); + fs.mkdirSync(dir, { recursive: true }); + const fm = Object.entries(frontmatter) + .map(([k, val]) => `${k}: ${val}`) + .join("\n"); + fs.writeFileSync(path.join(dir, `${slug}.md`), `---\n${fm}\n---\n\n${body}`); +} + // ── Fleet section ──────────────────────────────────────────────────────────── describe("buildFleetSection (lean fleet line + pointers)", () => { @@ -179,15 +206,15 @@ describe("user-memory section text (parity oracle vs the retired user_splice.ts) restoreSeams(stubs); } }); - it("## Your recent problems carries the warm-start guidance", () => { - const stubs = stubAllSeams({ vault: "knowledge" }); + it("## Your recent problems carries the warm-start guidance (derived from the p1 card)", () => { + const stubs = stubAllSeams({ vault: "problems" }); try { const block = buildStackStateBlock() ?? ""; expect(block).toContain( [ "## Your recent problems", "", - "- [p1](problems/p1.md) — thing one", + "- [p1](problems/p1.md) — transmon gate_synthesis X, solved 8×, best F=0.99995, pulse: p1-v1", "", "Before recommending parameters, check whether the user's target matches one", "of these cards (read the card file on demand for details). If a prior", @@ -252,19 +279,30 @@ describe("user-memory section text (parity oracle vs the retired user_splice.ts) // ── Caps + composition ─────────────────────────────────────────────────────── describe("caps + composition", () => { - it("KNOWLEDGE/DEMOS/MEMORY list lines are capped (50/30/50)", () => { + it("problem cards/DEMOS/MEMORY bullets are capped (50/30/50)", () => { const root = mkTmp("vaults-"); const v = mkVault(root, "capped", "personal"); fs.mkdirSync(path.join(v, "amicode", "memory"), { recursive: true }); + for (let i = 0; i < 60; i++) { + const date = new Date(Date.UTC(2026, 0, 1 + i)).toISOString().slice(0, 10); + mkProblemCard(v, `card${i}`, { + slug: `card${i}`, + platform: "transmon", + problem_kind: "gate_synthesis", + target: `G${i}`, + status: "solved", + solve_count: i + 1, + last_seen: date, + }); + } const mk = (n: number, t: (i: number) => string) => Array.from({ length: n }, (_, i) => t(i)).join("\n"); - fs.writeFileSync(path.join(v, "amicode", "KNOWLEDGE.md"), mk(60, (i) => `- k${i}`)); fs.writeFileSync(path.join(v, "amicode", "DEMOS.md"), mk(40, (i) => `- d${i}`)); fs.writeFileSync(path.join(v, "amicode", "memory", "MEMORY.md"), mk(60, (i) => `- m${i}`)); const stubs = stubAllSeams({ vaultsRoot: root }); try { const block = buildStackStateBlock() ?? ""; const count = (re: RegExp) => (block.match(re) ?? []).length; - expect(count(/^- k\d+$/gm)).toBe(50); + expect(count(/^- \[card\d+\]/gm)).toBe(50); expect(count(/^- d\d+$/gm)).toBe(30); expect(count(/^- m\d+$/gm)).toBe(50); } finally { @@ -370,6 +408,143 @@ describe("buildLiveRunsBlock (live individually, zombies flagged, backlog summar }); }); +// ── Recent problems: derived from problem-card frontmatter ─────────────────── + +describe("recent problems derived from problem-card frontmatter (KNOWLEDGE.md zombie dead)", () => { + /** Extract just the recent-problems section from the composed block. */ + function recentProblemsSection(): string { + const block = buildStackStateBlock() ?? ""; + const m = block.match(/## Your recent problems[\s\S]*?(?=\n\n## |\n*$)/); + return m ? m[0] : ""; + } + + it("a card recording 29 solves is reported even when the frozen index still claims 20", () => { + const root = mkTmp("vaults-"); + const v = mkVault(root, "zombie", "personal"); + fs.mkdirSync(path.join(v, "amicode", "memory"), { recursive: true }); + // The frozen index — the distiller stopped writing it; its lines lie. + fs.writeFileSync( + path.join(v, "amicode", "KNOWLEDGE.md"), + "- [x-gate-transmon](problems/x-gate-transmon.md) — transmon gate X, solved 20×, best F=0.999986, pulse: x-gate-transmon-v6\n", + ); + // The card — the distiller-maintained frontmatter, 29 solves as of Aug 16. + mkProblemCard(v, "x-gate-transmon", { + type: "amicode-problem", + slug: "x-gate-transmon", + platform: "transmon", + problem_kind: "gate_synthesis", + target: "X", + status: "solved", + best_fidelity: "0.9999858416888963", + best_run: "r20260713-142744Z-d508", + pulse_ref: "pulses/x-gate-transmon-v6", + solve_count: 29, + first_seen: "2026-07-03", + last_seen: "2026-08-16", + }); + const stubs = stubAllSeams({ vaultsRoot: root }); + try { + const s = recentProblemsSection(); + expect(s).toContain("solved 29×"); + expect(s).not.toContain("20×"); + } finally { + restoreSeams(stubs); + } + }); + + it("cards sort by last_seen, freshest first (filename order is irrelevant)", () => { + const root = mkTmp("vaults-"); + const v = mkVault(root, "order", "personal"); + fs.mkdirSync(path.join(v, "amicode", "memory"), { recursive: true }); + const base = { + type: "amicode-problem", + platform: "transmon", + problem_kind: "gate_synthesis", + status: "solved", + solve_count: 1, + }; + mkProblemCard(v, "beta", { ...base, slug: "beta", target: "Y", last_seen: "2026-07-04" }); + mkProblemCard(v, "alpha", { ...base, slug: "alpha", target: "X", last_seen: "2026-08-16" }); + mkProblemCard(v, "gamma", { ...base, slug: "gamma", target: "Z", last_seen: "2026-07-10" }); + const stubs = stubAllSeams({ vaultsRoot: root }); + try { + const bullets = recentProblemsSection() + .split("\n") + .filter((l) => l.startsWith("- [")) + .map((l) => l.match(/^- \[([a-z]+)\]/)?.[1]); + expect(bullets).toEqual(["alpha", "gamma", "beta"]); + } finally { + restoreSeams(stubs); + } + }); + + it("a card missing last_seen sorts last (ties keep filename order)", () => { + const root = mkTmp("vaults-"); + const v = mkVault(root, "nostamp", "personal"); + fs.mkdirSync(path.join(v, "amicode", "memory"), { recursive: true }); + const base = { type: "amicode-problem", platform: "transmon", problem_kind: "gate_synthesis", status: "solved", solve_count: 1 }; + mkProblemCard(v, "aaa-nostamp", { ...base, slug: "aaa-nostamp", target: "A" }); + mkProblemCard(v, "zzz-old", { ...base, slug: "zzz-old", target: "B", last_seen: "2026-07-01" }); + mkProblemCard(v, "mmm-new", { ...base, slug: "mmm-new", target: "C", last_seen: "2026-08-16" }); + const stubs = stubAllSeams({ vaultsRoot: root }); + try { + const bullets = recentProblemsSection() + .split("\n") + .filter((l) => l.startsWith("- [")) + .map((l) => l.match(/^- \[([a-z-]+)\]/)?.[1]); + expect(bullets).toEqual(["mmm-new", "zzz-old", "aaa-nostamp"]); + } finally { + restoreSeams(stubs); + } + }); + + it("malformed frontmatter is skipped — a bad card never crashes the splice", () => { + const root = mkTmp("vaults-"); + const v = mkVault(root, "malformed", "personal"); + fs.mkdirSync(path.join(v, "amicode", "memory"), { recursive: true }); + fs.mkdirSync(path.join(v, "amicode", "problems"), { recursive: true }); + // A README with no frontmatter block at all. + fs.writeFileSync(path.join(v, "amicode", "problems", "README.md"), "# problems\nCards live here.\n"); + // A card whose frontmatter block never closes. + fs.writeFileSync( + path.join(v, "amicode", "problems", "truncated.md"), + "---\ntype: amicode-problem\nslug: truncated\nplatform: transmon\n", + ); + // One well-formed card, so the section exists to assert against. + mkProblemCard(v, "valid", { + type: "amicode-problem", + slug: "valid", + platform: "transmon", + problem_kind: "gate_synthesis", + target: "X", + status: "solved", + solve_count: 3, + last_seen: "2026-08-01", +}); + const stubs = stubAllSeams({ vaultsRoot: root }); + try { + const s = recentProblemsSection(); + expect(s).toContain("solved 3×"); + expect(s).not.toContain("truncated"); + expect(s).not.toContain("README"); + } finally { + restoreSeams(stubs); + } + }); + + it("a personal vault with zero problem cards → the section is omitted", () => { + const root = mkTmp("vaults-"); + const v = mkVault(root, "empty-cards", "personal"); + fs.mkdirSync(path.join(v, "amicode"), { recursive: true }); + const stubs = stubAllSeams({ vaultsRoot: root }); + try { + expect(recentProblemsSection()).toBe(""); + } finally { + restoreSeams(stubs); + } + }); +}); + // ── Env-seam plumbing ──────────────────────────────────────────────────────── interface SeamOpts { @@ -378,7 +553,7 @@ interface SeamOpts { fleetStatus?: string; runsDir?: string; /** Prebuilt fixture vault flavor for the golden-text cases. */ - vault?: "profile" | "knowledge" | "demos" | "memory"; + vault?: "profile" | "problems" | "demos" | "memory"; } const SEAM_KEYS = [ @@ -410,8 +585,20 @@ function stubAllSeams(opts: SeamOpts): Record { if (opts.vault === "profile") { fs.writeFileSync(path.join(v, "amicode", "PROFILE.md"), "# Profile — Fixture\n- Role: researcher\n"); } - if (opts.vault === "knowledge") { - fs.writeFileSync(path.join(v, "amicode", "KNOWLEDGE.md"), "- [p1](problems/p1.md) — thing one\n"); + if (opts.vault === "problems") { + mkProblemCard(v, "p1", { + type: "amicode-problem", + slug: "p1", + platform: "transmon", + problem_kind: "gate_synthesis", + target: "X", + status: "solved", + best_fidelity: "0.99995", + solve_count: 8, + first_seen: "2026-07-03", + last_seen: "2026-07-04", + pulse_ref: "pulses/p1-v1", + }); } if (opts.vault === "demos") { fs.writeFileSync(path.join(v, "amicode", "DEMOS.md"), "- [d1](demos/d1.md) — demo one\n");