diff --git a/packages/extension/opencode-plugin/stack_state.ts b/packages/extension/opencode-plugin/stack_state.ts index 777767d9..be86dc66 100644 --- a/packages/extension/opencode-plugin/stack_state.ts +++ b/packages/extension/opencode-plugin/stack_state.ts @@ -221,39 +221,89 @@ function buildActiveProblemBlock(): string { return desc; } -/** Compact live-runs block: one-line-per-run with status. */ +/** A run "solving" with no FINISHED marker this many hours after its + * timestamped id started is a ZOMBIE (crashed server, killed process) — + * report it as stale, never as live work. Run ids are rYYYYMMDD-HHMMSSZ-hash. */ +const SOLVING_STALE_HOURS = 12; + +/** Age in hours parsed from the run id's timestamp, else undefined. */ +function runAgeHours(runName: string): number | undefined { + const m = runName.match(/^r(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})Z-/); + if (!m) return undefined; + const t = Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]); + if (Number.isNaN(t)) return undefined; + return (Date.now() - t) / 3_600_000; +} + +/** Compact live-runs block: LIVE runs individually (with age), zombies called + * out as stale, and the finished backlog as ONE summary line (count + best + * F + latest). The full history lives in the runs dir — 120 individually + * listed finished runs drowned exactly the signal the greeting needs. */ function buildLiveRunsBlock(): string { const root = runsRoot(); - const lines: string[] = []; try { if (!fs.existsSync(root)) return ""; + const live: string[] = []; + const stale: string[] = []; + const done: { name: string; lab: string; fidelity: number | undefined }[] = []; + const labs = fs.readdirSync(root, { withFileTypes: true }).filter((d) => d.isDirectory()); for (const lab of labs) { const labDir = path.join(root, lab.name); const runs = fs.readdirSync(labDir, { withFileTypes: true }).filter((d) => d.isDirectory()); for (const run of runs) { const runDir = path.join(labDir, run.name); - const solved = !fs.existsSync(path.join(runDir, "FINISHED")); - let fidelity: string | undefined; + const finished = fs.existsSync(path.join(runDir, "FINISHED")); + let fidelity: number | undefined; + let fidelityStr: string | undefined; const resultPath = path.join(runDir, "result.toml"); if (fs.existsSync(resultPath)) { try { const content = fs.readFileSync(resultPath, "utf8"); const m = content.match(/fidelity\s*=\s*([\d.eE+-]+)/); - if (m) fidelity = parseFloat(m[1]).toFixed(6); + if (m) { + fidelity = parseFloat(m[1]); + fidelityStr = fidelity.toFixed(6); + } } catch { /* skip */ } } - const status = solved ? "solving" : fidelity ? `done (F=${fidelity})` : "done"; - lines.push(`- ${run.name} @ ${lab.name}: ${status}`); + if (finished) { + done.push({ name: run.name, lab: lab.name, fidelity }); + } else { + const ageH = runAgeHours(run.name); + if (ageH !== undefined && ageH > SOLVING_STALE_HOURS) { + const since = run.name.slice(1, 9).replace(/^(\d{4})(\d{2})(\d{2})$/, "$1-$2-$3"); + stale.push(`- ${run.name} @ ${lab.name}: STALE — no FINISHED since ${since} (probably dead, do not present as live)`); + } else { + const age = ageH !== undefined ? (ageH < 1 ? `${Math.max(1, Math.round(ageH * 60))}m` : `${Math.round(ageH)}h`) : "age unknown"; + live.push(`- ${run.name} @ ${lab.name}: solving (${age} old)`); + } + } + } + } + + const lines: string[] = []; + if (live.length > 0 || stale.length > 0 || done.length > 0) { + lines.push("**live runs**"); + lines.push(...live, ...stale); + if (done.length > 0) { + const best = done.reduce((acc, d) => (d.fidelity !== undefined && (acc === undefined || d.fidelity > acc) ? d.fidelity : acc), undefined); + const withF = done.filter((d) => d.fidelity !== undefined); + const latest = done[done.length - 1]; + const bits = [ + `${done.length} finished`, + best !== undefined ? `best F=${best.toFixed(6)}` : null, + withF.length > 0 ? `latest ${latest.name}${latest.fidelity !== undefined ? ` (F=${latest.fidelity.toFixed(6)})` : ""}` : null, + ].filter(Boolean); + lines.push(`- backlog: ${bits.join(" · ")} — full history in the runs dir`); } } + return lines.length > 0 ? lines.join("\n") : ""; } catch { return ""; // optional — silent on error } - - return lines.length > 0 ? "**live runs**\n" + lines.join("\n") : ""; } // ── Fleet state ────────────────────────────────────────────────────────────── diff --git a/packages/extension/package.json b/packages/extension/package.json index 4c1c7218..23f3ac80 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -2,7 +2,7 @@ "name": "amicode", "displayName": "Amicode", "description": "Open autonomous research in VS Code \u2014 vaults, fleet, and live solves for quantum control and physical intelligence.", - "version": "0.2.4", + "version": "0.2.5", "publisher": "harmoniqs", "license": "Apache-2.0", "icon": "media/icon.png", diff --git a/packages/extension/src/scores/router.ts b/packages/extension/src/scores/router.ts index 97bd938a..2c52fc01 100644 --- a/packages/extension/src/scores/router.ts +++ b/packages/extension/src/scores/router.ts @@ -2,7 +2,12 @@ import { Score } from "./loader"; // The onset router — a meta question-tree over the visible repertoire (spec §5). // Pure: the caller filters by entitlement first. Score #0 (pulse-designer) renders -// as the fixed "Start from a system" option, never as an application entry card. +// as the "Design a new pulse" option, never as an application entry card. The +// returning-user branch is STATE-AWARE BY INSTRUCTION: the live stack state +// (amicode_context plugin) carries the active problem, the campaign-ledger +// pointer, and the fleet line, and the model composes the actual option list +// from it — this text pins the shape and the question-tool mandate, not the +// per-user content. const SYSTEM_FIRST_SCORE = "pulse-designer"; export function buildRouterSection(visible: Score[]): string { @@ -10,13 +15,27 @@ export function buildRouterSection(visible: Score[]): string { const lines: string[] = [ "## Onset router", "", - "When a session opens without a specific request, after your one-line Amico", - 'intro ask exactly one question — "What do you want to do today?" — via the', - "native `question` tool, with these options:", + "When a session opens without a specific request (a greeting, \"who are", + "you?\", \"what is this?\"), do NOT default to the pulse-designer interview —", + "build the moment from the live state. After your one-line Amico intro (name from", + "the profile when one is recorded), ask exactly ONE question —", + "\"What do you want to do today?\" — via the native `question` tool, composing", + "the options from what the live state actually shows:", + "", + "- **Resume the active problem** — ONLY when the stack state shows one; name it and where it stands (system ✓ / formulation ✓ / mid-solve).", + "- **Resume your research campaign** — ONLY when a session ledger exists under the personal vault's `sessions/`; the autoresearch director re-reads the latest ledger and continues the loop.", + `- **Design a new pulse** — the \`${SYSTEM_FIRST_SCORE}\` interview (the platform-first interview below); one path among these, never the default.`, + "- **Fleet & studio ops** — ONLY when fleet state is present; status digest, sync rituals, healthcheck.", + "- **Bring your own problem** — papers, notes, or a graph file; extract candidate entities, confirm each one before recording, then join the best-matching score mid-path.", + "- **Just explore** — free-form; no rail.", "", ]; if (cards.length > 0) { - lines.push("**Start from an application** — offer these entry cards:", ""); + lines.push( + "First run (no profile recorded): replace the two resume options and the", + "fleet option with the application entry cards:", + "", + ); for (const s of cards) { const m = s.manifest; const badge = m.device ? (m.device.qpu_runnable ? "QPU-runnable" : "emulator-only") : ""; @@ -26,18 +45,10 @@ export function buildRouterSection(visible: Score[]): string { lines.push(""); } lines.push( - `**Start from a system** — run the pack's \`${SYSTEM_FIRST_SCORE}\` onboarding interview (the platform-first interview below); it is one path among these, not the spine.`, - "", - "**Bring your own problem** — the user has papers, notes, or a graph file;", - "extract candidate entities, confirm each one before recording, then join the", - "best-matching score mid-path. If nothing usable is found, say so and offer", - "the other options — never a dead end. If candidates match multiple scores", - "equally, ask; never route by silent heuristic.", - "", - "**Resume where you left off** — read the session's interview state and", - "continue from its stage cursor.", - "", - "**Just explore** — free-form; no interview rail.", + "Never a dead end: if nothing usable is found for an option, say so and offer", + "the others. If candidates match multiple paths equally, ask — never route by", + "silent heuristic. A user who opens with a specific ask (\"X gate, 10 ns,", + "defaults\") skips the question entirely and gets straight to it.", ); return lines.join("\n"); } diff --git a/packages/extension/test/scores/entitlements_router.test.ts b/packages/extension/test/scores/entitlements_router.test.ts index bfce05be..190b67f9 100644 --- a/packages/extension/test/scores/entitlements_router.test.ts +++ b/packages/extension/test/scores/entitlements_router.test.ts @@ -86,14 +86,15 @@ describe("buildRouterSection", () => { it("renders the onset question with fixed options", () => { const md = buildRouterSection([pub]); expect(md).toContain("What do you want to do today?"); - expect(md).toContain("Start from a system"); + expect(md).toContain("Design a new pulse"); expect(md).toContain("Bring your own problem"); - expect(md).toContain("Resume where you left off"); + expect(md).toContain("Resume the active problem"); + expect(md).toContain("Resume your research campaign"); expect(md).toContain("Just explore"); }); it("pulse-designer is the fixed system option, NOT an entry card", () => { const md = buildRouterSection([pub, gated]); - const cardBlock = md.slice(md.indexOf("Start from an application")); + const cardBlock = md.slice(md.indexOf("application entry cards")); expect(cardBlock).toContain("pasqal-mis"); // score #0 must not be duplicated as an application entry card expect(md.indexOf("Name of pulse-designer")).toBe(-1); @@ -106,7 +107,7 @@ describe("buildRouterSection", () => { }); it("no application scores → no empty entry-card section", () => { const md = buildRouterSection([pub]); - expect(md).not.toContain("Start from an application"); + expect(md).not.toContain("application entry cards"); }); it("is deterministic", () => { expect(buildRouterSection([pub, gated])).toBe(buildRouterSection([pub, gated])); diff --git a/packages/extension/test/scores/golden/router-section.md b/packages/extension/test/scores/golden/router-section.md index d76d2327..fca1bef2 100644 --- a/packages/extension/test/scores/golden/router-section.md +++ b/packages/extension/test/scores/golden/router-section.md @@ -1,23 +1,26 @@ ## Onset router -When a session opens without a specific request, after your one-line Amico -intro ask exactly one question — "What do you want to do today?" — via the -native `question` tool, with these options: +When a session opens without a specific request (a greeting, "who are +you?", "what is this?"), do NOT default to the pulse-designer interview — +build the moment from the live state. After your one-line Amico intro (name from +the profile when one is recorded), ask exactly ONE question — +"What do you want to do today?" — via the native `question` tool, composing +the options from what the live state actually shows: -**Start from an application** — offer these entry cards: +- **Resume the active problem** — ONLY when the stack state shows one; name it and where it stands (system ✓ / formulation ✓ / mid-solve). +- **Resume your research campaign** — ONLY when a session ledger exists under the personal vault's `sessions/`; the autoresearch director re-reads the latest ledger and continues the loop. +- **Design a new pulse** — the `pulse-designer` interview (the platform-first interview below); one path among these, never the default. +- **Fleet & studio ops** — ONLY when fleet state is present; status digest, sync rituals, healthcheck. +- **Bring your own problem** — papers, notes, or a graph file; extract candidate entities, confirm each one before recording, then join the best-matching score mid-path. +- **Just explore** — free-form; no rail. + +First run (no profile recorded): replace the two resume options and the +fleet option with the application entry cards: - `overture` — **Welcome — let's set up your studio**: A profile Amico remembers: who you are and what you want to do · 2–3 min, then into your first task - `pasqal-mis` — **Solve a graph problem on a Pasqal atom array**: An optimized adiabatic waveform solving YOUR graph's MIS, validated on an emulator · 60–90 min · QPU-runnable -**Start from a system** — run the pack's `pulse-designer` onboarding interview (the platform-first interview below); it is one path among these, not the spine. - -**Bring your own problem** — the user has papers, notes, or a graph file; -extract candidate entities, confirm each one before recording, then join the -best-matching score mid-path. If nothing usable is found, say so and offer -the other options — never a dead end. If candidates match multiple scores -equally, ask; never route by silent heuristic. - -**Resume where you left off** — read the session's interview state and -continue from its stage cursor. - -**Just explore** — free-form; no interview rail. \ No newline at end of file +Never a dead end: if nothing usable is found for an option, say so and offer +the others. If candidates match multiple paths equally, ask — never route by +silent heuristic. A user who opens with a specific ask ("X gate, 10 ns, +defaults") skips the question entirely and gets straight to it. \ No newline at end of file diff --git a/packages/extension/test/stack_state.test.ts b/packages/extension/test/stack_state.test.ts index 4b659753..42dc4258 100644 --- a/packages/extension/test/stack_state.test.ts +++ b/packages/extension/test/stack_state.test.ts @@ -300,12 +300,84 @@ describe("caps + composition", () => { }); }); +// ── Live-runs block: live/stale/summary ────────────────────────────────────── + +describe("buildLiveRunsBlock (live individually, zombies flagged, backlog summarized)", () => { + function mkRun(lab: string, name: string, opts: { finished?: boolean; fidelity?: number } = {}): void { + const dir = path.join(lab, name); + fs.mkdirSync(dir, { recursive: true }); + if (opts.finished) fs.writeFileSync(path.join(dir, "FINISHED"), ""); + if (opts.fidelity !== undefined) { + fs.writeFileSync(path.join(dir, "result.toml"), `fidelity = ${opts.fidelity}\n`); + } + } + function runsBlockWith(runs: (labDir: string) => void): string { + const root = mkTmp("runs-"); + const lab = path.join(root, "default"); + fs.mkdirSync(lab, { recursive: true }); + runs(lab); + const stubs = stubAllSeams({ runsDir: root }); + try { + const block = buildStackStateBlock() ?? ""; + const m = block.match(/\*\*live runs\*\*[\s\S]*?(?=\n\n|$)/); + return m ? m[0] : ""; + } finally { + restoreSeams(stubs); + } + } + const now = new Date(); + const stamp = (hoursAgo: number): string => { + const t = new Date(now.getTime() - hoursAgo * 3_600_000); + const p = (n: number, w = 2) => String(n).padStart(w, "0"); + return `r${t.getUTCFullYear()}${p(t.getUTCMonth() + 1)}${p(t.getUTCDate())}-${p(t.getUTCHours())}${p(t.getUTCMinutes())}${p(t.getUTCSeconds())}Z-x`; + }; + + it("a fresh unfinished run is LIVE with its age; a days-old one is STALE, never 'solving'", () => { + const s = runsBlockWith((lab) => { + mkRun(lab, stamp(0.2)); // 12 min ago + mkRun(lab, stamp(24 * 8)); // 8 days ago — zombie + }); + expect(s).toContain("solving ("); + expect(s).toMatch(/STALE — no FINISHED since \d{4}-\d{2}-\d{2}/); + expect(s).toContain("do not present as live"); + // the zombie line must NOT read as solving + const zombieLine = s.split("\n").find((l) => l.includes("STALE")); + expect(zombieLine).not.toContain("solving"); + }); + it("finished runs collapse to ONE backlog line: count, best F, latest", () => { + const s = runsBlockWith((lab) => { + mkRun(lab, stamp(30), { finished: true, fidelity: 0.999 }); + mkRun(lab, stamp(20), { finished: true, fidelity: 0.999979 }); + mkRun(lab, stamp(10), { finished: true, fidelity: 0.99 }); + mkRun(lab, stamp(5), { finished: true }); // finished, no result.toml + }); + const backlog = s.split("\n").filter((l) => l.startsWith("- backlog:")); + expect(backlog.length).toBe(1); + expect(backlog[0]).toContain("4 finished"); + expect(backlog[0]).toContain("best F=0.999979"); + expect(backlog[0]).toContain("full history in the runs dir"); + // no individually listed done runs + expect(s.split("\n").filter((l) => l.startsWith("- ") && /: done/.test(l)).length).toBe(0); + }); + it("no runs at all → no live-runs section", () => { + const root = mkTmp("runs-"); + fs.mkdirSync(path.join(root, "default"), { recursive: true }); + const stubs = stubAllSeams({ runsDir: root }); + try { + expect(buildStackStateBlock() ?? "").not.toContain("**live runs**"); + } finally { + restoreSeams(stubs); + } + }); +}); + // ── Env-seam plumbing ──────────────────────────────────────────────────────── interface SeamOpts { vaultsRoot?: string; fleetConfig?: string; fleetStatus?: string; + runsDir?: string; /** Prebuilt fixture vault flavor for the golden-text cases. */ vault?: "profile" | "knowledge" | "demos" | "memory"; } @@ -364,7 +436,7 @@ function stubAllSeams(opts: SeamOpts): Record { process.env.AMICODE_OPS_DIR = ops; // no solver-mode.json → piccolo/ready → no section process.env.AMICODE_CONNECTIONS_FILE = path.join(conn, "absent.json"); // not connected process.env.AMICODE_PROBLEMS_DIR = problems; // no active problem - process.env.AMICODE_RUNS_DIR = runs; // no runs + process.env.AMICODE_RUNS_DIR = opts.runsDir ?? runs; // no runs unless a fixture is passed return saved; }