From 83d608afbf6d3252f91aca79d31ec8692177188b Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sat, 11 Jul 2026 01:45:48 -0400 Subject: [PATCH 1/4] =?UTF-8?q?feat(substrate):=20mount=5Fstore=20?= =?UTF-8?q?=E2=80=94=20full=20Armonia=20mount-stack=20discovery=20+=20prec?= =?UTF-8?q?edence=20(spec-20260707-002846=20C1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../extension/src/substrate/mount_store.ts | 239 ++++++++++++++++++ .../test/substrate/mount_store.test.ts | 185 ++++++++++++++ 2 files changed, 424 insertions(+) create mode 100644 packages/extension/src/substrate/mount_store.ts create mode 100644 packages/extension/test/substrate/mount_store.test.ts diff --git a/packages/extension/src/substrate/mount_store.ts b/packages/extension/src/substrate/mount_store.ts new file mode 100644 index 00000000..760d093a --- /dev/null +++ b/packages/extension/src/substrate/mount_store.ts @@ -0,0 +1,239 @@ +/** Armonia mount-stack discovery + precedence (spec-20260707-002846 Component 1 + * — "bootstrap parity", read side). + * + * A TypeScript port of the amico-plugin session-start hook's mount discovery + * (the PARITY ORACLE: ~/harmoniqs/amico-plugin-vault-cli/hooks/session-start, + * branch feat/amico-vault-mounts-toml / PR #27, lines 53–232). Same ranks, same + * skip/rescue semantics, same unlisted-append behavior. + * + * Canonical kind ranks follow the APPROVED vault-CLI spec + * (spec-20260703-053956), NOT the Ombra draft's table: the draft swapped + * team/restricted; we keep restricted(3) < team(4) (spec correction — see the + * parity PR body). Ranks: + * personal 0 · engagement 1 · project 2 · restricted 3 · team 4 · public 5 · other 6 + * Writable-by-default: personal/engagement/project = rw; the rest = ro. + * + * Everything here is read-only and failure-tolerant: a missing vaults root, + * unreadable marker, or unparseable manifest yields the empty/degraded value + * and a warning — never a throw (the session must boot regardless). + * + * TWIN / UNIFY-LATER: a second copy of this resolver lives in + * amico-run (packages/amico-run/src/mounts.ts) with the same API + semantics + * plus an $AMICO_VAULTS_ROOT/$AMICO_MOUNTS_TOML env seam (its verb tests cross a + * child-process boundary; this in-process vitest twin needs no env seam). The + * duplication is deliberate short-term (Ombra spec chose extension-resident + * mount discovery; depth-1 §7.3 wants the CLI to own it long-term). Unify-later + * follow-up: fold both onto the amico-run implementation once the CLI is the + * single retrieval spine. */ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { parse as parseToml } from "smol-toml"; + +export interface Mount { + /** Resolved mount name (marker `name`, else dir basename). */ + name: string; + /** Mount kind (marker `kind`, overridable by a matching manifest entry). */ + kind: string; + /** Absolute path to the vault dir (the dir holding `.amico-vault.toml`). */ + path: string; + /** Writable-by-default posture (rw when true, ro when false). */ + writable: boolean; +} + +export interface MountStack { + /** Mounts in read precedence (top = highest precedence). */ + mounts: Mount[]; + /** Non-fatal skip/degrade notices (mirror the oracle's `⚠ skipped:` lines). */ + warnings: string[]; +} + +export function defaultVaultsRoot(): string { + return path.join(os.homedir(), ".amico", "vaults"); +} + +export function defaultMountsTomlPath(): string { + return path.join(os.homedir(), ".amico", "mounts.toml"); +} + +/** Canonical kind order (vault-CLI spec-20260703-053956). Unknown → 6. */ +function kindRank(kind: string): number { + switch (kind) { + case "personal": + return 0; + case "engagement": + return 1; + case "project": + return 2; + case "restricted": + return 3; + case "team": + return 4; + case "public": + return 5; + default: + return 6; + } +} + +/** Writability default by kind (oracle lines 142–145). */ +function writableByKind(kind: string): boolean { + return kind === "personal" || kind === "project" || kind === "engagement"; +} + +interface ManifestEntry { + id?: string; + kind?: string; + path?: string; + writable?: boolean | string; +} + +/** Parse `~/.amico/mounts.toml`'s `[[mount]]` array. Missing file → []; a parse + * failure is tolerated (→ [] + warning) so a garbled manifest degrades to + * kind-rank ordering rather than bricking discovery. */ +function loadManifest(mountsTomlPath: string, warnings: string[]): ManifestEntry[] { + let text: string; + try { + text = fs.readFileSync(mountsTomlPath, "utf8"); + } catch { + return []; // absent manifest is the common case, not a warning + } + try { + const parsed = parseToml(text) as { mount?: ManifestEntry[] }; + return Array.isArray(parsed.mount) ? parsed.mount : []; + } catch { + warnings.push(`mounts.toml unparseable at ${mountsTomlPath} — falling back to kind-rank ordering`); + return []; + } +} + +/** The manifest match key for a mount entry: its `id`, else its `path` basename + * (oracle ordering match, hook lines 162–166 / 173). */ +function manifestKey(entry: ManifestEntry): string | undefined { + if (typeof entry.id === "string" && entry.id !== "") return entry.id; + if (typeof entry.path === "string" && entry.path !== "") return path.basename(entry.path); + return undefined; +} + +function manifestWritable(entry: ManifestEntry): boolean | undefined { + if (entry.writable === true || entry.writable === "true") return true; + if (entry.writable === false || entry.writable === "false") return false; + return undefined; +} + +/** Discover + order the Armonia mount stack. + * + * Discovery: every dir under `vaultsRoot` with an `.amico-vault.toml` marker. + * `name` defaults to the dir basename. Kind resolution order (oracle lines + * 125–133): the manifest `kind` override applies BEFORE the missing-kind skip — + * a marker with no `kind` but a matching manifest entry is RESCUED with the + * manifest kind; only a mount with no kind from either source is skipped. + * Duplicate resolved name → the later discovery is skipped + warned. + * + * Ordering: with a manifest present, its array order governs (each entry + * matched by id-or-path-basename against a discovered mount); unlisted mounts + * append in DISCOVERY order (NOT kind-rank — oracle lines 181–187). Absent + * manifest → kind-rank then name. */ +export function resolveMountStack( + vaultsRoot: string = defaultVaultsRoot(), + mountsTomlPath: string = defaultMountsTomlPath(), +): MountStack { + const warnings: string[] = []; + + let entries: string[]; + try { + entries = fs.readdirSync(vaultsRoot).sort(); // sorted = deterministic discovery order (matches the glob) + } catch { + return { mounts: [], warnings }; // missing/unreadable root → empty stack, no throw + } + + const manifest = loadManifest(mountsTomlPath, warnings); + // Kind/writable override is keyed on id === name (oracle manifest_field, strict id). + const overrideById = new Map(); + for (const e of manifest) { + if (typeof e.id === "string" && e.id !== "") overrideById.set(e.id, e); + } + + const discovered: Mount[] = []; + const seen = new Set(); + for (const base of entries) { + const dir = path.join(vaultsRoot, base); + const marker = path.join(dir, ".amico-vault.toml"); + let markerText: string; + try { + markerText = fs.readFileSync(marker, "utf8"); + } catch { + warnings.push(`skipped '${base}': no .amico-vault.toml marker`); + continue; + } + let kind = ""; + let name = base; + try { + const m = parseToml(markerText) as { kind?: unknown; name?: unknown }; + if (typeof m.kind === "string") kind = m.kind; + if (typeof m.name === "string" && m.name !== "") name = m.name; + } catch { + warnings.push(`skipped '${base}': .amico-vault.toml is unparseable`); + continue; + } + // Manifest kind override BEFORE the missing-kind skip (oracle rescue rule). + const override = overrideById.get(name); + if (override && typeof override.kind === "string" && override.kind !== "") kind = override.kind; + if (kind === "") { + warnings.push(`skipped '${base}': marker missing 'kind'`); + continue; + } + if (seen.has(name)) { + warnings.push(`skipped '${base}': duplicate id '${name}'`); + continue; + } + seen.add(name); + let writable = writableByKind(kind); + const w = override ? manifestWritable(override) : undefined; + if (w !== undefined) writable = w; + discovered.push({ name, kind, path: dir, writable }); + } + + const ordered = manifest.length > 0 ? orderByManifest(discovered, manifest) : orderByKindRank(discovered); + return { mounts: ordered, warnings }; +} + +/** Manifest array order, then unlisted mounts in discovery order (oracle 157–188). */ +function orderByManifest(discovered: Mount[], manifest: ManifestEntry[]): Mount[] { + const ordered: Mount[] = []; + const emitted = new Set(); + for (const entry of manifest) { + const key = manifestKey(entry); + if (key === undefined) continue; + for (const m of discovered) { + if (emitted.has(m.name)) continue; + if (m.name === key || path.basename(m.path) === key) { + ordered.push(m); + emitted.add(m.name); + } + } + } + for (const m of discovered) { + if (!emitted.has(m.name)) { + ordered.push(m); + emitted.add(m.name); + } + } + return ordered; +} + +/** Kind rank, then name (oracle `sort -k1,1n -k2,2`). */ +function orderByKindRank(discovered: Mount[]): Mount[] { + return [...discovered].sort((a, b) => { + const ra = kindRank(a.kind); + const rb = kindRank(b.kind); + if (ra !== rb) return ra - rb; + return a.name < b.name ? -1 : a.name > b.name ? 1 : 0; + }); +} + +/** The personal mount (first `kind === "personal"` in stack order), or + * undefined. This is what the config funnel maps to the legacy `vaultDir`. */ +export function personalMount(stack: MountStack): Mount | undefined { + return stack.mounts.find((m) => m.kind === "personal"); +} diff --git a/packages/extension/test/substrate/mount_store.test.ts b/packages/extension/test/substrate/mount_store.test.ts new file mode 100644 index 00000000..8bcb2ae1 --- /dev/null +++ b/packages/extension/test/substrate/mount_store.test.ts @@ -0,0 +1,185 @@ +import { describe, it, expect } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { resolveMountStack, personalMount } from "../../src/substrate/mount_store"; + +function mkTmp(prefix: string): string { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} +/** Write a vault dir with an optional `.amico-vault.toml` marker. `kind`/`name` + * are only emitted when given (so we can exercise the missing-kind path); pass + * `noMarker` to omit the marker entirely. Returns the dir path. */ +function mkMount( + root: string, + dirName: string, + opts: { kind?: string; name?: string; noMarker?: boolean } = {}, +): string { + const dir = path.join(root, dirName); + fs.mkdirSync(dir, { recursive: true }); + if (!opts.noMarker) { + let toml = ""; + if (opts.kind !== undefined) toml += `kind = "${opts.kind}"\n`; + if (opts.name !== undefined) toml += `name = "${opts.name}"\n`; + fs.writeFileSync(path.join(dir, ".amico-vault.toml"), toml); + } + return dir; +} +const kinds = (s: { mounts: { kind: string }[] }) => s.mounts.map((m) => m.kind); +const names = (s: { mounts: { name: string }[] }) => s.mounts.map((m) => m.name); + +describe("resolveMountStack — discovery + kind-rank ordering (no manifest)", () => { + it("(a) orders by canonical kind rank, restricted(3) BEFORE team(4)", () => { + const root = mkTmp("vaults-"); + mkMount(root, "a-public", { kind: "public" }); + mkMount(root, "m-team", { kind: "team" }); + mkMount(root, "b-restricted", { kind: "restricted" }); + mkMount(root, "c-project", { kind: "project" }); + mkMount(root, "d-engagement", { kind: "engagement" }); + mkMount(root, "z-personal", { kind: "personal" }); + const stack = resolveMountStack(root, path.join(root, "no-manifest.toml")); + expect(kinds(stack)).toEqual(["personal", "engagement", "project", "restricted", "team", "public"]); + }); + + it("(a′) breaks kind ties by name (ascending)", () => { + const root = mkTmp("vaults-"); + mkMount(root, "c-proj", { kind: "project", name: "c-proj" }); + mkMount(root, "a-proj", { kind: "project", name: "a-proj" }); + const stack = resolveMountStack(root, path.join(root, "no-manifest.toml")); + expect(names(stack)).toEqual(["a-proj", "c-proj"]); + }); + + it("(g) writability defaults by kind (personal/engagement/project rw; rest ro)", () => { + const root = mkTmp("vaults-"); + mkMount(root, "p", { kind: "personal" }); + mkMount(root, "e", { kind: "engagement" }); + mkMount(root, "j", { kind: "project" }); + mkMount(root, "r", { kind: "restricted" }); + mkMount(root, "t", { kind: "team" }); + mkMount(root, "u", { kind: "public" }); + mkMount(root, "x", { kind: "weirdkind" }); + const stack = resolveMountStack(root, path.join(root, "no-manifest.toml")); + const byKind = Object.fromEntries(stack.mounts.map((m) => [m.kind, m.writable])); + expect(byKind.personal).toBe(true); + expect(byKind.engagement).toBe(true); + expect(byKind.project).toBe(true); + expect(byKind.restricted).toBe(false); + expect(byKind.team).toBe(false); + expect(byKind.public).toBe(false); + expect(byKind.weirdkind).toBe(false); // unknown → ro + }); +}); + +describe("resolveMountStack — skips + rescue", () => { + it("(b) a dir with no marker is skipped and warned, not fatal", () => { + const root = mkTmp("vaults-"); + mkMount(root, "not-a-vault", { noMarker: true }); + mkMount(root, "real", { kind: "personal" }); + const stack = resolveMountStack(root, path.join(root, "no-manifest.toml")); + expect(names(stack)).toEqual(["real"]); + expect(stack.warnings.some((w) => w.includes("not-a-vault") && /marker/i.test(w))).toBe(true); + }); + + it("(c) marker missing kind AND no manifest entry → skipped + warned", () => { + const root = mkTmp("vaults-"); + mkMount(root, "no-kind", { name: "no-kind" }); // marker present, no kind + mkMount(root, "ok", { kind: "personal" }); + const stack = resolveMountStack(root, path.join(root, "no-manifest.toml")); + expect(names(stack)).toEqual(["ok"]); + expect(stack.warnings.some((w) => w.includes("no-kind") && /kind/i.test(w))).toBe(true); + }); + + it("(c′) marker missing kind but WITH a manifest entry → rescued with manifest kind", () => { + const root = mkTmp("vaults-"); + mkMount(root, "rescueme", {}); // empty marker: no kind, no name + const manifest = path.join(root, "mounts.toml"); + fs.writeFileSync(manifest, '[[mount]]\nid = "rescueme"\nkind = "project"\n'); + const stack = resolveMountStack(root, manifest); + const m = stack.mounts.find((x) => x.name === "rescueme"); + expect(m).toBeDefined(); + expect(m!.kind).toBe("project"); + expect(m!.writable).toBe(true); // project default rw + }); + + it("(d) duplicate resolved name → second discovery skipped + warned", () => { + const root = mkTmp("vaults-"); + mkMount(root, "aaa", { kind: "personal", name: "dup" }); + mkMount(root, "bbb", { kind: "team", name: "dup" }); // discovered after aaa (sorted) + const stack = resolveMountStack(root, path.join(root, "no-manifest.toml")); + expect(stack.mounts.filter((m) => m.name === "dup").length).toBe(1); + expect(stack.mounts[0].kind).toBe("personal"); // first wins + expect(stack.warnings.some((w) => /duplicate/i.test(w) && w.includes("dup"))).toBe(true); + }); + + it("(e) name defaults to the dir basename when the marker omits it", () => { + const root = mkTmp("vaults-"); + mkMount(root, "my-vault-dir", { kind: "personal" }); + const stack = resolveMountStack(root, path.join(root, "no-manifest.toml")); + expect(names(stack)).toEqual(["my-vault-dir"]); + }); + + it("(h) missing vaults root → empty stack, no throw", () => { + const stack = resolveMountStack("/nonexistent-vaults-root-xyz", "/nonexistent-manifest.toml"); + expect(stack.mounts).toEqual([]); + expect(stack.warnings).toEqual([]); + }); +}); + +describe("resolveMountStack — mounts.toml precedence", () => { + it("(f) manifest array order governs; kind/writable overridden; unlisted appended in discovery order", () => { + const root = mkTmp("vaults-"); + mkMount(root, "alpha", { kind: "personal" }); // discovery order: alpha, beta, gamma + mkMount(root, "beta", { kind: "team" }); + mkMount(root, "gamma", { kind: "project" }); + const manifest = path.join(root, "mounts.toml"); + fs.writeFileSync( + manifest, + [ + "[[mount]]", + 'id = "gamma"', + 'kind = "project"', + "writable = false", // override project's rw default → ro + "", + "[[mount]]", + 'id = "alpha"', + 'kind = "engagement"', // override personal → engagement + "", + ].join("\n"), + ); + const stack = resolveMountStack(root, manifest); + // manifest order (gamma, alpha) then the unlisted beta in discovery order: + expect(names(stack)).toEqual(["gamma", "alpha", "beta"]); + const gamma = stack.mounts[0]; + expect(gamma.writable).toBe(false); // writable override honored + const alpha = stack.mounts[1]; + expect(alpha.kind).toBe("engagement"); // kind override honored + expect(alpha.writable).toBe(true); // engagement default rw (no writable override) + expect(stack.mounts[2].name).toBe("beta"); + }); + + it("matches a manifest entry by path basename when it has no id", () => { + const root = mkTmp("vaults-"); + mkMount(root, "solo", { kind: "team" }); + mkMount(root, "aardvark", { kind: "personal" }); + const manifest = path.join(root, "mounts.toml"); + fs.writeFileSync(manifest, `[[mount]]\npath = "${path.join(root, "solo")}"\n`); + const stack = resolveMountStack(root, manifest); + // solo listed first via path basename; aardvark unlisted, appended after + expect(names(stack)).toEqual(["solo", "aardvark"]); + }); +}); + +describe("personalMount", () => { + it("returns the first kind=personal mount, or undefined when none exist", () => { + const root = mkTmp("vaults-"); + mkMount(root, "team-one", { kind: "team" }); + const personalDir = mkMount(root, "me", { kind: "personal" }); + const stack = resolveMountStack(root, path.join(root, "no-manifest.toml")); + expect(personalMount(stack)?.path).toBe(personalDir); + + const root2 = mkTmp("vaults-"); + mkMount(root2, "team-only", { kind: "team" }); + const stack2 = resolveMountStack(root2, path.join(root2, "no-manifest.toml")); + expect(personalMount(stack2)).toBeUndefined(); + }); +}); From c0e015416681be568c767188fe24eb8e57988235 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sat, 11 Jul 2026 01:56:42 -0400 Subject: [PATCH 2/4] feat(substrate): mount-stack + memory-index splice sections (spec-20260707-002846 C3/C4 read side) Co-Authored-By: Claude Fable 5 --- .../extension/src/substrate/user_splice.ts | 52 ++++++++++++++- .../extension/src/substrate/vault_store.ts | 9 +++ .../test/substrate/user_splice.test.ts | 64 +++++++++++++++++++ .../test/substrate/vault_store.test.ts | 20 ++++++ 4 files changed, 144 insertions(+), 1 deletion(-) diff --git a/packages/extension/src/substrate/user_splice.ts b/packages/extension/src/substrate/user_splice.ts index 069a3a46..a38484ca 100644 --- a/packages/extension/src/substrate/user_splice.ts +++ b/packages/extension/src/substrate/user_splice.ts @@ -1,7 +1,11 @@ /** The personalized splice (spec-20260705-002847 §6): two lean sections built * from the vault's user-memory files. Both are ≤~3 KB by construction (profile * capped at ~30 lines by convention, knowledge lines capped at 50 by the - * reader); the agent reads full cards on demand from the granted vault path. */ + * reader); the agent reads full cards on demand from the granted vault path. + * + * The mount-stack + memory-index sections (spec-20260707-002846 C3/C4 read + * side) live here too — same "build a lean section, splice on demand" shape. */ +import type { MountStack } from "./mount_store"; export function buildAboutUserSection(profileMd: string): string { if (!profileMd) return ""; @@ -29,6 +33,52 @@ export function buildReferenceDemosSection(demoLines: string[]): string { ].join("\n"); } +/** The Armonia mount stack, top→bottom in read precedence, plus a condensed + * static block mirroring the amico-vault skill's "Mounts & resolution" (so the + * agent knows how reads union and how writes route without loading the skill). + * Empty stack → "" (no mounts discovered ⇒ nothing to say). Parity oracle: the + * session-start hook's rendered "Mount stack" block. */ +export function buildMountStackSection(stack: MountStack): string { + if (stack.mounts.length === 0) return ""; + const mountLines = stack.mounts.map( + (m) => `- ${m.name} · kind=${m.kind} · ${m.writable ? "rw" : "ro"} · ${m.path}`, + ); + const warnLines = stack.warnings.map((w) => `- ⚠ ${w}`); + return [ + "## Mount stack (Armonia — read precedence top→bottom)", + "", + ...mountLines, + ...warnLines, + "", + "Resolution & write-routing (condensed from the amico-vault skill):", + "- Reads union across all mounts; on the same relative path the first hit", + " top→bottom wins (higher-precedence mount shadows lower).", + "- Writes route by intent to the first WRITABLE mount of that kind:", + " personal→personal, engagement→engagement, project→project,", + " restricted/team/public→their own kind.", + "- If the target mount is absent or read-only, write to the personal mount", + " and stamp `route_intent: ` in the note frontmatter — never silently", + " drop a write, never write a ro mount.", + "- Ambiguous intent: ask once, else default to personal.", + ].join("\n"); +} + +/** The typed-memory index (spec-20260707-002846 C4 read side): the one-line + * pointers from `amicode/memory/MEMORY.md`. Only the index is spliced; the full + * typed cards load on demand from the granted vault path. No lines → "". */ +export function buildMemoryIndexSection(memoryIndexLines: string[]): string { + if (memoryIndexLines.length === 0) return ""; + return [ + "## Memory index", + "", + ...memoryIndexLines, + "", + "These are one-line pointers. The full typed-memory cards (user / feedback /", + "project / reference) load on demand from the granted vault path under", + "`amicode/memory/` — read a card only when its hook is relevant to the turn.", + ].join("\n"); +} + export function buildRecentProblemsSection(knowledgeLines: string[]): string { if (knowledgeLines.length === 0) return ""; return [ diff --git a/packages/extension/src/substrate/vault_store.ts b/packages/extension/src/substrate/vault_store.ts index 429b236e..a9a02ef2 100644 --- a/packages/extension/src/substrate/vault_store.ts +++ b/packages/extension/src/substrate/vault_store.ts @@ -9,6 +9,7 @@ import * as os from "node:os"; import * as path from "node:path"; export const KNOWLEDGE_LINE_CAP = 50; +export const MEMORY_INDEX_LINE_CAP = 50; export function defaultVaultsRoot(): string { return path.join(os.homedir(), ".amico", "vaults"); @@ -79,6 +80,14 @@ export function readDemoLines(vaultDir: string, cap = 30): string[] { return readIndexLines(vaultDir, "DEMOS.md", cap); } +/** Typed-memory index list lines (spec-20260707-002846 C4). The distiller writes + * durable facts as typed cards under `/amicode/memory/` and maintains a + * one-line index at `memory/MEMORY.md`; only that index is spliced (the cards + * load on demand). Subdir-capable reuse of the readIndexLines pattern. */ +export function readMemoryIndexLines(vaultDir: string, cap: number = MEMORY_INDEX_LINE_CAP): string[] { + return readIndexLines(vaultDir, path.join("memory", "MEMORY.md"), cap); +} + /** Second disjunct of the routing predicate (§3): completed marker in the * onboarding stream. Malformed lines are skipped. */ export function hasOnboardingCompleted(onboardingStreamDir: string): boolean { diff --git a/packages/extension/test/substrate/user_splice.test.ts b/packages/extension/test/substrate/user_splice.test.ts index 9d237f94..705695c1 100644 --- a/packages/extension/test/substrate/user_splice.test.ts +++ b/packages/extension/test/substrate/user_splice.test.ts @@ -6,7 +6,10 @@ import { buildAboutUserSection, buildRecentProblemsSection, buildReferenceDemosSection, + buildMountStackSection, + buildMemoryIndexSection, } from "../../src/substrate/user_splice"; +import type { MountStack } from "../../src/substrate/mount_store"; import { buildOpencodeConfigContent, prepareOpencodeProject } from "../../src/opencode_config"; describe("buildAboutUserSection (spec §6)", () => { @@ -84,6 +87,67 @@ describe("vault wiring (grant + splice + return)", () => { }); }); +describe("buildMountStackSection (spec §3 C3 read side)", () => { + const stack = (mounts: MountStack["mounts"], warnings: string[] = []): MountStack => ({ mounts, warnings }); + + it("empty stack → empty string (no section)", () => { + expect(buildMountStackSection(stack([]))).toBe(""); + // warnings alone (nothing discovered) still render nothing — parity with the + // "Empty stack → ''" contract. + expect(buildMountStackSection(stack([], ["skipped 'x': no marker"]))).toBe(""); + }); + + it("renders the header + one precedence line per mount with rw/ro + path", () => { + const s = buildMountStackSection( + stack([ + { name: "armonia-aaron", kind: "personal", path: "/v/armonia-aaron", writable: true }, + { name: "armonissima", kind: "team", path: "/v/armonissima", writable: false }, + ]), + ); + expect(s).toContain("## Mount stack (Armonia — read precedence top→bottom)"); + expect(s).toContain("- armonia-aaron · kind=personal · rw · /v/armonia-aaron"); + expect(s).toContain("- armonissima · kind=team · ro · /v/armonissima"); + // top→bottom = read precedence: the personal line precedes the team line. + expect(s.indexOf("armonia-aaron")).toBeLessThan(s.indexOf("armonissima")); + }); + + it("renders warning lines beneath the mounts", () => { + const s = buildMountStackSection( + stack( + [{ name: "p", kind: "personal", path: "/v/p", writable: true }], + ["skipped 'junk': marker missing 'kind'"], + ), + ); + expect(s).toContain("skipped 'junk': marker missing 'kind'"); + }); + + it("appends the condensed routing-rules block (union/first-hit, intent routing, route_intent, ask-once)", () => { + const s = buildMountStackSection(stack([{ name: "p", kind: "personal", path: "/v/p", writable: true }])); + expect(s).toMatch(/union/i); // union reads across mounts + expect(s).toMatch(/first hit/i); // first-hit precedence + expect(s).toMatch(/route_intent/); // the fallback stamp + expect(s).toMatch(/writable/i); // routes to first WRITABLE mount of that kind + expect(s).toMatch(/ask once/i); // ambiguous → ask once + expect(s).toMatch(/default(?:s)?(?: to)? personal/i); // else default (to) personal + }); +}); + +describe("buildMemoryIndexSection (spec §3 C4 read side)", () => { + it("no lines → empty string", () => { + expect(buildMemoryIndexSection([])).toBe(""); + }); + it("renders the heading + index lines + a load-on-demand instruction", () => { + const s = buildMemoryIndexSection([ + "- [user-role](user_role.md) — Aaron is CEO of Harmoniqs", + "- [feedback-latex](feedback_latex.md) — use LaTeX in chat", + ]); + expect(s).toContain("## Memory index"); + expect(s).toContain("- [user-role](user_role.md) — Aaron is CEO of Harmoniqs"); + expect(s).toContain("- [feedback-latex](feedback_latex.md) — use LaTeX in chat"); + expect(s).toMatch(/load on demand from the granted vault path/i); + }); +}); + describe("buildReferenceDemosSection (L1 §3)", () => { it("empty → ''", () => { expect(buildReferenceDemosSection([])).toBe(""); diff --git a/packages/extension/test/substrate/vault_store.test.ts b/packages/extension/test/substrate/vault_store.test.ts index 91a8f1a8..a1f0a062 100644 --- a/packages/extension/test/substrate/vault_store.test.ts +++ b/packages/extension/test/substrate/vault_store.test.ts @@ -6,6 +6,7 @@ import { resolvePersonalVault, readProfileMd, readKnowledgeLines, + readMemoryIndexLines, hasOnboardingCompleted, } from "../../src/substrate/vault_store"; @@ -83,6 +84,25 @@ describe("readKnowledgeLines (spec §2.3: list lines, cap 50)", () => { }); }); +describe("readMemoryIndexLines (spec §3 C4: memory/MEMORY.md list lines, cap 50)", () => { + it("missing memory index → []", () => { + expect(readMemoryIndexLines(mkTmp("vault-"))).toEqual([]); + }); + it("reads list lines from the amicode/memory subdir, capped", () => { + const v = mkTmp("vault-"); + fs.mkdirSync(path.join(v, "amicode", "memory"), { recursive: true }); + const items = Array.from({ length: 60 }, (_, i) => `- [m${i}](m${i}.md) — fact ${i}`); + fs.writeFileSync( + path.join(v, "amicode", "memory", "MEMORY.md"), + "# Memory index\n" + items.join("\n") + "\nprose ignored\n", + ); + const lines = readMemoryIndexLines(v); + expect(lines.length).toBe(50); + expect(lines[0]).toContain("m0"); + expect(lines.every((l) => l.startsWith("- "))).toBe(true); + }); +}); + describe("hasOnboardingCompleted (spec §3 routing predicate, second disjunct)", () => { it("missing stream → false", () => { expect(hasOnboardingCompleted(path.join(mkTmp("ops-"), "onboarding"))).toBe(false); From b7270e7524aff04f66a79d37c3fd9bfa73ce89d4 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sat, 11 Jul 2026 02:13:51 -0400 Subject: [PATCH 3/4] =?UTF-8?q?feat(config):=20per-mount=20grants=20+=20mo?= =?UTF-8?q?unt-stack/memory=20splice=20at=20session=20prep=20(spec-2026070?= =?UTF-8?q?7-002846=20C1=E2=80=93C4=20read=20side;=20C2's=20compaction=20k?= =?UTF-8?q?nobs=20deliberately=20deferred)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- packages/extension/src/extension.ts | 5 ++ packages/extension/src/opencode_config.ts | 78 ++++++++++++++++--- .../extension/test/opencode_config.test.ts | 21 +++++ .../test/scores/prep_integration.test.ts | 63 +++++++++++++++ .../extension/test/slow/interview_e2e.test.ts | 1 + .../extension/test/slow/scores_e2e.test.ts | 1 + 6 files changed, 158 insertions(+), 11 deletions(-) diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 41fbf2be..67e6d1ff 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -160,6 +160,9 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { opencodeChannel.appendLine(`[boot] opencode project dir: ${opencodeProject.projectDir}`); opencodeChannel.appendLine(`[boot] AGENTS.md: ${opencodeProject.agentsPath}`); opencodeChannel.appendLine(`[boot] template: ${opencodeProject.templatePath}`); + opencodeChannel.appendLine( + `[boot] armonia mounts: ${opencodeProject.mounts.length} (${opencodeProject.mounts.map((m) => m.name).join(", ")})`, + ); // 4. Spawn opencode — the VENDORED binary by default (spec §4; S35, kills // Assumption 4). Config override is a dev-only escape hatch. On a missing @@ -221,6 +224,8 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { opencodeProject.skillPaths, opencodeProject.skillsStageDir, opencodeProject.vaultDir, + // Armonia mount stack (spec-20260707-002846 C1): per-mount read grants. + opencodeProject.mounts, // Model pin (fallback-only, resolveModelPin): without it, default // resolution gambles on provider ordering — with Google creds it // picked a preview model that hung every headless/agent turn. diff --git a/packages/extension/src/opencode_config.ts b/packages/extension/src/opencode_config.ts index a1806a68..58333f75 100644 --- a/packages/extension/src/opencode_config.ts +++ b/packages/extension/src/opencode_config.ts @@ -14,15 +14,21 @@ import { type SkillIndexEntry, } from "./scores/package_skills"; import { - resolvePersonalVault, - defaultVaultsRoot, readProfileMd, readKnowledgeLines, readDemoLines, + readMemoryIndexLines, hasOnboardingCompleted, onboardingDir, } from "./substrate/vault_store"; -import { buildAboutUserSection, buildRecentProblemsSection, buildReferenceDemosSection } from "./substrate/user_splice"; +import { resolveMountStack, personalMount, type Mount, type MountStack } from "./substrate/mount_store"; +import { + buildAboutUserSection, + buildRecentProblemsSection, + buildReferenceDemosSection, + buildMountStackSection, + buildMemoryIndexSection, +} from "./substrate/user_splice"; // ============================================================================ // Prepare a per-session opencode project directory. @@ -291,6 +297,7 @@ export function buildOpencodeConfigContent( skillPaths: string[] = [], skillsStageDir: string = "", vaultDir: string = "", + mounts: Mount[] = [], modelPin?: string, ): string { const templatesDir = path.dirname(templatePath); @@ -333,6 +340,12 @@ export function buildOpencodeConfigContent( [`${problemsRoot()}/**`]: "allow", // amicode_* problem workspaces the agent reads back [`${scoresRoot}/**`]: "allow", // score templates + memory hooks ([Why?]) the agent reads ...skillGrants, // per-indexed-skill dirs (spec §3, least-privilege) + // Armonia mount stack (spec-20260707-002846 C1): a READ grant per mount + // so the agent can read cards/notes on demand across the WHOLE stack. + // The permission surface has no read/write split, so even a read-only + // mount gets a grant here (read posture); write discipline stays + // distiller-side (its own config), same contract as the vault grant below. + ...Object.fromEntries(mounts.map((m) => [`${m.path}/**`, "allow"])), // User-memory substrate (spec-20260705-002847 §6): the interview reads // problem/environment cards on demand. Read-only BY CONTRACT — vault // writes are distiller-only (its own config); the permission surface @@ -362,9 +375,12 @@ export interface OpencodeConfigOptions { platformSkills?: string[]; /** Roots for the central platform-skill library (spec §3). Default: DEFAULT_LIBRARY_ROOTS. */ skillLibraryRoots?: string[]; - /** Personal vault dir for the user-memory substrate (spec-20260705-002847). - * undefined → auto-resolve (kind=personal marker scan under ~/.amico/vaults); - * "" → personalization disabled; a path → used as-is. */ + /** Personal vault dir for the user-memory substrate (spec-20260705-002847), + * three-state (spec-20260707-002846 C1): + * undefined → auto-resolve the full Armonia mount stack under + * ~/.amico/vaults; vaultDir = the personal mount ("" if none); + * "" → personalization disabled (empty stack, no grants, no splice); + * a path → a single forced personal mount at that path (dev escape hatch). */ vaultDir?: string; } @@ -379,8 +395,13 @@ export interface OpencodeProject { * buildOpencodeConfigContent as `skills.paths`. "" if none staged. */ skillsStageDir: string; /** Resolved personal vault ("" when personalization is off) — thread into - * buildOpencodeConfigContent for the read grant. */ + * buildOpencodeConfigContent for the read grant. Equals `personalMount(mounts)` + * path (unchanged behavior for the distiller + onboarding consumers). */ vaultDir: string; + /** The resolved Armonia mount stack (spec-20260707-002846 C1) — thread into + * buildOpencodeConfigContent for the per-mount read grants. [] when + * personalization is disabled ("" vaultDir). */ + mounts: Mount[]; } export function prepareOpencodeProject(opts: OpencodeConfigOptions): OpencodeProject { @@ -402,10 +423,27 @@ export function prepareOpencodeProject(opts: OpencodeConfigOptions): OpencodePro // transport for the Bun-side plugin. FALLBACK: any failure leaves the substituted // AGENTS.md exactly as before — the hardcoded section IS the fallback content; // score trouble must never brick the boot. - // User-memory substrate (spec-20260705-002847): resolve the personal vault - // ONCE, up front — the routing predicate (§3) and the splice (§6) both need - // it. undefined → auto-resolve (kind=personal marker scan); "" → off. - const vaultDir = opts.vaultDir !== undefined ? opts.vaultDir : resolvePersonalVault(defaultVaultsRoot(), ""); + // Armonia mount stack (spec-20260707-002846 C1) + user-memory substrate + // (spec-20260705-002847): resolve ONCE, up front — the routing predicate (§3), + // the per-mount read grants, and the splice (§6, C3/C4) all need it. The + // three-state opts.vaultDir contract is preserved EXACTLY: + // undefined → auto-resolve the FULL stack (personal mount → vaultDir); + // "" → personalization OFF (empty stack, no grants, no splice); + // a path → a single forced personal mount at that path (dev escape hatch). + let stack: MountStack; + if (opts.vaultDir === undefined) { + stack = resolveMountStack(); + } else if (opts.vaultDir === "") { + stack = { mounts: [], warnings: [] }; + } else { + stack = { + mounts: [{ name: path.basename(opts.vaultDir), kind: "personal", path: opts.vaultDir, writable: true }], + warnings: [], + }; + } + // vaultDir === the personal mount path (unchanged behavior for the distiller + + // onboarding predicate consumers); "" when there is no personal mount. + const vaultDir = personalMount(stack)?.path ?? ""; let finalContent = filled; try { @@ -517,6 +555,23 @@ export function prepareOpencodeProject(opts: OpencodeConfigOptions): OpencodePro } } + // Mount-stack + memory-index splice (spec-20260707-002846 C3/C4 read side): + // its OWN try/catch — mount-parity trouble must never brick the boot. The + // mount-stack section renders whenever the stack has mounts (mounts can exist + // without a personal vault — e.g. a team-only stack); the typed-memory index + // is read from the personal mount, so it is gated on vaultDir. Empty stack + // ("" vaultDir) → both builders return "" → nothing is spliced. + try { + const mountSection = buildMountStackSection(stack); + if (mountSection) finalContent = finalContent + "\n\n" + mountSection; + if (vaultDir) { + const memorySection = buildMemoryIndexSection(readMemoryIndexLines(vaultDir)); + if (memorySection) finalContent = finalContent + "\n\n" + memorySection; + } + } catch (e) { + console.warn(`amicode: mount-stack/memory-index splice failed (session continues): ${e}`); + } + fs.writeFileSync(agentsPath, finalContent, "utf8"); // The agent reads the template from its bundled absolute path (the session @@ -528,5 +583,6 @@ export function prepareOpencodeProject(opts: OpencodeConfigOptions): OpencodePro skillPaths: skillEntries.map((e) => e.path), skillsStageDir, vaultDir, + mounts: stack.mounts, }; } diff --git a/packages/extension/test/opencode_config.test.ts b/packages/extension/test/opencode_config.test.ts index 19cb9da5..0b8acf5c 100644 --- a/packages/extension/test/opencode_config.test.ts +++ b/packages/extension/test/opencode_config.test.ts @@ -112,6 +112,27 @@ describe("buildOpencodeConfigContent", () => { else process.env.AMICODE_PROBLEMS_DIR = prev; } }); + it("grants external_directory /** for EVERY Armonia mount, retaining the personal-vault amicode grant", () => { + const mounts = [ + { name: "me", kind: "personal", path: "/v/me", writable: true }, + { name: "team", kind: "team", path: "/v/team", writable: false }, + ]; + const cfg = JSON.parse( + buildOpencodeConfigContent("/abs/AGENTS.md", TPL, "/home/u/.amico/runs/default", undefined, undefined, [], "", "/v/me", mounts), + ); + const ed = cfg.permission.external_directory; + expect(ed["/v/me/**"]).toBe("allow"); // personal mount read grant + // a read-only mount STILL gets a read grant — the permission surface has no + // r/w split; write discipline stays distiller-side (documented posture). + expect(ed["/v/team/**"]).toBe("allow"); + expect(ed["/v/me/amicode/**"]).toBe("allow"); // existing personal-vault grant retained + }); + it("no mounts → no per-mount grants (only the personal amicode grant when a vaultDir is given)", () => { + const cfg = JSON.parse(buildOpencodeConfigContent("/abs/AGENTS.md", TPL, "/home/u/.amico/runs/default", undefined, undefined, [], "", "/v/me")); + const ed = cfg.permission.external_directory; + expect(ed["/v/me/**"]).toBeUndefined(); // no mount list → no whole-mount grant + expect(ed["/v/me/amicode/**"]).toBe("allow"); + }); it("never embeds a credential in the config content (D11 no-store/no-inject regression guard)", () => { // amico owns no secret: the config it writes into OPENCODE_CONFIG_CONTENT must // never carry a provider key, even when one is present in the environment. diff --git a/packages/extension/test/scores/prep_integration.test.ts b/packages/extension/test/scores/prep_integration.test.ts index 31554351..f438f31c 100644 --- a/packages/extension/test/scores/prep_integration.test.ts +++ b/packages/extension/test/scores/prep_integration.test.ts @@ -168,6 +168,69 @@ describe("buildOpencodeConfigContent × scores", () => { }); }); +describe("prepareOpencodeProject × Armonia mount stack (spec-20260707-002846 C1–C4, three-state vaultDir)", () => { + it('vaultDir "" → personalization disabled: empty mount stack, no mount/memory splice (regression guard)', () => { + const proj = prep({ vaultDir: "" }); + expect(proj.mounts).toEqual([]); + expect(proj.vaultDir).toBe(""); + const agents = fs.readFileSync(proj.agentsPath, "utf8"); + expect(agents).not.toContain("## Mount stack (Armonia"); + expect(agents).not.toContain("## Memory index"); + }); + + it("vaultDir path → single forced personal mount at that path; returns mounts + splices the mount stack", () => { + const vault = fs.mkdtempSync(path.join(os.tmpdir(), "forced-vault-")); + fs.mkdirSync(path.join(vault, "amicode", "memory"), { recursive: true }); + fs.writeFileSync( + path.join(vault, "amicode", "memory", "MEMORY.md"), + "# Memory index\n- [user-role](user_role.md) — Aaron is CEO\n", + ); + const proj = prep({ vaultDir: vault }); + expect(proj.mounts).toHaveLength(1); + expect(proj.mounts[0]).toMatchObject({ kind: "personal", path: vault, writable: true }); + expect(proj.vaultDir).toBe(vault); // vaultDir === personalMount path + const agents = fs.readFileSync(proj.agentsPath, "utf8"); + expect(agents).toContain("## Mount stack (Armonia — read precedence top→bottom)"); + expect(agents).toContain(`kind=personal · rw · ${vault}`); + // memory index reads from the personal mount: + expect(agents).toContain("## Memory index"); + expect(agents).toContain("- [user-role](user_role.md) — Aaron is CEO"); + }); + + it("vaultDir undefined → auto-resolves the full stack from ~/.amico/vaults; vaultDir === personal mount", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "home-")); + const vaults = path.join(home, ".amico", "vaults"); + const personal = path.join(vaults, "armonia-me"); + fs.mkdirSync(path.join(personal, "amicode"), { recursive: true }); + fs.writeFileSync(path.join(personal, ".amico-vault.toml"), 'kind = "personal"\nname = "armonia-me"\n'); + // a PROFILE.md suppresses the overture routing predicate (keeps this a plain + // pulse-designer compile — onboarding routing has its own suite). + fs.writeFileSync(path.join(personal, "amicode", "PROFILE.md"), "# Profile — A\n- Role: CEO\n"); + const team = path.join(vaults, "armonissima"); + fs.mkdirSync(team, { recursive: true }); + fs.writeFileSync(path.join(team, ".amico-vault.toml"), 'kind = "team"\nname = "armonissima"\n'); + + const prevHome = process.env.HOME; + const prevProfile = process.env.AMICO_PROFILE_FILE; + process.env.HOME = home; + process.env.AMICO_PROFILE_FILE = path.join(home, "no-profile.json"); // wizard gate off + try { + const proj = prep({ vaultDir: undefined }); + expect(proj.mounts.map((m) => m.name)).toEqual(["armonia-me", "armonissima"]); // kind-rank: personal(0) < team(4) + expect(proj.vaultDir).toBe(personal); + const agents = fs.readFileSync(proj.agentsPath, "utf8"); + expect(agents).toContain("## Mount stack (Armonia — read precedence top→bottom)"); + expect(agents).toContain(`- armonia-me · kind=personal · rw · ${personal}`); + expect(agents).toContain(`- armonissima · kind=team · ro · ${team}`); + } finally { + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + if (prevProfile === undefined) delete process.env.AMICO_PROFILE_FILE; + else process.env.AMICO_PROFILE_FILE = prevProfile; + } + }); +}); + describe("prepareOpencodeProject × skill index (spec §3, Rev 2 — dual-source)", () => { // NOTE: presence/absence is asserted on the STRUCTURED authoring.json skills // array (source/name/package), not raw prompt text — the author-first prose diff --git a/packages/extension/test/slow/interview_e2e.test.ts b/packages/extension/test/slow/interview_e2e.test.ts index 01ada6a2..9ce19e0c 100644 --- a/packages/extension/test/slow/interview_e2e.test.ts +++ b/packages/extension/test/slow/interview_e2e.test.ts @@ -52,6 +52,7 @@ function layer0Config(agentsPath: string): string { [], "", "", + [], // mounts (spec-20260707-002846 C1) — none in this e2e // production model pin (fallback-only) — without it the live turns ride // opencode's default resolution, which picks a hanging preview model here resolveModelPin(), diff --git a/packages/extension/test/slow/scores_e2e.test.ts b/packages/extension/test/slow/scores_e2e.test.ts index c94686fe..d76c6bdf 100644 --- a/packages/extension/test/slow/scores_e2e.test.ts +++ b/packages/extension/test/slow/scores_e2e.test.ts @@ -71,6 +71,7 @@ async function serveWithScores(port: number) { [], "", "", + [], // mounts (spec-20260707-002846 C1) — none in this e2e // production model pin (fallback-only) — without it the live turns ride // opencode's default resolution, which picks a hanging preview model here resolveModelPin(), From 05cc83017f7b6b6f7d119e32bb28d994073c1626 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sat, 11 Jul 2026 02:19:17 -0400 Subject: [PATCH 4/4] feat(distiller): typed memory store writes + MEMORY.md index (spec-20260707-002846 C4) Co-Authored-By: Claude Fable 5 --- packages/extension/DISTILLER.md | 84 +++++++++++++++++++++++++++++++-- 1 file changed, 80 insertions(+), 4 deletions(-) diff --git a/packages/extension/DISTILLER.md b/packages/extension/DISTILLER.md index dd80de46..88aff9a0 100644 --- a/packages/extension/DISTILLER.md +++ b/packages/extension/DISTILLER.md @@ -170,13 +170,27 @@ run_id = "r20260703-095831Z-e5b7" New `-v` ONLY when fidelity strictly improves on the card's `best_fidelity` or the formulation materially changed. Otherwise no new binary. -### `KNOWLEDGE.md` line (insert newest-first; update in place on card update; cap 50 lines) +### `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 +`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: ```markdown - [x-gate-transmon](problems/x-gate-transmon.md) — transmon gate X, solved 8×, best F=0.99995, pulse: x-gate-transmon-v1 -- [cat-state-transmon-cavity](problems/cat-state-transmon-cavity.md) — cavity-transmon - state_prep, ATTEMPTED (launch failed: no solvespec), no pulse yet +``` + +On each run, make `KNOWLEDGE.md` carry a single migration pointer to the new +store — **append the line below ONLY if that exact line is not already present** +(this prompt runs on every distill, so the presence check is what keeps it +idempotent → zero diff on re-run; never append a second copy): + +```markdown +> Superseded — durable memory now lives in `memory/` (index: `memory/MEMORY.md`). ``` ### Onboarding materialization (only per Hard rule 4) @@ -264,7 +278,10 @@ script: sys_params: { fock_cutoff: 20, chi: 0.0000328, alpha: 2 } # if readable ``` -DEMOS.md line: +DEMOS.md line — **FROZEN** (read-only; superseded by the typed memory store). +Keep writing the demo *card* to `demos/.md` as before; the distiller no +longer appends to `DEMOS.md` (the bootstrap still reads its existing lines as +"Reference demos"). Historical line shape, for reading only: ``` - [stanford-bosonics-cat](demos/stanford-bosonics-cat.md) — cavity state_prep cat-state, N_fock=20, script scripts/optimize_cat_alpha2.jl @@ -274,6 +291,65 @@ Match/idempotency: a `demo` job for a `demo_dir` already carded (same slug) with no change is a no-op. Demo cards use the same 3-tuple identity as problem cards but never merge with the user's own solves (source distinguishes them). +## Typed memory store (durable facts) — `/amicode/memory/` (spec-20260707-002846 C4) + +Beyond problem/demo cards (which capture *solves*), record durable **facts** +about the user and the work in a typed memory store — the write side of the +"Memory index" the session bootstrap splices. This SUPERSEDES the flat +`KNOWLEDGE.md`/`DEMOS.md` indices (now frozen). Keep it lean and +non-duplicative: a fact worth remembering across future sessions, not the state +of the current one. + +Four types — pick the best fit: + +- **user** — the user's role, goals, preferences, environment (who they are, how + they like to work). File: `memory/user_.md`. +- **feedback** — a correction the user gave OR an approach they confirmed ("do + X", "never Y", "yes, that was right"). Lead with the rule, then a **Why:** line + (the reason/incident) and a **How to apply:** line. File: `memory/feedback_.md`. +- **project** — an ongoing initiative, decision, deadline, or incident not + derivable from the artifacts or git history. File: `memory/project_.md`. +- **reference** — a pointer to where information lives outside the vault (a repo, + dashboard, or channel) and what it is for. File: `memory/reference_.md`. + +Each card's frontmatter (then the body): + +```markdown +--- +name: +description: +type: user | feedback | project | reference +--- + + +``` + +Maintain `memory/MEMORY.md` as the one-line index (this is the exact file the +session bootstrap reads and splices). One entry per card, newest-first, ≤~150 +chars each, cap ~50 lines: + +```markdown +- [user-role](user_role.md) — Aaron is CEO of Harmoniqs; frame for a senior IC +- [feedback-latex](feedback_latex.md) — use LaTeX math in chat, not just docs +``` + +Rules for the typed store (same discipline as problem cards): + +- **Match before create.** Read `memory/MEMORY.md` and the frontmatter of every + file in `memory/` first. If a card already covers the fact, UPDATE it in place + (never write a second card, never a duplicate index line). Remove a card only + when the fact is explicitly retracted. +- **Idempotent.** Re-running any job must produce zero diff once a fact is + recorded — the MEMORY.md line is updated in place, appended only when new. +- **No secrets** (Hard rule 5) and **no fabrication** — record only facts the + job's artifacts/transcript actually establish. +- **Identity gate.** Only an `onboarding` job may write **user**-type identity + facts that mirror `PROFILE.md` (parity with Hard rule 4). `run`/`sweep`/ + `batch`/`demo` jobs may record **feedback**/**project**/**reference** facts + they legitimately observe, but never touch `PROFILE.md` or user-identity cards. +- Writes stay under `/amicode/` → the pathspec-scoped commit (Hard rule 1) + and the existing grant already cover them; **no new permission is needed**. + ## Finishing a job 1. Write the files. 2. Pathspec-scoped commit (Hard rule 1). 3. Final message: