From 94e555de9507b5bde0fd03fd93a4d8bc67c75d38 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sat, 11 Jul 2026 01:40:49 -0400 Subject: [PATCH 1/3] =?UTF-8?q?feat(mounts):=20Armonia=20mount-stack=20res?= =?UTF-8?q?olver=20=E2=80=94=20markers=20+=20mounts.toml=20precedence=20(v?= =?UTF-8?q?ault-cli=20spec=20canonical=20order)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- packages/amico-run/src/mounts.ts | 262 +++++++++++++++++++++++++ packages/amico-run/test/mounts.test.ts | 246 +++++++++++++++++++++++ 2 files changed, 508 insertions(+) create mode 100644 packages/amico-run/src/mounts.ts create mode 100644 packages/amico-run/test/mounts.test.ts diff --git a/packages/amico-run/src/mounts.ts b/packages/amico-run/src/mounts.ts new file mode 100644 index 00000000..2459c01d --- /dev/null +++ b/packages/amico-run/src/mounts.ts @@ -0,0 +1,262 @@ +// The Armonia MOUNT-STACK resolver — the pure core that discovers the vaults +// mounted under `~/.amico/vaults/*`, applies the `~/.amico/mounts.toml` manifest +// (kind/writable override + ordering), and returns a precedence-ordered stack. +// Backs the mount-aware `amico vault` verbs (status/resolve/query) and the routed +// `note route` writer (plan Task 6; spec-20260703-053956 §"mounts.toml"). +// +// TWIN: this is a deliberate short-term duplicate of the extension's +// `packages/extension/src/substrate/mount_store.ts` (same API, same semantics). +// The Ombra spec (spec-20260707-002846 C1) put the resolver extension-resident; +// the depth-1 redesign (spec-20260708-112732 §7.3) wants the CLI to own it +// long-term. UNIFY-LATER follow-up: collapse the two into one shared module once +// the extension can depend on amico-run. Keep the two byte-for-byte behaviorally +// identical until then — the ONLY intended delta is this file's env seam (below). +// +// PARITY ORACLE: the amico-plugin session-start hook +// (~/harmoniqs/amico-plugin-vault-cli/hooks/session-start, branch +// feat/amico-vault-mounts-toml, PR #27). Same ranks, same skip/rescue rules, same +// unlisted-append behavior. The canonical kind order follows the APPROVED +// vault-CLI spec (spec-20260703-053956), NOT the Ombra draft table — the Ombra +// draft swapped team/restricted; here restricted=3 < team=4 (spec correction). +// +// kind rank writable(default) +// personal 0 rw +// engagement 1 rw +// project 2 rw +// restricted 3 ro +// team 4 ro +// public 5 ro +// other 6 ro +// +// ENV SEAM (amico-run only; the extension twin needs none — it runs in-process +// vitest where function params suffice). b3's verb tests execute the esbuild +// bundle via `execFileSync`, so params can't reach fixtures — the defaults read +// `$AMICO_VAULTS_ROOT` / `$AMICO_MOUNTS_TOML` before `~/.amico/...`. Explicit +// params still win. `$AMICO_VAULT_DIR` keeps its existing meaning (force a single +// unnamed personal mount — back-compat with vault_query.ts's vaultDir()) and WINS +// over `$AMICO_VAULTS_ROOT`/`$AMICO_MOUNTS_TOML` when both are set; an explicit +// `vaultsRoot` param still overrides even that. +// +// House style (mirrors repertoire.ts): never-throwing loaders (a missing/corrupt +// vault or manifest degrades to a warning, never a throw) + pure functions. +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { basename, join } from "node:path"; +import { parse as parseToml } from "smol-toml"; + +/** One resolved Armonia vault mount. `writable` is the effective posture after the + * kind default + any manifest override. */ +export interface Mount { + name: string; + kind: string; + path: string; // ABS path to the vault directory + writable: boolean; +} + +/** The ordered mount stack (read precedence top→bottom) plus non-fatal warnings + * (skipped/duplicate/corrupt mounts) surfaced for the caller to render. */ +export interface MountStack { + mounts: Mount[]; + warnings: string[]; +} + +// ── kind ranks + writability (spec canonical order) ────────────────────────────── +const KIND_RANK: Record = { + personal: 0, + engagement: 1, + project: 2, + restricted: 3, + team: 4, + public: 5, +}; +function kindRank(kind: string): number { + return kind in KIND_RANK ? KIND_RANK[kind] : 6; +} +const RW_KINDS = new Set(["personal", "engagement", "project"]); +function defaultWritable(kind: string): boolean { + return RW_KINDS.has(kind); +} + +// ── env-seam defaults ───────────────────────────────────────────────────────── +function defaultVaultsRoot(): string { + const env = process.env.AMICO_VAULTS_ROOT; + if (env && env.trim() !== "") return env; + return join(homedir(), ".amico", "vaults"); +} +function defaultMountsToml(): string { + const env = process.env.AMICO_MOUNTS_TOML; + if (env && env.trim() !== "") return env; + return join(homedir(), ".amico", "mounts.toml"); +} + +// ── manifest (`mounts.toml`) ───────────────────────────────────────────────────── +interface ManifestEntry { + id?: string; + kind?: string; + path?: string; + writable?: boolean; + repo?: string; +} + +/** Parse `mounts.toml`'s `[[mount]]` array. Absent → no manifest (no warning); + * corrupt → no manifest + a warning (tolerated per house rule). */ +function loadManifest(path: string): { entries: ManifestEntry[]; warning?: string } { + if (!existsSync(path)) return { entries: [] }; + let parsed: Record; + try { + parsed = parseToml(readFileSync(path, "utf8")) as Record; + } catch { + return { entries: [], warning: `mounts.toml parse error (${path}); ignoring the manifest` }; + } + const raw = parsed.mount; + if (!Array.isArray(raw)) return { entries: [] }; + const entries: ManifestEntry[] = []; + for (const m of raw) { + if (!m || typeof m !== "object") continue; + const e = m as Record; + entries.push({ + id: typeof e.id === "string" ? e.id : undefined, + kind: typeof e.kind === "string" && e.kind.trim() !== "" ? e.kind : undefined, + path: typeof e.path === "string" ? e.path : undefined, + writable: typeof e.writable === "boolean" ? e.writable : undefined, + repo: typeof e.repo === "string" ? e.repo : undefined, + }); + } + return { entries }; +} + +/** A discovered mount matches a manifest entry when the mount's resolved name OR + * its directory basename equals the entry's id OR its path basename (oracle: id + * or path-basename match; hook lines 128–129 / 168–178). */ +function entryMatches(name: string, dirBase: string, e: ManifestEntry): boolean { + const tokens = new Set(); + if (e.id) tokens.add(e.id); + if (e.path) tokens.add(basename(e.path)); + return tokens.has(name) || tokens.has(dirBase); +} + +// ── marker (`.amico-vault.toml`) ───────────────────────────────────────────────── +/** Read a marker's `kind`/`name`. A parse failure is non-fatal: `ok=false` and the + * fields come back undefined (the caller may still rescue via the manifest). */ +function readMarker(file: string): { ok: boolean; kind?: string; name?: string } { + let parsed: Record; + try { + parsed = parseToml(readFileSync(file, "utf8")) as Record; + } catch { + return { ok: false }; + } + const scalar = (v: unknown): string | undefined => + typeof v === "string" && v.trim() !== "" ? v.trim() : undefined; + return { ok: true, kind: scalar(parsed.kind), name: scalar(parsed.name) }; +} + +// ── resolver ───────────────────────────────────────────────────────────────────── +/** Resolve the Armonia mount stack. `vaultsRoot`/`mountsTomlPath` default via the + * env seam (see header). Never throws. */ +export function resolveMountStack(vaultsRoot?: string, mountsTomlPath?: string): MountStack { + // $AMICO_VAULT_DIR back-compat: force a single unnamed personal mount. Honored + // only when the caller passed no explicit vaultsRoot (explicit params win); it + // wins over $AMICO_VAULTS_ROOT / $AMICO_MOUNTS_TOML. + if (vaultsRoot === undefined) { + const forced = process.env.AMICO_VAULT_DIR; + if (forced && forced.trim() !== "") { + return { + mounts: [{ name: basename(forced) || forced, kind: "personal", path: forced, writable: true }], + warnings: [], + }; + } + } + + const root = vaultsRoot ?? defaultVaultsRoot(); + const tomlPath = mountsTomlPath ?? defaultMountsToml(); + const warnings: string[] = []; + + if (!existsSync(root)) return { mounts: [], warnings }; + let names: string[]; + try { + names = readdirSync(root).sort(); // discovery order = dir-name ascending (glob parity) + } catch { + return { mounts: [], warnings }; + } + + const manifest = loadManifest(tomlPath); + if (manifest.warning) warnings.push(manifest.warning); + const hasManifest = manifest.entries.length > 0; + + // ── discovery (per-mount kind/writable resolution) ── + const discovered: Mount[] = []; + const seen = new Set(); + for (const base of names) { + const dir = join(root, base); + let isDir = false; + try { + isDir = statSync(dir).isDirectory(); + } catch { + isDir = false; + } + if (!isDir) continue; + + const marker = join(dir, ".amico-vault.toml"); + if (!existsSync(marker)) { + warnings.push(`skipped ${base}: no .amico-vault.toml marker`); + continue; + } + const m = readMarker(marker); + if (!m.ok) warnings.push(`${base}: could not parse .amico-vault.toml (treating its fields as empty)`); + + const name = m.name ?? base; + // Manifest kind override applies BEFORE the missing-kind skip (oracle rescue + // rule, hook lines 125–133): a kind-less marker with a manifest entry is rescued. + const entry = manifest.entries.find((e) => entryMatches(name, base, e)); + const kind = entry?.kind ?? m.kind; + if (!kind) { + warnings.push(`skipped ${base}: marker missing 'kind' (and no mounts.toml kind)`); + continue; + } + if (seen.has(name)) { + warnings.push(`skipped ${base}: duplicate mount id '${name}'`); + continue; + } + seen.add(name); + + let writable = defaultWritable(kind); + if (entry?.writable === true) writable = true; + else if (entry?.writable === false) writable = false; + + discovered.push({ name, kind, path: dir, writable }); + } + + // ── ordering ── + let ordered: Mount[]; + if (hasManifest) { + // Manifest array order governs; unlisted mounts append in discovery order + // (oracle parity, hook lines 181–187 — NOT kind-rank). + ordered = []; + const emitted = new Set(); + for (const e of manifest.entries) { + for (const mount of discovered) { + if (emitted.has(mount.name)) continue; + if (entryMatches(mount.name, basename(mount.path), e)) { + ordered.push(mount); + emitted.add(mount.name); + } + } + } + for (const mount of discovered) { + if (!emitted.has(mount.name)) ordered.push(mount); + } + } else { + // No manifest: kind-rank, then name (matches the hook's `sort -k1,1n -k2,2`). + ordered = [...discovered].sort( + (a, b) => kindRank(a.kind) - kindRank(b.kind) || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0), + ); + } + + return { mounts: ordered, warnings }; +} + +/** The personal mount (the routed writer's default target, the extension's + * personalization root) — the first mount of kind `personal`, if any. */ +export function personalMount(stack: MountStack): Mount | undefined { + return stack.mounts.find((m) => m.kind === "personal"); +} diff --git a/packages/amico-run/test/mounts.test.ts b/packages/amico-run/test/mounts.test.ts new file mode 100644 index 00000000..4c2e4dca --- /dev/null +++ b/packages/amico-run/test/mounts.test.ts @@ -0,0 +1,246 @@ +// `mounts.ts` — the Armonia mount-stack resolver (Slice B, plan Task 6; +// spec-20260703-053956 vault-CLI canonical order). Pure logic, so it is unit-tested +// directly against src (no bundle): fixture tmp-dir vault trees exercise discovery, +// precedence, the manifest override/rescue, and the env seam. The parity oracle is +// the amico-plugin session-start hook (branch feat/amico-vault-mounts-toml, PR #27). +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { resolveMountStack, personalMount } from "../src/mounts.js"; + +// ── fixture helpers ───────────────────────────────────────────────────────────── +let root: string; // a fresh vaults-root per test +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "amico-mounts-")); +}); +afterEach(() => rmSync(root, { recursive: true, force: true })); + +/** Seed a vault dir with an `.amico-vault.toml` marker. `marker === null` seeds a + * bare dir (no marker); otherwise `marker` is the marker's TOML body. */ +function seedVault(dirName: string, marker: string | null): void { + const dir = join(root, dirName); + mkdirSync(dir, { recursive: true }); + if (marker !== null) writeFileSync(join(dir, ".amico-vault.toml"), marker); +} + +/** Write a `mounts.toml` manifest under a temp path and return it. */ +function seedManifest(body: string): string { + const path = join(root, "mounts.toml"); + writeFileSync(path, body); + return path; +} + +const NO_MANIFEST = () => join(root, "no-such-mounts.toml"); + +// ── (a) 6-kind ordering (no manifest → kind-rank-then-name) ───────────────────── +describe("resolveMountStack — kind-rank ordering (no manifest)", () => { + it("orders personal { + // Dir names are chosen so alphabetical order fights kind-rank order. + seedVault("z-personal", 'kind = "personal"'); + seedVault("y-engagement", 'kind = "engagement"'); + seedVault("x-project", 'kind = "project"'); + seedVault("w-restricted", 'kind = "restricted"'); + seedVault("v-team", 'kind = "team"'); + seedVault("u-public", 'kind = "public"'); + const stack = resolveMountStack(root, NO_MANIFEST()); + expect(stack.mounts.map((m) => m.kind)).toEqual([ + "personal", + "engagement", + "project", + "restricted", + "team", + "public", + ]); + // restricted (3) sorts strictly before team (4) — the spec correction vs the Ombra draft. + const kinds = stack.mounts.map((m) => m.kind); + expect(kinds.indexOf("restricted")).toBeLessThan(kinds.indexOf("team")); + }); + it("breaks kind ties by name (ascending)", () => { + seedVault("b-personal", 'kind = "personal"'); + seedVault("a-personal", 'kind = "personal"'); + const stack = resolveMountStack(root, NO_MANIFEST()); + expect(stack.mounts.map((m) => m.name)).toEqual(["a-personal", "b-personal"]); + }); +}); + +// ── (b) missing marker → skipped + warned ─────────────────────────────────────── +describe("resolveMountStack — missing marker", () => { + it("drops a dir with no .amico-vault.toml and warns, keeping the valid ones", () => { + seedVault("good", 'kind = "personal"'); + seedVault("bare", null); + const stack = resolveMountStack(root, NO_MANIFEST()); + expect(stack.mounts.map((m) => m.name)).toEqual(["good"]); + expect(stack.warnings.join("\n")).toMatch(/bare/); + expect(stack.warnings.join("\n")).toMatch(/marker/i); + }); +}); + +// ── (c) marker missing kind AND no manifest entry → skipped + warned ──────────── +describe("resolveMountStack — missing kind, no rescue", () => { + it("skips a marker with no kind when no manifest entry supplies one", () => { + seedVault("orphan", 'name = "orphan"'); + seedVault("ok", 'kind = "personal"'); + const stack = resolveMountStack(root, NO_MANIFEST()); + expect(stack.mounts.map((m) => m.name)).toEqual(["ok"]); + expect(stack.warnings.join("\n")).toMatch(/orphan/); + expect(stack.warnings.join("\n")).toMatch(/kind/i); + }); +}); + +// ── (c′) marker missing kind WITH manifest entry → RESCUED with manifest kind ──── +describe("resolveMountStack — rescue via manifest kind (oracle rule)", () => { + it("rescues a kind-less marker using the manifest kind (override before skip)", () => { + seedVault("rescued", 'name = "rescued"'); // no kind in the marker + const manifest = seedManifest( + ['[[mount]]', 'id = "rescued"', 'kind = "team"', `path = "${join(root, "rescued")}"`].join("\n"), + ); + const stack = resolveMountStack(root, manifest); + expect(stack.mounts).toHaveLength(1); + expect(stack.mounts[0]).toMatchObject({ name: "rescued", kind: "team", writable: false }); + }); +}); + +// ── (d) duplicate name → second skipped ───────────────────────────────────────── +describe("resolveMountStack — duplicate id", () => { + it("keeps the first, skips the second, and warns", () => { + seedVault("d1", 'kind = "personal"\nname = "dup"'); + seedVault("d2", 'kind = "personal"\nname = "dup"'); + const stack = resolveMountStack(root, NO_MANIFEST()); + expect(stack.mounts.map((m) => m.path)).toEqual([join(root, "d1")]); + expect(stack.warnings.join("\n")).toMatch(/duplicate/i); + }); +}); + +// ── (e) name defaults to basename ─────────────────────────────────────────────── +describe("resolveMountStack — name default", () => { + it("uses the directory basename when the marker omits name", () => { + seedVault("my-basename", 'kind = "project"'); + const stack = resolveMountStack(root, NO_MANIFEST()); + expect(stack.mounts[0].name).toBe("my-basename"); + }); +}); + +// ── (f) manifest order + kind/writable override + unlisted appended (discovery order) ── +describe("resolveMountStack — manifest ordering & overrides", () => { + it("orders by manifest, overrides kind/writable, appends unlisted in discovery order", () => { + seedVault("aaa", 'kind = "personal"'); + seedVault("bbb", 'kind = "project"'); // unlisted in the manifest + seedVault("ccc", 'kind = "team"'); + const manifest = seedManifest( + [ + "[[mount]]", + 'id = "ccc"', + 'kind = "engagement"', // override team → engagement + `path = "${join(root, "ccc")}"`, + "writable = true", // override ro → rw + "", + "[[mount]]", + 'id = "aaa"', + `path = "${join(root, "aaa")}"`, + ].join("\n"), + ); + const stack = resolveMountStack(root, manifest); + // manifest order [ccc, aaa]; then unlisted bbb in discovery order. + expect(stack.mounts.map((m) => m.name)).toEqual(["ccc", "aaa", "bbb"]); + const ccc = stack.mounts.find((m) => m.name === "ccc")!; + expect(ccc.kind).toBe("engagement"); + expect(ccc.writable).toBe(true); + }); +}); + +// ── (g) writability defaults by kind ──────────────────────────────────────────── +describe("resolveMountStack — default writability by kind", () => { + it("personal/engagement/project are rw; restricted/team/public are ro", () => { + seedVault("p", 'kind = "personal"'); + seedVault("e", 'kind = "engagement"'); + seedVault("j", 'kind = "project"'); + seedVault("r", 'kind = "restricted"'); + seedVault("t", 'kind = "team"'); + seedVault("b", 'kind = "public"'); + const stack = resolveMountStack(root, NO_MANIFEST()); + const w = Object.fromEntries(stack.mounts.map((m) => [m.kind, m.writable])); + expect(w).toEqual({ + personal: true, + engagement: true, + project: true, + restricted: false, + team: false, + public: false, + }); + }); +}); + +// ── (h) missing vaults root → empty stack, no throw ───────────────────────────── +describe("resolveMountStack — missing root", () => { + it("returns an empty stack (never throws) when the vaults root is absent", () => { + const stack = resolveMountStack(join(root, "does-not-exist"), NO_MANIFEST()); + expect(stack.mounts).toEqual([]); + expect(stack.warnings).toEqual([]); + }); +}); + +// ── tolerance: corrupt marker / manifest are non-fatal (house rule) ───────────── +describe("resolveMountStack — tolerates corrupt TOML", () => { + it("skips a corrupt marker with a warning and keeps resolving the rest", () => { + seedVault("broken", "kind = = bad toml"); + seedVault("fine", 'kind = "personal"'); + const stack = resolveMountStack(root, NO_MANIFEST()); + expect(stack.mounts.map((m) => m.name)).toEqual(["fine"]); + expect(stack.warnings.length).toBeGreaterThan(0); + }); + it("ignores a corrupt manifest (falls back to kind-rank) with a warning", () => { + seedVault("solo", 'kind = "personal"'); + const manifest = seedManifest("[[mount]\n oops not toml ="); + const stack = resolveMountStack(root, manifest); + expect(stack.mounts.map((m) => m.name)).toEqual(["solo"]); + expect(stack.warnings.join("\n")).toMatch(/manifest|mounts\.toml/i); + }); +}); + +// ── (i) env seam: $AMICO_VAULTS_ROOT / $AMICO_MOUNTS_TOML defaults ─────────────── +describe("resolveMountStack — env seam", () => { + const saved = { ...process.env }; + afterEach(() => { + process.env = { ...saved }; + }); + it("reads $AMICO_VAULTS_ROOT / $AMICO_MOUNTS_TOML when no params are passed", () => { + seedVault("envvault", 'kind = "personal"'); + delete process.env.AMICO_VAULT_DIR; + process.env.AMICO_VAULTS_ROOT = root; + process.env.AMICO_MOUNTS_TOML = NO_MANIFEST(); + const stack = resolveMountStack(); + expect(stack.mounts.map((m) => m.name)).toEqual(["envvault"]); + }); + it("$AMICO_VAULT_DIR forces a single personal mount and wins over $AMICO_VAULTS_ROOT", () => { + seedVault("ignored", 'kind = "team"'); + const forced = mkdtempSync(join(tmpdir(), "amico-forced-")); + process.env.AMICO_VAULT_DIR = forced; + process.env.AMICO_VAULTS_ROOT = root; // must be ignored + const stack = resolveMountStack(); + expect(stack.mounts).toHaveLength(1); + expect(stack.mounts[0]).toMatchObject({ path: forced, kind: "personal", writable: true }); + rmSync(forced, { recursive: true, force: true }); + }); + it("explicit params win over $AMICO_VAULT_DIR", () => { + seedVault("explicit", 'kind = "project"'); + process.env.AMICO_VAULT_DIR = "/some/forced/dir"; + const stack = resolveMountStack(root, NO_MANIFEST()); + expect(stack.mounts.map((m) => m.name)).toEqual(["explicit"]); + }); +}); + +// ── personalMount ─────────────────────────────────────────────────────────────── +describe("personalMount", () => { + it("returns the first personal mount, else undefined", () => { + seedVault("team-one", 'kind = "team"'); + seedVault("me", 'kind = "personal"'); + const stack = resolveMountStack(root, NO_MANIFEST()); + expect(personalMount(stack)?.name).toBe("me"); + }); + it("undefined when there is no personal mount", () => { + seedVault("team-only", 'kind = "team"'); + const stack = resolveMountStack(root, NO_MANIFEST()); + expect(personalMount(stack)).toBeUndefined(); + }); +}); From 5bf6fb77db4b8a6931e8433aa00209c693f74e91 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sat, 11 Jul 2026 01:54:01 -0400 Subject: [PATCH 2/3] feat(vault): mount-aware status/resolve + union query over the Armonia stack Co-Authored-By: Claude Fable 5 --- packages/amico-run/src/mounts.ts | 8 ++ packages/amico-run/src/vault_query.ts | 29 +++++- packages/amico-run/src/vault_verb.ts | 111 +++++++++++++++++---- packages/amico-run/src/verbs.ts | 10 +- packages/amico-run/test/vault_verb.test.ts | 106 ++++++++++++++++++++ 5 files changed, 240 insertions(+), 24 deletions(-) diff --git a/packages/amico-run/src/mounts.ts b/packages/amico-run/src/mounts.ts index 2459c01d..0dbbe3f2 100644 --- a/packages/amico-run/src/mounts.ts +++ b/packages/amico-run/src/mounts.ts @@ -260,3 +260,11 @@ export function resolveMountStack(vaultsRoot?: string, mountsTomlPath?: string): export function personalMount(stack: MountStack): Mount | undefined { return stack.mounts.find((m) => m.kind === "personal"); } + +/** Read a vault directory's `.amico-vault.toml` marker (kind/name). Amico-run-only + * helper (not part of the shared twin API): `vault status` uses the raw marker + * kind to detect drift against the manifest-resolved kind. Never throws. */ +export function readVaultMarker(vaultDir: string): { kind?: string; name?: string } { + const m = readMarker(join(vaultDir, ".amico-vault.toml")); + return { kind: m.kind, name: m.name }; +} diff --git a/packages/amico-run/src/vault_query.ts b/packages/amico-run/src/vault_query.ts index b101be0b..f08b80a7 100644 --- a/packages/amico-run/src/vault_query.ts +++ b/packages/amico-run/src/vault_query.ts @@ -15,7 +15,9 @@ import { homedir } from "node:os"; import { join } from "node:path"; /** A vault note projected for retrieval. `body` is the markdown after the - * frontmatter; `title` is the first `# ` heading (else the filename). */ + * frontmatter; `title` is the first `# ` heading (else the filename). `mount` is + * the name of the mount the note came from (stamped by loadNotesAcross; undefined + * for a single-mount `loadNotes`). */ export interface NoteRecord { path: string; // ABS path file: string; // basename @@ -26,6 +28,7 @@ export interface NoteRecord { gate?: string; tags: string[]; body: string; + mount?: string; } /** The vault root. `$AMICO_VAULT_DIR` overrides it (tests point it at a temp @@ -136,6 +139,28 @@ export function loadNotes(dir: string, folders: readonly string[] = NOTE_FOLDERS return records; } +/** Union the note folders across an ordered list of mounts (highest precedence + * first). Each record is stamped with its mount's name. A collision on the same + * `/` relpath is won by the higher-precedence (earlier) mount — the + * lower one is dropped, so retrieval never surfaces a shadowed note. Never throws. + * This is the read side of the Armonia mount stack (plan Task 7). */ +export function loadNotesAcross( + mounts: readonly { name: string; path: string }[], + folders: readonly string[] = NOTE_FOLDERS, +): NoteRecord[] { + const out: NoteRecord[] = []; + const seen = new Set(); + for (const m of mounts) { + for (const rec of loadNotes(m.path, folders)) { + const key = `${rec.folder}/${rec.file}`; + if (seen.has(key)) continue; // a higher-precedence mount already provided this relpath + seen.add(key); + out.push({ ...rec, mount: m.name }); + } + } + return out; +} + // ── relevance ranking ───────────────────────────────────────────────────────── export interface RankedNote { @@ -147,6 +172,7 @@ export interface RankedNote { tags: string[]; score: number; snippet: string; + mount?: string; // which mount the hit came from (union query); undefined single-mount } export interface QueryOpts { @@ -223,5 +249,6 @@ export function rankNotes(notes: NoteRecord[], query: string, opts: QueryOpts = tags: note.tags, score, snippet: snippetFor(note, terms), + mount: note.mount, })); } diff --git a/packages/amico-run/src/vault_verb.ts b/packages/amico-run/src/vault_verb.ts index 1d63806e..8f453c4e 100644 --- a/packages/amico-run/src/vault_verb.ts +++ b/packages/amico-run/src/vault_verb.ts @@ -1,19 +1,35 @@ -// `amico vault` — knowledge-graph retrieval (issue #113, slice B3; -// spec-20260708-112732 §3.1, §7.3). One subcommand today, read-only: +// `amico vault` — knowledge-graph retrieval + Armonia mount-stack introspection +// (issue #113, slice B3; spec-20260708-112732 §3.1, §7.3 + plan Task 7). Read-only: // -// amico vault query --q "" [--type insight|experiment] -// [--platform

] [--kind ] [--limit ] -// → the notes (insights/experiments) most RELEVANT to the query, ranked -// (title > tags > body weighting), read from the mounted vault. This is -// the retrieval seam an agent hits on demand — retrieval, not -// front-loading the whole graph into context. +// amico vault query --q "" [--type insight|experiment] [--platform

] +// [--kind ] [--limit ] [--mount ] +// → the notes most RELEVANT to the query, ranked (title > tags > body), +// UNION over the whole mount stack in precedence order. A collision on the +// same / relpath is won by the higher-precedence mount. +// `--mount ` restricts to one mount. `$AMICO_VAULT_DIR` forces a single +// unnamed mount (back-compat — see mounts.ts). // -// Pure ranking logic lives in vault_query.ts; this is the flag surface + I/O. +// amico vault status [--json] +// → the resolved mount stack as `{ok, mounts:[{id,path,kind,writable, +// last_sync,warnings}], error}` — FIELD-COMPATIBLE with the bash +// `amico-vault status --json` (ops/scripts/amico-vault cmd_status). A drift +// warning fires when the marker kind ≠ the manifest-resolved kind (manifest +// wins); `last_sync` is `git log -1 --format=%cr`, "unknown" tolerated. +// +// amico vault resolve +// → first-hit lookup of across the stack in precedence order: +// `{found, path, mount}` on a hit, `{found:false, path:null, misses:[…]}` +// (the mount roots searched) otherwise. +// +// Pure logic lives in vault_query.ts (ranking + union load) and mounts.ts (stack +// resolution). This is the flag surface + I/O. // FLAG NAMES (S31 guard): the physics-knob double-dash flags (gate/pulse/system) -// are banned in src/; the gate discriminator is `--kind` (mapping onto the note -// `gate` field, exactly as `amico catalog` does), and the free-text query is -// `--q`. -import { loadNotes, rankNotes, vaultDir, type QueryOpts } from "./vault_query.js"; +// are banned in src/; the gate discriminator is `--kind`, the free-text query `--q`. +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { loadNotesAcross, rankNotes, type QueryOpts } from "./vault_query.js"; +import { resolveMountStack, readVaultMarker } from "./mounts.js"; import type { VerbResult } from "./verbs.js"; function flagValue(argv: string[], name: string): string | undefined { @@ -21,6 +37,7 @@ function flagValue(argv: string[], name: string): string | undefined { return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined; } +// ── query (union over the mount stack) ────────────────────────────────────────── export function vaultQuery(argv: string[]): VerbResult { const q = flagValue(argv, "--q"); if (q === undefined || q.trim() === "") { @@ -34,18 +51,23 @@ export function vaultQuery(argv: string[]): VerbResult { const limitRaw = flagValue(argv, "--limit"); if (limitRaw !== undefined) { const n = Number(limitRaw); - if (!Number.isFinite(n) || n <= 0) return { json: { verb: "vault", subcommand: "query", error: `--limit must be a positive number (got "${limitRaw}")` }, code: 64 }; + if (!Number.isFinite(n) || n <= 0) + return { json: { verb: "vault", subcommand: "query", error: `--limit must be a positive number (got "${limitRaw}")` }, code: 64 }; opts.limit = Math.floor(n); } - const dir = vaultDir(); - const hits = rankNotes(loadNotes(dir), q, opts); + + const stack = resolveMountStack(); + const only = flagValue(argv, "--mount"); + const mounts = only ? stack.mounts.filter((m) => m.name === only) : stack.mounts; + const hits = rankNotes(loadNotesAcross(mounts), q, opts); return { json: { verb: "vault", subcommand: "query", - vault: dir, + vault: mounts[0]?.path ?? null, // back-compat: the highest-precedence mount root + mounts: mounts.map((m) => m.name), query: q, - filters: { type: opts.type ?? null, platform: opts.platform ?? null, gate: opts.gate ?? null }, + filters: { type: opts.type ?? null, platform: opts.platform ?? null, gate: opts.gate ?? null, mount: only ?? null }, count: hits.length, hits, }, @@ -53,17 +75,68 @@ export function vaultQuery(argv: string[]): VerbResult { }; } +// ── status (field-compatible with `amico-vault status --json`) ────────────────── +/** `git log -1 --format=%cr` for a mount; "unknown" for a non-repo (tolerated, + * matching the bash oracle). */ +function gitLastSync(dir: string): string { + try { + const out = execFileSync("git", ["-C", dir, "log", "-1", "--format=%cr"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + return out || "unknown"; + } catch { + return "unknown"; + } +} + +export function vaultStatus(_argv: string[]): VerbResult { + const stack = resolveMountStack(); + const mounts = stack.mounts.map((m) => { + const markerKind = readVaultMarker(m.path).kind; + const warnings: string[] = []; + // Drift: the resolved (display) kind differs from the marker kind ⇒ the manifest + // overrode it (manifest wins). Mirrors cmd_status's drift surfacing. + if ((markerKind ?? "") !== m.kind) { + warnings.push(`drift: marker kind='${markerKind ?? ""}' but mounts.toml kind='${m.kind}' (using mounts.toml)`); + } + return { id: m.name, path: m.path, kind: m.kind, writable: m.writable, last_sync: gitLastSync(m.path), warnings }; + }); + return { json: { ok: true, mounts, error: null }, code: 0 }; +} + +// ── resolve (first-hit across precedence) ─────────────────────────────────────── +export function vaultResolve(argv: string[]): VerbResult { + const relpath = argv[0]; + if (!relpath || relpath.startsWith("--")) { + return { json: { verb: "vault", subcommand: "resolve", error: "a is required", usage: "amico vault resolve " }, code: 64 }; + } + const stack = resolveMountStack(); + const misses: string[] = []; + for (const m of stack.mounts) { + const candidate = join(m.path, relpath); + if (existsSync(candidate)) { + return { json: { verb: "vault", subcommand: "resolve", relpath, found: true, path: candidate, mount: m.name }, code: 0 }; + } + misses.push(m.path); + } + return { json: { verb: "vault", subcommand: "resolve", relpath, found: false, path: null, misses }, code: 0 }; +} + /** The `vault` verb body: dispatch on the subcommand. Backs BOTH the CLI * (amico.ts) and the MCP facade (mcp_serve.ts). */ export function vaultVerb(argv: string[]): VerbResult { const sub = argv[0]; const rest = argv.slice(1); if (sub === "query") return vaultQuery(rest); + if (sub === "status") return vaultStatus(rest); + if (sub === "resolve") return vaultResolve(rest); return { json: { verb: "vault", error: `unknown subcommand ${sub ? `"${sub}"` : "(none)"}`, - usage: 'amico vault query --q "" [--type insight|experiment] [--platform

] [--kind ] [--limit ]', + usage: + 'amico vault query --q "" [--type insight|experiment] [--platform

] [--kind ] [--limit ] [--mount ] | amico vault status [--json] | amico vault resolve ', }, code: 64, }; diff --git a/packages/amico-run/src/verbs.ts b/packages/amico-run/src/verbs.ts index 3e950317..d81dfd41 100644 --- a/packages/amico-run/src/verbs.ts +++ b/packages/amico-run/src/verbs.ts @@ -63,12 +63,14 @@ const catalog: Verb = { run: catalogVerb, }; -// vault — retrieval over the knowledge graph (query tools, not front-loading context). -// REAL as of B3: `query` ranks insights/experiments by relevance to a free-text query. +// vault — retrieval over the knowledge graph + Armonia mount-stack introspection. +// REAL as of B3: `query` (union-over-mounts relevance ranking), `status` (resolved +// mount stack, field-compatible with `amico-vault status --json`), `resolve` +// (first-hit relpath lookup across the stack). const vault: Verb = { name: "vault", - summary: "query the knowledge graph (insights/experiments) by relevance — retrieval, not front-load", - generalizes: "the amicode_* vault plugin tools (retrieval half)", + summary: "query the knowledge graph (union over mounts) / mount-stack status / resolve a relpath", + generalizes: "the amicode_* vault plugin tools (retrieval half) + amico-vault status", slice: "spine bookkeeping (B3)", run: vaultVerb, }; diff --git a/packages/amico-run/test/vault_verb.test.ts b/packages/amico-run/test/vault_verb.test.ts index 677259b3..1f5c4a28 100644 --- a/packages/amico-run/test/vault_verb.test.ts +++ b/packages/amico-run/test/vault_verb.test.ts @@ -121,3 +121,109 @@ describe("amico vault query (bundle)", () => { expect(JSON.parse(r.stdout).error).toMatch(/unknown subcommand/); }); }); + +// ── mount-aware verbs (Task 7): multi-mount fixtures reach the bundle via the env seam ── +/** Seed a vault dir under `root` with an `.amico-vault.toml` marker. */ +function seedMarker(root: string, name: string, markerBody: string): string { + const dir = join(root, name); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, ".amico-vault.toml"), markerBody); + return dir; +} + +describe("amico vault status (bundle) — field-compatible with amico-vault status --json", () => { + it("emits {ok, mounts:[{id,path,kind,writable,last_sync,warnings}], error} with drift warnings", () => { + const root = mkdtempSync(join(tmpdir(), "amico-mstatus-")); + seedMarker(root, "me", 'kind = "personal"\nname = "me"'); + seedMarker(root, "shared", 'kind = "project"\nname = "shared"'); // marker says project… + const manifest = join(root, "mounts.toml"); + writeFileSync(manifest, ["[[mount]]", 'id = "shared"', 'kind = "team"', `path = "${join(root, "shared")}"`].join("\n")); // …manifest says team + + const r = run(["vault", "status", "--json"], { AMICO_VAULTS_ROOT: root, AMICO_MOUNTS_TOML: manifest, AMICO_VAULT_DIR: "" }); + expect(r.code).toBe(0); + const out = JSON.parse(r.stdout); + expect(out.ok).toBe(true); + expect(out.error).toBeNull(); + expect(Array.isArray(out.mounts)).toBe(true); + + const shared = out.mounts.find((m: { id: string }) => m.id === "shared"); + expect(shared).toMatchObject({ kind: "team", writable: false }); + expect(shared.warnings.join(" ")).toMatch(/drift.*project.*team/i); + expect(typeof shared.last_sync).toBe("string"); // "unknown" for a non-git temp dir (tolerated) + expect(Object.keys(shared).sort()).toEqual(["id", "kind", "last_sync", "path", "warnings", "writable"]); + + const me = out.mounts.find((m: { id: string }) => m.id === "me"); + expect(me).toMatchObject({ kind: "personal", writable: true }); + expect(me.warnings).toEqual([]); // no manifest override ⇒ no drift + rmSync(root, { recursive: true, force: true }); + }); +}); + +describe("amico vault resolve (bundle) — first-hit across precedence", () => { + it("resolves to the highest-precedence mount that has the relpath; reports misses otherwise", () => { + const root = mkdtempSync(join(tmpdir(), "amico-mresolve-")); + seedMarker(root, "a", 'kind = "personal"\nname = "a"'); // rank 0 — wins collisions + seedMarker(root, "b", 'kind = "team"\nname = "b"'); // rank 4 + seedNote(join(root, "a"), "insights", "shared.md", "type: insight", "# a copy"); + seedNote(join(root, "b"), "insights", "shared.md", "type: insight", "# b copy"); + seedNote(join(root, "b"), "insights", "bonly.md", "type: insight", "# b only"); + const env = { AMICO_VAULTS_ROOT: root, AMICO_MOUNTS_TOML: join(root, "none.toml"), AMICO_VAULT_DIR: "" }; + + const hit = JSON.parse(run(["vault", "resolve", "insights/shared.md"], env).stdout); + expect(hit).toMatchObject({ found: true, mount: "a", path: join(root, "a", "insights", "shared.md") }); + + const hitB = JSON.parse(run(["vault", "resolve", "insights/bonly.md"], env).stdout); + expect(hitB).toMatchObject({ found: true, mount: "b" }); + + const miss = JSON.parse(run(["vault", "resolve", "insights/ghost.md"], env).stdout); + expect(miss).toMatchObject({ found: false, path: null }); + expect(miss.misses).toEqual([join(root, "a"), join(root, "b")]); + rmSync(root, { recursive: true, force: true }); + }); + it("missing relpath → 64 (checked before any stack resolution)", () => { + expect(run(["vault", "resolve"], { AMICO_VAULT_DIR: "" }).code).toBe(64); + }); +}); + +describe("amico vault query — union over the mount stack (bundle)", () => { + it("searches all mounts in precedence order; a same-relpath collision is won by the higher-precedence mount", () => { + const root = mkdtempSync(join(tmpdir(), "amico-mquery-")); + seedMarker(root, "a", 'kind = "personal"\nname = "a"'); + seedMarker(root, "b", 'kind = "team"\nname = "b"'); + seedNote(join(root, "a"), "insights", "shared.md", "type: insight", "# shared alpha\nkeyword rydberg here"); + seedNote(join(root, "b"), "insights", "shared.md", "type: insight", "# shared beta\nkeyword rydberg here"); + seedNote(join(root, "b"), "insights", "bonly.md", "type: insight", "# b only\nkeyword rydberg here"); + const env = { AMICO_VAULTS_ROOT: root, AMICO_MOUNTS_TOML: join(root, "none.toml"), AMICO_VAULT_DIR: "" }; + + const out = JSON.parse(run(["vault", "query", "--q", "rydberg"], env).stdout); + expect(out.count).toBe(2); // a/shared (b/shared shadowed) + b/bonly + expect(out.mounts).toEqual(["a", "b"]); + const shared = out.hits.find((h: { file: string }) => h.file === "shared.md"); + expect(shared.mount).toBe("a"); // personal (rank 0) wins the collision + expect(shared.title).toBe("shared alpha"); + expect(out.hits.find((h: { file: string }) => h.file === "bonly.md").mount).toBe("b"); + + // --mount restricts to a single mount + const restricted = JSON.parse(run(["vault", "query", "--q", "rydberg", "--mount", "b"], env).stdout); + expect(restricted.count).toBe(2); // b/shared + b/bonly + expect(restricted.hits.every((h: { mount: string }) => h.mount === "b")).toBe(true); + expect(restricted.hits.find((h: { file: string }) => h.file === "shared.md").title).toBe("shared beta"); + rmSync(root, { recursive: true, force: true }); + }); + it("$AMICO_VAULT_DIR forces a single mount and wins over $AMICO_VAULTS_ROOT (back-compat)", () => { + const single = mkdtempSync(join(tmpdir(), "amico-single-")); + const multi = mkdtempSync(join(tmpdir(), "amico-multi-")); + seedNote(single, "insights", "only.md", "type: insight", "# only\nkeyword photon"); + seedMarker(multi, "x", 'kind = "personal"\nname = "x"'); + seedNote(join(multi, "x"), "insights", "other.md", "type: insight", "# other\nkeyword photon"); + + const out = JSON.parse( + run(["vault", "query", "--q", "photon"], { AMICO_VAULT_DIR: single, AMICO_VAULTS_ROOT: multi, AMICO_MOUNTS_TOML: join(multi, "none.toml") }).stdout, + ); + expect(out.count).toBe(1); + expect(out.hits[0].file).toBe("only.md"); + expect(out.vault).toBe(single); // the forced single mount root + rmSync(single, { recursive: true, force: true }); + rmSync(multi, { recursive: true, force: true }); + }); +}); From c4ec87bd7f13933ef9ab36376d53865f388ef937 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sat, 11 Jul 2026 02:04:50 -0400 Subject: [PATCH 3/3] =?UTF-8?q?feat(note):=20note=20route=20=E2=80=94=20ro?= =?UTF-8?q?uted=20generic=20note=20writes=20by=20intent=20+=20route=5Finte?= =?UTF-8?q?nt=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- packages/amico-run/src/note.ts | 97 +++++++++++++ packages/amico-run/src/note_verb.ts | 116 ++++++++++++++- packages/amico-run/src/verbs.ts | 5 +- packages/amico-run/test/note_verb.test.ts | 163 ++++++++++++++++++++++ 4 files changed, 378 insertions(+), 3 deletions(-) diff --git a/packages/amico-run/src/note.ts b/packages/amico-run/src/note.ts index 51ffa71d..096bfeba 100644 --- a/packages/amico-run/src/note.ts +++ b/packages/amico-run/src/note.ts @@ -9,6 +9,13 @@ // Two operations: (1) render an experiment note with full frontmatter from a // finished-run row; (2) bump the `best_gates` list in a system-context note, // replacing the incumbent gate entry iff the candidate has higher fidelity. +// +// B-slice addition (plan Task 8): the routed GENERIC writer behind `amico note +// route` — the amico-vault skill's write-routing table mechanized. It is a SEPARATE +// subcommand from `note write`: the experiment writer above is byte-for-byte +// untouched. The routing logic here is pure (mount stack + intent in, decision out; +// clock/fs stay in note_verb.ts). +import type { Mount } from "./mounts.js"; // ── experiment note rendering ───────────────────────────────────────────────── @@ -259,3 +266,93 @@ export function bumpBestGatesInText(text: string, entry: BestGate): BumpTextResu const rebuilt = [...lines.slice(0, keyIdx), ...newBlock, ...lines.slice(blockEnd)].join("\n"); return { ok: true, text: rebuilt, bumped: true, previous: merge.previous, reason: merge.reason }; } + +// ── routed generic note (`amico note route`) ───────────────────────────────────── +// The amico-vault skill's write-routing table, mechanized. `experiment` is +// deliberately absent — schema-complete experiment notes are `note write`'s job. +// The skill's folder table lacks notes/hopper rows, so the explicit map is stated +// here (plan Task 8). +export const ROUTE_FOLDERS: Record = { + spec: "specs", + plan: "plans", + insight: "insights", + method: "methods", + note: "notes", + hopper: "hopper", +}; + +/** A route type is valid iff it has a folder in the explicit map (excludes + * `experiment`, which is `note write`'s exclusive job). */ +export function isRoutableType(type: string): boolean { + return Object.prototype.hasOwnProperty.call(ROUTE_FOLDERS, type); +} + +/** The routing decision: which mount to write to, plus the `route_intent` to stamp + * when we fall back to personal (target kind absent or read-only). */ +export interface RouteDecision { + mount: Mount; + routeIntent?: string; // set only on a personal fallback (never silently dropped) +} + +/** Route by intent kind → the first WRITABLE mount of that kind in stack order. If + * none exists (kind absent or read-only), fall back to the personal mount and mark + * `routeIntent` so the note records where it wanted to go. Never writes a ro mount; + * never silently drops the intent. Pure — the stack is resolved by the caller. */ +export function routeNote(mounts: readonly Mount[], intent: string): RouteDecision | { error: string } { + const target = mounts.find((m) => m.kind === intent && m.writable); + if (target) return { mount: target }; + const personal = mounts.find((m) => m.kind === "personal" && m.writable); + if (!personal) return { error: `no writable personal mount to route a '${intent}' note to` }; + // intent === "personal" reaching here means there is no writable personal mount + // (handled above), so any fallback here is a genuine cross-kind reroute. + return { mount: personal, routeIntent: intent }; +} + +/** ISO date derived from a `YYYYMMDD-HHMMSS` stamp: "20260711-013000" → "2026-07-11". */ +export function stampToDate(stamp: string): string { + const day = stamp.slice(0, 8); + return `${day.slice(0, 4)}-${day.slice(4, 6)}-${day.slice(6, 8)}`; +} + +/** A filesystem-safe kebab slug from a title (empty → "note"). */ +export function slugify(title: string): string { + return ( + title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") || "note" + ); +} + +/** `--` — the routed note's basename (no folder, no extension). */ +export function routedNoteBasename(type: string, stamp: string, title: string): string { + return `${type}-${stamp}-${slugify(title)}`; +} + +export interface RoutedNoteFields { + type: string; + title: string; + body: string; + stamp: string; // YYYYMMDD-HHMMSS + route_intent?: string; // stamped only on a personal fallback + session_id?: string | null; // routed notes are agent-agnostic → null +} + +/** Render a routed generic note: minimal frontmatter (`type`, `date`, + * `session_id: null`, `tags: []`, and `route_intent` only when set) + an H1 + * title and the supplied body. Deterministic — stamp/date/intent come from the + * caller, nothing is invented. */ +export function renderRoutedNote(f: RoutedNoteFields): string { + const fm = [ + "---", + `type: ${f.type}`, + `date: ${stampToDate(f.stamp)}`, + `session_id: ${f.session_id ? `"${f.session_id}"` : "null"}`, + `tags: [${f.type}]`, + ]; + if (f.route_intent) fm.push(`route_intent: ${f.route_intent}`); + fm.push("---"); + + const body = ["", `# ${f.title}`, "", f.body.trim(), ""].join("\n"); + return fm.join("\n") + "\n" + body + "\n"; +} diff --git a/packages/amico-run/src/note_verb.ts b/packages/amico-run/src/note_verb.ts index 2e32c6fd..a57a49b0 100644 --- a/packages/amico-run/src/note_verb.ts +++ b/packages/amico-run/src/note_verb.ts @@ -18,19 +18,35 @@ // replacing the incumbent gate entry iff the candidate has higher // fidelity. Surgical text edit — the rest of the note is untouched. // +// amico note route --type --title +// (--body | --body-file ) [--intent ] +// [--stamp YYYYMMDD-HHMMSS] [--commit] [--dry-run] +// → the routed GENERIC writer (plan Task 8): pick the first WRITABLE mount of +// the intent kind, else fall back to the personal mount and stamp +// `route_intent`. `experiment` is NOT a valid --type (that is `note write`'s +// job). `--stamp` is injectable (clock injected at this verb layer, not the +// pure core). NEW subcommand — `note write`/`bump-best` are untouched. +// // FLAG NAMES (S31 guard): the physics-knob double-dash flags (gate/pulse/system) // are banned in src/; the gate discriminator is `--kind` (mapping onto the note // `gate` field, as `amico catalog` does). +import { execFileSync } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { parse as parseToml } from "smol-toml"; import { + ROUTE_FOLDERS, bumpBestGatesInText, experimentId, + isRoutableType, renderExperimentNote, + renderRoutedNote, + routeNote, + routedNoteBasename, type BestGate, type ExperimentFields, } from "./note.js"; +import { resolveMountStack } from "./mounts.js"; import { vaultDir } from "./vault_query.js"; import type { VerbResult } from "./verbs.js"; @@ -201,6 +217,103 @@ export function noteBumpBest(argv: string[]): VerbResult { return { json: { ...common, written: true }, code: 0 }; } +// ── route (routed generic writer) ──────────────────────────────────────────── +/** A `YYYYMMDD-HHMMSS` stamp from the system clock (UTC — matches `today()`). The + * clock lives HERE, not in the pure core (note.ts), which takes the stamp as a + * parameter (b3 house rule). */ +function nowStamp(): string { + const iso = new Date().toISOString(); // 2026-07-11T01:30:00.000Z + return iso.slice(0, 10).replace(/-/g, "") + "-" + iso.slice(11, 19).replace(/:/g, ""); +} + +/** `git add && git commit -m ` in the mount. Tolerates a non-repo + * (or any git failure): returns a warning, never throws — the note is already on + * disk, so a failed commit is a warning, not a verb failure. */ +function gitCommit(mountPath: string, file: string, message: string): { committed: boolean; warning?: string } { + try { + execFileSync("git", ["-C", mountPath, "add", file], { stdio: ["ignore", "ignore", "ignore"] }); + execFileSync("git", ["-C", mountPath, "commit", "-m", message], { stdio: ["ignore", "ignore", "ignore"] }); + return { committed: true }; + } catch (e) { + return { committed: false, warning: `git commit skipped: ${e instanceof Error ? e.message : String(e)}` }; + } +} + +export function noteRoute(argv: string[]): VerbResult { + const fail = (error: string, extra: Record = {}): VerbResult => ({ + json: { verb: "note", subcommand: "route", error, ...extra }, + code: 64, + }); + + const type = flagValue(argv, "--type"); + if (!type) return fail("--type is required (spec|plan|insight|method|note|hopper)"); + if (type === "experiment") { + return fail("`experiment` is not a routable type — schema-complete experiment notes are written by `note write`", { + use: "amico note write --platform

