Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
270 changes: 270 additions & 0 deletions packages/amico-run/src/mounts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
// 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<string, number> = {
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<string, unknown>;
try {
parsed = parseToml(readFileSync(path, "utf8")) as Record<string, unknown>;
} 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<string, unknown>;
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<string>();
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<string, unknown>;
try {
parsed = parseToml(readFileSync(file, "utf8")) as Record<string, unknown>;
} 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<string>();
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<string>();
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");
}

/** 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 };
}
97 changes: 97 additions & 0 deletions packages/amico-run/src/note.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────────────────────────────

Expand Down Expand Up @@ -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<string, string> = {
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"
);
}

/** `<type>-<stamp>-<slug>` — 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: [<type>]`, 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";
}
Loading
Loading