--kind --fidelity ", + }); + } + if (!isRoutableType(type)) return fail(`unknown --type "${type}" (want: spec|plan|insight|method|note|hopper)`); + const folder = ROUTE_FOLDERS[type]; + + const title = flagValue(argv, "--title"); + if (!title) return fail("--title is required"); + + const bodyInline = flagValue(argv, "--body"); + const bodyFile = flagValue(argv, "--body-file"); + let body: string; + if (bodyInline !== undefined) { + body = bodyInline; + } else if (bodyFile !== undefined) { + if (!existsSync(bodyFile)) return fail(`--body-file not found: ${bodyFile}`); + try { + body = readFileSync(bodyFile, "utf8"); + } catch (e) { + return fail(`cannot read --body-file: ${e instanceof Error ? e.message : String(e)}`); + } + } else { + return fail("a body is required: --body or --body-file "); + } + + const intent = flagValue(argv, "--intent") ?? "personal"; + const stamp = flagValue(argv, "--stamp") ?? nowStamp(); + + const stack = resolveMountStack(); + const decision = routeNote(stack.mounts, intent); + if ("error" in decision) return fail(decision.error); + const { mount, routeIntent } = decision; + + const content = renderRoutedNote({ type, title, body, stamp, route_intent: routeIntent, session_id: null }); + const dir = join(mount.path, folder); + const file = join(dir, `${routedNoteBasename(type, stamp, title)}.md`); + + const common = { + verb: "note", + subcommand: "route", + type, + intent, + mount: mount.name, + route_intent: routeIntent ?? null, + path: file, + }; + + if (argv.includes("--dry-run")) { + return { json: { ...common, written: false, dry_run: true, content }, code: 0 }; + } + + try { + mkdirSync(dir, { recursive: true }); + writeFileSync(file, content); + } catch (e) { + return fail(`failed to write note: ${e instanceof Error ? e.message : String(e)}`); + } + + const result: Record = { ...common, written: true }; + if (argv.includes("--commit")) { + result.commit = gitCommit(mount.path, file, `note: add ${routedNoteBasename(type, stamp, title)}`); + } + return { json: result, code: 0 }; +} + // ── dispatch ───────────────────────────────────────────────────────────────── /** The `note` verb body: dispatch on the subcommand. Backs BOTH the CLI * (amico.ts) and the MCP facade (mcp_serve.ts). */ @@ -209,12 +322,13 @@ export function noteVerb(argv: string[]): VerbResult { const rest = argv.slice(1); if (sub === "write") return noteWrite(rest); if (sub === "bump-best") return noteBumpBest(rest); + if (sub === "route") return noteRoute(rest); return { json: { verb: "note", error: `unknown subcommand ${sub ? `"${sub}"` : "(none)"}`, usage: - "amico note write --platform

--kind --fidelity | amico note bump-best --platform

--kind --fidelity [--source ]", + "amico note write --platform

--kind --fidelity | amico note bump-best --platform

--kind --fidelity [--source ] | amico note route --type --title (--body | --body-file ) [--intent ] [--stamp ] [--commit]", }, code: 64, }; diff --git a/packages/amico-run/src/verbs.ts b/packages/amico-run/src/verbs.ts index d81dfd41..50ab1241 100644 --- a/packages/amico-run/src/verbs.ts +++ b/packages/amico-run/src/verbs.ts @@ -87,10 +87,11 @@ const device: Verb = { }; // note — librarian bookkeeping. REAL as of B3: `write` (experiment note) + -// `bump-best` (best_gates), both deterministic. +// `bump-best` (best_gates); `route` (routed generic note by intent, Task 8) — all +// deterministic. const note: Verb = { name: "note", - summary: "write experiment note / bump best_gates (librarian bookkeeping → deterministic)", + summary: "write experiment note / bump best_gates / route a generic note by intent (librarian bookkeeping)", generalizes: "the amicode_* librarian/note plugin tools (bookkeeping half)", slice: "spine bookkeeping (B3)", run: noteVerb, diff --git a/packages/amico-run/test/note_verb.test.ts b/packages/amico-run/test/note_verb.test.ts index af13fb1e..117a41c0 100644 --- a/packages/amico-run/test/note_verb.test.ts +++ b/packages/amico-run/test/note_verb.test.ts @@ -14,8 +14,16 @@ import { mergeBestGates, bumpBestGatesInText, parseBestGate, + routeNote, + renderRoutedNote, + routedNoteBasename, + slugify, + stampToDate, + isRoutableType, + ROUTE_FOLDERS, type BestGate, } from "../src/note.js"; +import type { Mount } from "../src/mounts.js"; // ── pure logic (note.ts) ──────────────────────────────────────────────────────── describe("renderExperimentNote + experimentId", () => { @@ -103,6 +111,58 @@ describe("bumpBestGatesInText — surgical frontmatter edit", () => { }); }); +// ── routed generic note — pure core (note.ts) ───────────────────────────────────── +describe("routeNote — intent → first writable mount of that kind, else personal fallback", () => { + const mk = (name: string, kind: string, writable: boolean): Mount => ({ name, kind, path: `/v/${name}`, writable }); + const personal = mk("me", "personal", true); + const eng = mk("acme", "engagement", true); + const team = mk("shared", "team", false); // ro + + it("routes to the first writable mount of the intent kind (no route_intent)", () => { + const d = routeNote([personal, eng, team], "engagement"); + expect(d).toEqual({ mount: eng }); + }); + it("falls back to personal and stamps route_intent when the target kind is read-only", () => { + const d = routeNote([personal, team], "team"); + expect(d).toEqual({ mount: personal, routeIntent: "team" }); + }); + it("falls back to personal and stamps route_intent when the target kind is absent", () => { + const d = routeNote([personal], "engagement"); + expect(d).toEqual({ mount: personal, routeIntent: "engagement" }); + }); + it("personal intent routes to the personal mount with no route_intent", () => { + expect(routeNote([personal, team], "personal")).toEqual({ mount: personal }); + }); + it("errors when there is no writable personal mount to fall back to", () => { + const d = routeNote([team], "team"); + expect(d).toMatchObject({ error: expect.stringMatching(/personal/) }); + }); +}); + +describe("routed note rendering helpers", () => { + it("stampToDate slices the stamp's date; slugify kebabs the title", () => { + expect(stampToDate("20260711-013000")).toBe("2026-07-11"); + expect(slugify("A Great Plan!")).toBe("a-great-plan"); + expect(slugify("!!!")).toBe("note"); // empty slug guard + }); + it("routedNoteBasename is --", () => { + expect(routedNoteBasename("plan", "20260711-013000", "My Big Idea")).toBe("plan-20260711-013000-my-big-idea"); + }); + it("renderRoutedNote emits minimal frontmatter; route_intent only when set", () => { + const plain = renderRoutedNote({ type: "spec", title: "Title", body: "prose", stamp: "20260711-013000" }); + expect(plain).toMatch(/^---\ntype: spec\ndate: 2026-07-11\nsession_id: null\ntags: \[spec\]\n---/); + expect(plain).not.toMatch(/route_intent/); + expect(plain).toMatch(/# Title\n\nprose/); + const rerouted = renderRoutedNote({ type: "note", title: "T", body: "b", stamp: "20260711-013000", route_intent: "team" }); + expect(rerouted).toMatch(/route_intent: team/); + }); + it("ROUTE_FOLDERS / isRoutableType map types to folders and exclude experiment", () => { + expect(ROUTE_FOLDERS).toMatchObject({ spec: "specs", plan: "plans", insight: "insights", method: "methods", note: "notes", hopper: "hopper" }); + expect(isRoutableType("plan")).toBe(true); + expect(isRoutableType("experiment")).toBe(false); + }); +}); + // ── verb bodies through the bundle ────────────────────────────────────────────── const BUNDLE = join(__dirname, "..", "dist", "amico.js"); beforeAll(() => { @@ -187,3 +247,106 @@ describe("amico note bump-best (bundle)", () => { expect(r.code).toBe(64); }); }); + +// ── routed generic writer `note route` (bundle) — NEW subcommand, mount-aware ────── +/** Seed a vault dir under `root` with an `.amico-vault.toml` marker. */ +function seedMarker(root: string, name: string, kind: string): string { + const dir = join(root, name); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, ".amico-vault.toml"), `kind = "${kind}"\nname = "${name}"\n`); + return dir; +} +/** Fixture env pointing the spawned CLI at a multi-mount root (Task 6 seam). */ +function mountEnv(root: string): Record { + return { AMICO_VAULTS_ROOT: root, AMICO_MOUNTS_TOML: join(root, "none.toml"), AMICO_VAULT_DIR: "" }; +} +const STAMP = "20260711-013000"; + +describe("amico note route (bundle)", () => { + it("routes to the writable mount of the intent kind (no route_intent)", () => { + const root = mkdtempSync(join(tmpdir(), "amico-route-")); + seedMarker(root, "me", "personal"); + seedMarker(root, "acme", "engagement"); + const r = run(["note", "route", "--type", "spec", "--intent", "engagement", "--title", "My Spec", "--body", "hello", "--stamp", STAMP], mountEnv(root)); + expect(r.code).toBe(0); + const out = JSON.parse(r.stdout); + expect(out).toMatchObject({ written: true, type: "spec", intent: "engagement", mount: "acme", route_intent: null }); + const file = join(root, "acme", "specs", `spec-${STAMP}-my-spec.md`); + expect(out.path).toBe(file); + const text = readFileSync(file, "utf8"); + expect(text).toMatch(/^---\ntype: spec\ndate: 2026-07-11/); + expect(text).not.toMatch(/route_intent/); + rmSync(root, { recursive: true, force: true }); + }); + + it("team intent with a read-only team mount → personal fallback + route_intent: team", () => { + const root = mkdtempSync(join(tmpdir(), "amico-route-")); + seedMarker(root, "me", "personal"); + seedMarker(root, "shared", "team"); // ro by default + const r = run(["note", "route", "--type", "note", "--intent", "team", "--title", "Notice", "--body", "b", "--stamp", STAMP], mountEnv(root)); + expect(r.code).toBe(0); + const out = JSON.parse(r.stdout); + expect(out).toMatchObject({ written: true, mount: "me", route_intent: "team" }); + const text = readFileSync(join(root, "me", "notes", `note-${STAMP}-notice.md`), "utf8"); + expect(text).toMatch(/route_intent: team/); + rmSync(root, { recursive: true, force: true }); + }); + + it("missing personal mount → error JSON, exit 64", () => { + const root = mkdtempSync(join(tmpdir(), "amico-route-")); + seedMarker(root, "shared", "team"); // no personal mount to fall back to + const r = run(["note", "route", "--type", "note", "--title", "x", "--body", "y", "--stamp", STAMP], mountEnv(root)); + expect(r.code).toBe(64); + expect(JSON.parse(r.stdout).error).toMatch(/personal/); + rmSync(root, { recursive: true, force: true }); + }); + + it("--type experiment is rejected with a pointer to `note write`", () => { + const root = mkdtempSync(join(tmpdir(), "amico-route-")); + seedMarker(root, "me", "personal"); + const r = run(["note", "route", "--type", "experiment", "--title", "x", "--body", "y"], mountEnv(root)); + expect(r.code).toBe(64); + const out = JSON.parse(r.stdout); + expect(out.error).toMatch(/experiment/); + expect(JSON.stringify(out)).toMatch(/note write/); + rmSync(root, { recursive: true, force: true }); + }); + + it("folder + filename follow the explicit type→folder map and -- naming", () => { + const root = mkdtempSync(join(tmpdir(), "amico-route-")); + seedMarker(root, "me", "personal"); + const r = run(["note", "route", "--type", "plan", "--title", "Great Plan!", "--body", "b", "--stamp", STAMP], mountEnv(root)); + expect(r.code).toBe(0); + const out = JSON.parse(r.stdout); + expect(out.path).toBe(join(root, "me", "plans", `plan-${STAMP}-great-plan.md`)); + expect(existsSync(out.path)).toBe(true); + rmSync(root, { recursive: true, force: true }); + }); + + it("--commit stages + commits in a git-repo mount", () => { + const root = mkdtempSync(join(tmpdir(), "amico-route-")); + const me = seedMarker(root, "me", "personal"); + execFileSync("git", ["-C", me, "init", "-q"]); + execFileSync("git", ["-C", me, "config", "user.email", "t@example.com"]); + execFileSync("git", ["-C", me, "config", "user.name", "Test"]); + const r = run(["note", "route", "--type", "note", "--title", "Committed", "--body", "b", "--stamp", STAMP, "--commit"], mountEnv(root)); + expect(r.code).toBe(0); + const out = JSON.parse(r.stdout); + expect(out).toMatchObject({ written: true, commit: { committed: true } }); + const log = execFileSync("git", ["-C", me, "log", "--oneline"], { encoding: "utf8" }); + expect(log).toMatch(new RegExp(`note-${STAMP}-committed`)); + rmSync(root, { recursive: true, force: true }); + }); + + it("--commit into a non-repo mount → warning, note still written, exit 0", () => { + const root = mkdtempSync(join(tmpdir(), "amico-route-")); + seedMarker(root, "me", "personal"); // not a git repo + const r = run(["note", "route", "--type", "note", "--title", "NoRepo", "--body", "b", "--stamp", STAMP, "--commit"], mountEnv(root)); + expect(r.code).toBe(0); + const out = JSON.parse(r.stdout); + expect(out.written).toBe(true); + expect(out.commit.committed).toBe(false); + expect(out.commit.warning).toMatch(/git/i); + rmSync(root, { recursive: true, force: true }); + }); +});