diff --git a/packages/extension/AGENTS.md b/packages/extension/AGENTS.md index 73093f03..1cf41ba8 100644 --- a/packages/extension/AGENTS.md +++ b/packages/extension/AGENTS.md @@ -1,5 +1,12 @@ # Amicode project context +## Identity + +You are **Amico** — Amicode's pulse-design copilot. You are NOT "opencode": +opencode is the engine underneath, **Amicode** is the product, **Amico** is you. +If asked who or what you are, answer in one line — "I'm Amico — Amicode's +pulse-design copilot" — and never describe yourself as an interactive CLI tool. + You help a quantum-control researcher synthesize optimal-control pulses with Piccolo (Julia) without leaving VS Code. You author a Julia script, run it, and the Run Inspector renders the live solve. @@ -33,8 +40,73 @@ and the Run Inspector renders the live solve. F ≥ 0.99 — don't ask. If asked for the result later, read the latest run's `FINISHED` + `result.toml` under `~/.amico/runs///`. -There is **no MCP server**. The only tool is `amico-run` via bash. -`amico-run --help` prints usage. +There is **no MCP server**. The solve runs through `amico-run` via bash; the +`amicode_*` tools below (when present) record design state — they never replace +the bash launch. `amico-run --help` prints usage. + +## Pulse-designer interview + +**Scope rule:** run this interview when you are the **pulse-designer** agent, +when the user asks to be walked through designing a pulse, — and **proactively**: +if a session opens with a greeting or no specific request ("hello", "who are +you?", "what is this?"), introduce yourself as Amico in one line and ask the +stage-1 PLATFORM question. If the user already knows their parameters ("X gate, +10 ns, defaults"), **skip straight to the workflow above** — never force the +interview on someone with a specific ask. The user can say "fast-forward" at +any stage to jump to defaults. + +**Protocol: ONE question at a time.** Never batch questions. Ask, wait, record, +advance. After each answer, record the stage's state: call the matching +`amicode_*` tool if it is available; if not, summarize the recorded values in +one line and continue (the tools record entities — System, Formulation, Run — +they are bookkeeping, not gates). + +**Buttons for choices:** when a stage's answer is a small option set (PLATFORM; +simulate-vs-solve; gate synthesis vs state prep; which gate), ask it via +`amicode_ask` (question + 2–6 options) — the chat renders the options as +buttons and the user's click arrives as their next message. Free-form values +($\omega$, $\delta$, `T`, `N`, `max_iter`) stay plain-text questions. If +`amicode_ask` is unavailable, ask in plain text with the options listed. + +Stages, in order: + +1. **PLATFORM** — "What kind of system are you working with?" (transmon / + neutral-atom Rydberg / other). On answer, show the model Hamiltonian and + confirm it matches their device. Record via `amicode_pick_system`. + - transmon (fully supported end-to-end tonight): + $\hat H/\hbar = \omega\,\hat a^\dagger\hat a + \tfrac{\delta}{2}\,\hat a^{\dagger 2}\hat a^2 + u_1(t)\,(\hat a + \hat a^\dagger) + i\,u_2(t)\,(\hat a - \hat a^\dagger)$ + - Rydberg 3-level ($|0\rangle$ dark, $|1\rangle\!\leftrightarrow\!|r\rangle$ driven, + blockade on $|rr\rangle$): show the form, record the System entity honestly as + `platform = "rydberg"` — then say plainly that this build's vetted template is + transmon-only and Rydberg solve authoring is not wired yet; offer to record the + formulation for follow-up instead of guessing at an unvetted script. +2. **MODEL** — levels (default 3; warn at 5+ per the guidance below), drive + parameterization + `drive_max`. Convention: **`T` = scalar gate time (ns), + `N` = number of timesteps** — never conflate them. Record via `amicode_set_model`. +3. **MODE** — simulate first, or straight to solve? Warm start available? + (If yes: the warm-start idiom below, `load_traj`.) +4. **PROBLEM** — gate synthesis vs state prep; the target (X, Y, Z, H, S, T, + √X, or an arbitrary single-qubit unitary — multi-qubit is out of scope, per + the scope section). +5. **FORMULATION** — objective and constraints. The vetted template optimizes + unitary infidelity under the amplitude bound `drive_max`; record any further + objectives/constraints the user wants in the Formulation entity as follow-ups + — do not improvise unvetted physics into the script. **Never silently + co-optimize global model parameters** (frequencies, anharmonicities) — if + the user wants that, it's a recorded follow-up, not a tonight-edit. Record via + `amicode_formulate`. +6. **SOLVE PARAMS** — `T`, `N`, `max_iter` (defaults per the regime guidance + below), then author `solve.jl` from the vetted template ({{TEMPLATE_PATH}}) + and launch it detached per the workflow above (`amico-run` via bash — the + `amicode_solve` tool, when available, records the Run entity; the bash + launch is still the mechanism). +7. **INSPECT** — the Run Inspector opens itself and streams the live pulse; + after `FINISHED`, report `fidelity` from `result.toml`. +8. **HARDWARE / CALIBRATE** — guided stubs tonight: explain the send-to-device + gate (fidelity + amplitude/bandwidth checks, then human sign-off) and the + calibration loop that follows; record interest via `amicode_to_hardware` and + `amicode_calibrate` (bookkeeping stubs — they perform NO device I/O), set no + expectations of device I/O in this build. ## Scope & parameter guidance diff --git a/packages/extension/opencode-plugin/amicode_tools.ts b/packages/extension/opencode-plugin/amicode_tools.ts new file mode 100644 index 00000000..6b3d4319 --- /dev/null +++ b/packages/extension/opencode-plugin/amicode_tools.ts @@ -0,0 +1,430 @@ +// ============================================================================ +// amicode_* tool pack v0 — an opencode PLUGIN, not extension-bundle code. +// +// RUNTIME: this file executes inside opencode's embedded Bun runtime. It is +// registered by ABSOLUTE PATH via OPENCODE_CONFIG_CONTENT `plugin: [""]` +// (built in ../src/opencode_config.ts) and imported by the binary's plugin +// loader with a bare dynamic `import()` — Bun transpiles TS natively, so the +// relative `./entities` sibling import below resolves; nothing else does. +// Keep this module dependency-free (node: builtins + ./entities only) and it +// must have EXACTLY ONE export: opencode 1.17.3's legacy-plugin scan +// (plugin/index.ts getLegacyPlugins) throws "Plugin export is not a function" +// on any extra named export. It is deliberately OUTSIDE the extension's +// tsconfig include and vitest graph; its pure logic lives in ./entities.ts, +// which IS unit-tested (test/amicode_tools.test.ts). +// +// T8 REGISTRATION DECISION (probed on the stock vendored binary v1.17.3): +// chosen: OPENCODE_CONFIG_CONTENT carrying BOTH +// - `agent: {"pulse-designer": {description, prompt}}` → shows in GET /agent +// - `plugin: ["/abs/path/amicode_tools.ts"]` → module executes on +// session creation (plugin_origins lists source OPENCODE_CONFIG_CONTENT) +// fallback (if a future binary drops either): instructions-only interview — +// AGENTS.md already tells the agent to summarize each stage in one line when +// the amicode_* tools are absent, and the solve launch is ALWAYS the bash +// `amico-run` workflow. The tools are bookkeeping, not gates. +// +// ARGS-SCHEMA DECISION: plain JSON-Schema property objects, validated inside +// execute(). Rationale (from the v1.17.3 source, tool/registry.ts fromPlugin): +// - if every `args` value is a Zod type it uses z.object(...); the only zod +// the loader accepts is zod v4 (`"_zod" in value`) and the sanctioned way +// to get it is `tool.schema` from @opencode-ai/plugin — which is NOT a +// dependency of this repo and MUST NOT become one (the binary can't be +// assumed to resolve npm imports from this directory). +// - otherwise `legacyJsonSchema` treats each value as a raw JSON-Schema +// property definition: {type:"object", properties, required: ALL keys}, +// and server-side validation is skipped (parameters = Schema.Unknown). +// Consequences we design for: every declared arg is REQUIRED in the schema +// the LLM sees, so optional args are declared nullable ("pass null to skip") +// and all real validation happens in execute() via ./entities validators. +// +// STATE: entities are written under entitiesDir(): +// $AMICODE_ENTITIES_DIR if set, else ~/.amico/runs/default/_entities +// system.json is a machine-readable sidecar of system.toml — the merge source +// for amicode_set_model (this module is TOML-writer-only; it carries no TOML +// parser, and won't grow one). The Run stub (run.toml here) is bookkeeping — +// NOT the run-dir run.toml that amico-run writes. +// +// TODO(follow-up): extension.ts should pass the plugin path explicitly to +// buildOpencodeConfigContent once packaging (.vsix layout) is verified; today +// the default path is derived from __dirname in opencode_config.ts. +// ============================================================================ + +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { + systemToml, + formulationToml, + runStubToml, + deviceSessionStubToml, + calibrationStubToml, + updateSystem, + validateSystem, + validateFormulation, + PLATFORMS, + type SystemEntity, + type FormulationEntity, + type RunStub, + type DeviceSessionStub, + type CalibrationStub, +} from "./entities"; +import { guardAndRecordStage, completeStage } from "./score_guard"; + +// Load line goes to STDERR, not stdout: `opencode debug config` imports plugin +// modules before printing the resolved config as JSON on stdout (verified on +// v1.17.3) — a stdout log here corrupts that JSON and breaks any caller that +// parses it (test/opencode_config.test.ts does). stderr still lands in the +// serve log, which is where the load line is grepped for. +console.error("[amicode-tools] loaded — amicode_* tool pack v0 (entities → " + entitiesDir() + ")"); + +function entitiesDir(): string { + const env = process.env.AMICODE_ENTITIES_DIR; + if (env && env.trim() !== "") return env; + return path.join(os.homedir(), ".amico", "runs", "default", "_entities"); +} + +function writeEntity(name: string, content: string): string { + const dir = entitiesDir(); + fs.mkdirSync(dir, { recursive: true }); + const file = path.join(dir, name); + fs.writeFileSync(file, content, "utf8"); + return file; +} + +/** null/undefined → absent (the schema forces the LLM to pass every key, so + * "not applicable" arrives as null — see the args-schema decision above). */ +function given(v: T | null | undefined): v is T { + return v !== null && v !== undefined; +} + +function readSystemState(): SystemEntity | undefined { + const file = path.join(entitiesDir(), "system.json"); + if (!fs.existsSync(file)) return undefined; + try { + return JSON.parse(fs.readFileSync(file, "utf8")) as SystemEntity; + } catch { + return undefined; + } +} + +function persistSystem(e: SystemEntity): string { + const tomlPath = writeEntity("system.toml", systemToml(e)); + writeEntity("system.json", JSON.stringify(e, null, 2) + "\n"); + return tomlPath; +} + +function paramsSummary(params: Record): string { + const entries = Object.entries(params); + if (entries.length === 0) return "no params recorded"; + return entries.map(([k, v]) => `${k}=${v}`).join(", "); +} + +// LaTeX shown at the PLATFORM stage — kept verbatim in sync with AGENTS.md's +// "Pulse-designer interview" section (the agent renders these in chat). +const TRANSMON_LATEX = String.raw`$\hat H/\hbar = \omega\,\hat a^\dagger\hat a + \tfrac{\delta}{2}\,\hat a^{\dagger 2}\hat a^2 + u_1(t)\,(\hat a + \hat a^\dagger) + i\,u_2(t)\,(\hat a - \hat a^\dagger)$`; +const RYDBERG_DESC = String.raw`3-level ladder: $|0\rangle$ dark, $|1\rangle\!\leftrightarrow\!|r\rangle$ laser-driven, blockade shift on $|rr\rangle$`; +const RYDBERG_SCOPE_NOTE = + "Honest scope note: this build's vetted solve template is transmon-only — " + + "Rydberg solve authoring is not wired yet. The System entity is recorded so the " + + "formulation can be captured for follow-up; don't improvise an unvetted script."; + +// The plugin: exactly one export (see header). opencode calls it on session +// creation with PluginInput; we need nothing from it today. +export const AmicodeTools = async (_input: unknown) => ({ + tool: { + amicode_ask: { + description: + "Present ONE multiple-choice question to the user as clickable buttons in the Amicode chat. " + + "Use for interview stages with a small option set (platform, sim-vs-solve, problem/gate). " + + "The user's next message is their answer (a button click sends the option text verbatim). " + + "End your turn after calling this — never answer on the user's behalf.", + args: { + question: { + type: "string", + description: "The single question to ask.", + }, + options: { + type: "array", + items: { type: "string" }, + description: "2-6 short option labels, one per button.", + }, + }, + async execute(a: { question: string; options: string[] }) { + const opts = Array.isArray(a.options) + ? a.options.filter((o) => typeof o === "string" && o.trim() !== "") + : []; + if (!a.question || a.question.trim() === "") return "Cannot ask: empty question."; + if (opts.length < 2 || opts.length > 6) return "Cannot ask: need 2-6 non-empty options."; + // The renderer draws the buttons from this tool part's INPUT args; this + // return text is for the model (and the pre-rail fallback display). + return ( + `Question presented with ${opts.length} option buttons — ` + + `the user's next message is the answer; wait for it.` + ); + }, + }, + amicode_pick_system: { + description: + "Record the chosen platform as the System entity (interview stage 1: PLATFORM). " + + "Returns the model Hamiltonian in LaTeX to show the user for confirmation. " + + "Bookkeeping only — never launches anything.", + args: { + platform: { + type: "string", + enum: [...PLATFORMS], + description: "Device platform the user named.", + }, + omega: { + type: ["number", "null"], + description: "Transmon frequency ω in GHz; pass null if not yet known.", + }, + delta: { + type: ["number", "null"], + description: "Anharmonicity δ in GHz; pass null if not yet known.", + }, + }, + async execute(a: { platform: string; omega?: number | null; delta?: number | null }) { + const blocked = guardAndRecordStage(entitiesDir(), "platform"); + if (blocked) return blocked; + const params: Record = {}; + if (given(a.omega)) params.omega = a.omega; + if (given(a.delta)) params.delta = a.delta; + const entity: SystemEntity = { platform: a.platform as SystemEntity["platform"], levels: 3, params }; + const problems = validateSystem(entity); + if (problems.length) return `Cannot record system: ${problems.join("; ")}`; + const file = persistSystem(entity); + completeStage(entitiesDir(), "platform"); + if (entity.platform === "transmon") { + return ( + `System recorded (transmon, ${entity.levels} levels, ${paramsSummary(params)}) → ${file}\n\n` + + `Model Hamiltonian:\n${TRANSMON_LATEX}\n\n` + + `Show this to the user and confirm it matches their device.` + ); + } + return ( + `System recorded (rydberg, ${entity.levels} levels, ${paramsSummary(params)}) → ${file}\n\n` + + `Model: ${RYDBERG_DESC}\n\n${RYDBERG_SCOPE_NOTE}` + ); + }, + }, + + amicode_set_model: { + description: + "Merge model details (interview stage 2: MODEL) into the recorded System entity: " + + "levels, drive_max, and any extra named numeric parameters. Requires " + + "amicode_pick_system to have run first. Bookkeeping only.", + args: { + levels: { + type: ["integer", "null"], + description: "Number of transmon levels to model (2–6, default 3); null to leave unchanged.", + }, + drive_max: { + type: ["number", "null"], + description: "Drive amplitude bound (GHz); null to leave unchanged.", + }, + params: { + type: ["object", "null"], + additionalProperties: { type: "number" }, + description: "Extra named numeric model parameters to merge (e.g. {\"T1\": 80}); null for none.", + }, + }, + async execute(a: { levels?: number | null; drive_max?: number | null; params?: Record | null }) { + const blocked = guardAndRecordStage(entitiesDir(), "model"); + if (blocked) return blocked; + const existing = readSystemState(); + if (!existing) return "No system recorded yet — call amicode_pick_system first (interview stage 1)."; + const patchParams: Record = { ...(given(a.params) ? a.params : {}) }; + if (given(a.drive_max)) patchParams.drive_max = a.drive_max; + try { + const merged = updateSystem(existing, { + levels: given(a.levels) ? a.levels : undefined, + params: patchParams, + }); + const file = persistSystem(merged); + completeStage(entitiesDir(), "model"); + return `System updated (${merged.platform}, ${merged.levels} levels, ${paramsSummary(merged.params)}) → ${file}`; + } catch (err) { + return `Cannot update model: ${err instanceof Error ? err.message : String(err)}`; + } + }, + }, + + amicode_formulate: { + description: + "Record the Formulation entity (interview stages 4–5: PROBLEM + FORMULATION): " + + "problem kind, target, objective, constraints. Bookkeeping only.", + args: { + problem: { + type: "string", + description: "Problem kind: \"gate_synthesis\" or \"state_prep\".", + }, + target: { + type: "string", + description: "The target, e.g. \"X\", \"H\", \"sqrt(X)\", or a description of the unitary/state.", + }, + objective: { + type: ["string", "null"], + description: "Objective; null for the default \"unitary infidelity\".", + }, + constraints: { + type: ["array", "null"], + items: { type: "string" }, + description: "Constraint list; null for the default [\"amplitude bound (drive_max)\"].", + }, + }, + async execute(a: { problem: string; target: string; objective?: string | null; constraints?: string[] | null }) { + const blocked = guardAndRecordStage(entitiesDir(), "formulate"); + if (blocked) return blocked; + const entity: FormulationEntity = { + problem: a.problem, + target: a.target, + objective: given(a.objective) ? a.objective : "unitary infidelity", + constraints: given(a.constraints) ? a.constraints : ["amplitude bound (drive_max)"], + }; + const problems = validateFormulation(entity); + if (problems.length) return `Cannot record formulation: ${problems.join("; ")}`; + const file = writeEntity("formulation.toml", formulationToml(entity)); + completeStage(entitiesDir(), "formulate"); + return ( + `Formulation recorded → ${file}\n` + + `problem: ${entity.problem}; target: ${entity.target}; objective: ${entity.objective}; ` + + `constraints: ${entity.constraints.join(" · ")}` + ); + }, + }, + + amicode_solve: { + description: + "Record the Run entity stub (interview stage 6: SOLVE PARAMS). This tool NEVER " + + "launches a solve — the launch is the AGENTS.md bash workflow (`nohup amico-run …`). " + + "Call this to record that a launch was requested/performed. Bookkeeping, not a gate.", + args: { + run_dir: { + type: ["string", "null"], + description: "The run directory if the bash launch already happened and it is known; else null.", + }, + note: { + type: ["string", "null"], + description: "Short free-text note, e.g. \"X gate, T=10ns, N=50, defaults\"; null for none.", + }, + }, + async execute(a: { run_dir?: string | null; note?: string | null }) { + const dir = entitiesDir(); + const blocked = guardAndRecordStage(dir, "solve"); + if (blocked) return blocked; + const stub: RunStub = {}; + const sysPath = path.join(dir, "system.toml"); + const formPath = path.join(dir, "formulation.toml"); + if (fs.existsSync(sysPath)) stub.system_ref = sysPath; + if (fs.existsSync(formPath)) stub.formulation_ref = formPath; + if (given(a.run_dir)) stub.run_dir = a.run_dir; + if (given(a.note)) stub.note = a.note; + const file = writeEntity("run.toml", runStubToml(stub)); + const missing = [ + ...(stub.system_ref ? [] : ["system (stage 1 skipped?)"]), + ...(stub.formulation_ref ? [] : ["formulation (stages 4–5 skipped?)"]), + ]; + const warn = missing.length ? ` Note: no recorded ${missing.join(" or ")}.` : ""; + completeStage(dir, "solve"); + return ( + `Run entity recorded → ${file} — launch via the workflow's amico-run bash command ` + + `if not already launched.${warn}` + ); + }, + }, + + amicode_to_hardware: { + description: + "Record the DeviceSession entity stub (interview stage 8: HARDWARE — guided stub). " + + "THIS BUILD PERFORMS NO DEVICE I/O: the tool records intent only and returns an " + + "explanation of the send-to-device gate. Bookkeeping, not a gate.", + args: { + pulse_ref: { + type: ["string", "null"], + description: "Path to the solved pulse artifact (pulse.jld2) if known; else null.", + }, + run_dir: { + type: ["string", "null"], + description: "The run directory the pulse came from, if known; else null.", + }, + note: { + type: ["string", "null"], + description: "Short free-text note; null for none.", + }, + }, + async execute(a: { pulse_ref?: string | null; run_dir?: string | null; note?: string | null }) { + const blocked = guardAndRecordStage(entitiesDir(), "hardware"); + if (blocked) return blocked; + const stub: DeviceSessionStub = {}; + if (given(a.pulse_ref)) stub.pulse_ref = a.pulse_ref; + if (given(a.run_dir)) stub.run_dir = a.run_dir; + if (given(a.note)) stub.note = a.note; + let file: string; + try { + file = writeEntity("device_session.toml", deviceSessionStubToml(stub)); + } catch (err) { + return `Cannot record device session: ${err instanceof Error ? err.message : String(err)}`; + } + const warn = stub.pulse_ref || stub.run_dir + ? "" + : " Note: no pulse/run referenced yet — re-record after the solve finishes."; + return ( + `Device session recorded → ${file} (gate: pending-human-signoff).${warn}\n\n` + + `The send-to-device gate, when wired: (1) automated checks — fidelity ≥ threshold, ` + + `|drive| ≤ amplitude cap, bandwidth within hardware limits, leakage bounded; ` + + `(2) a human visually signs off on the pulse before anything is sent. ` + + `THIS BUILD PERFORMS NO DEVICE I/O — intent recorded only; set no expectation of ` + + `hardware execution tonight.` + ); + }, + }, + + amicode_calibrate: { + description: + "Record the Calibration entity stub (interview stage 8: CALIBRATE — guided stub). " + + "The calibration loop is NOT wired in this build: the tool records the follow-up " + + "and returns an explanation of the loop. Bookkeeping, not a gate.", + args: { + device_session_ref: { + type: ["string", "null"], + description: + "Path to the recorded device_session.toml; null to auto-reference the recorded one if present.", + }, + note: { + type: ["string", "null"], + description: "Short free-text note; null for none.", + }, + }, + async execute(a: { device_session_ref?: string | null; note?: string | null }) { + const blocked = guardAndRecordStage(entitiesDir(), "hardware"); + if (blocked) return blocked; + const stub: CalibrationStub = {}; + if (given(a.device_session_ref)) { + stub.device_session_ref = a.device_session_ref; + } else { + // Mirror amicode_solve's auto-ref idiom: point at the recorded device + // session when one exists (existence check only — no TOML parsing here). + const dsPath = path.join(entitiesDir(), "device_session.toml"); + if (fs.existsSync(dsPath)) stub.device_session_ref = dsPath; + } + if (given(a.note)) stub.note = a.note; + let file: string; + try { + file = writeEntity("calibration.toml", calibrationStubToml(stub)); + } catch (err) { + return `Cannot record calibration: ${err instanceof Error ? err.message : String(err)}`; + } + const warn = stub.device_session_ref + ? "" + : " Note: no device session recorded yet — amicode_to_hardware comes first."; + return ( + `Calibration follow-up recorded → ${file} (loop: ILC, status: not-wired).${warn}\n\n` + + `After hardware runs, a calibration loop (ILC — iterative learning control) closes ` + + `the model-device gap: run the pulse, measure, compare against the model's ` + + `prediction, update, repeat until the device matches the design. In this build ` + + `that loop is a recorded follow-up only — nothing is executed tonight.` + ); + }, + }, + }, +}); diff --git a/packages/extension/opencode-plugin/entities.ts b/packages/extension/opencode-plugin/entities.ts new file mode 100644 index 00000000..4747467b --- /dev/null +++ b/packages/extension/opencode-plugin/entities.ts @@ -0,0 +1,249 @@ +// ============================================================================ +// Entity TOML writers for the amicode_* tool pack — pure, dependency-free. +// +// This file is imported from TWO runtimes and must stay import-free (types + +// functions only, no node: builtins, no npm packages): +// 1. opencode's embedded Bun runtime — amicode_tools.ts (the plugin, loaded +// by absolute path via OPENCODE_CONFIG_CONTENT `plugin: [...]`) does +// `import { ... } from "./entities"`; Bun transpiles TS natively and +// resolves the relative sibling, but nothing guarantees npm resolution +// from this directory, so we depend on nothing. +// 2. vitest (test/amicode_tools.test.ts) — round-trips the emitted TOML +// through `smol-toml`, the parser @amicode/schema and the extension use. +// +// Entities live under (see amicode_tools.ts): System and +// Formulation are the interview's durable design state; the Run *stub* records +// that a launch was requested — it is bookkeeping, NOT the run-dir `run.toml` +// that amico-run itself writes (different directory, different schema). +// +// `recorded` is emitted as a QUOTED ISO-8601 string, not a bare TOML datetime: +// smol-toml parses bare datetimes into TomlDate objects (schema/src/index.ts +// has a note on exactly this trap), and downstream consumers want a plain +// string. Serializers throw on invalid entities; validate* return a list of +// human-readable problems so tools can answer the chat without throwing. +// ============================================================================ + +export interface SystemEntity { + platform: "transmon" | "rydberg"; + levels: number; + /** Named physical parameters, e.g. omega/delta (GHz), drive_max. */ + params: Record; +} + +export interface FormulationEntity { + problem: string; + target: string; + objective: string; + constraints: string[]; +} + +export interface RunStub { + formulation_ref?: string; + system_ref?: string; + /** Run directory, when the bash launch already happened and the agent knows it. */ + run_dir?: string; + /** Optional free-text note ("X gate, defaults"). */ + note?: string; +} + +/** Stage-8 guided stub (amicode_to_hardware): records intent to send a pulse to + * a device. THIS BUILD PERFORMS NO DEVICE I/O — `gate` and `checks` are fixed + * by the serializer (pending-human-signoff + the auto-check list), never + * caller-supplied, so a stub can't claim an approval that didn't happen. */ +export interface DeviceSessionStub { + /** The solved pulse artifact (pulse.jld2) if known. */ + pulse_ref?: string; + /** The run directory the pulse came from, if known. */ + run_dir?: string; + note?: string; +} + +/** Guided follow-up stub (amicode_calibrate): the calibration loop that follows + * hardware runs. `loop`/`status` are fixed by the serializer — "not-wired" is + * the honest state of this build. */ +export interface CalibrationStub { + device_session_ref?: string; + note?: string; +} + +export const PLATFORMS = ["transmon", "rydberg"] as const; +export const MIN_LEVELS = 2; +export const MAX_LEVELS = 6; + +// --- validation -------------------------------------------------------------- + +/** Problems with a SystemEntity; [] means valid. */ +export function validateSystem(e: SystemEntity): string[] { + const problems: string[] = []; + if (!(PLATFORMS as readonly string[]).includes(e.platform)) { + problems.push(`unknown platform "${e.platform}" — expected one of: ${PLATFORMS.join(", ")}`); + } + if (!Number.isInteger(e.levels) || e.levels < MIN_LEVELS || e.levels > MAX_LEVELS) { + problems.push(`levels must be an integer in [${MIN_LEVELS}, ${MAX_LEVELS}], got ${e.levels}`); + } + for (const [k, v] of Object.entries(e.params ?? {})) { + if (typeof v !== "number" || !Number.isFinite(v)) { + problems.push(`param "${k}" must be a finite number, got ${v}`); + } + } + return problems; +} + +/** Problems with a FormulationEntity; [] means valid. */ +export function validateFormulation(e: FormulationEntity): string[] { + const problems: string[] = []; + if (typeof e.problem !== "string" || e.problem.trim() === "") problems.push("problem must be non-empty"); + if (typeof e.target !== "string" || e.target.trim() === "") problems.push("target must be non-empty"); + if (typeof e.objective !== "string" || e.objective.trim() === "") problems.push("objective must be non-empty"); + if (!Array.isArray(e.constraints) || e.constraints.some((c) => typeof c !== "string")) { + problems.push("constraints must be an array of strings"); + } + return problems; +} + +// --- merge (amicode_set_model) ------------------------------------------------ + +export interface SystemPatch { + levels?: number; + params?: Record; +} + +/** Merge a set_model patch into an existing SystemEntity (pure — returns a new + * object; the input is never mutated). Throws if the RESULT is invalid, so a + * bad patch can never corrupt a previously-valid recorded entity. */ +export function updateSystem(existing: SystemEntity, patch: SystemPatch): SystemEntity { + const merged: SystemEntity = { + platform: existing.platform, + levels: patch.levels ?? existing.levels, + params: { ...existing.params, ...(patch.params ?? {}) }, + }; + const problems = validateSystem(merged); + if (problems.length) throw new Error(`invalid system after merge: ${problems.join("; ")}`); + return merged; +} + +// --- TOML emission ------------------------------------------------------------- + +/** Escape a string for a TOML basic (double-quoted) string. */ +function tomlEscape(s: string): string { + let out = ""; + for (const ch of s) { + const code = ch.codePointAt(0)!; + if (ch === "\\") out += "\\\\"; + else if (ch === '"') out += '\\"'; + else if (ch === "\n") out += "\\n"; + else if (ch === "\r") out += "\\r"; + else if (ch === "\t") out += "\\t"; + else if (ch === "\b") out += "\\b"; + else if (ch === "\f") out += "\\f"; + else if (code < 0x20 || code === 0x7f) out += "\\u" + code.toString(16).padStart(4, "0"); + else out += ch; + } + return `"${out}"`; +} + +/** A TOML key: bare when safe, basic-quoted otherwise. */ +function tomlKey(k: string): string { + return /^[A-Za-z0-9_-]+$/.test(k) ? k : tomlEscape(k); +} + +/** Finite-number TOML literal (validators guarantee finiteness before this). */ +function tomlNumber(v: number): string { + if (!Number.isFinite(v)) throw new Error(`param value ${v} has no TOML representation`); + return String(v); +} + +function isoNow(now?: Date): string { + return (now ?? new Date()).toISOString(); +} + +/** Serialize a SystemEntity: + * [system] platform/levels/recorded + [system.params] name = value. Throws on + * an invalid entity. `now` is injectable for deterministic tests. */ +export function systemToml(e: SystemEntity, now?: Date): string { + const problems = validateSystem(e); + if (problems.length) throw new Error(`invalid system: ${problems.join("; ")}`); + const lines = [ + "[system]", + `platform = ${tomlEscape(e.platform)}`, + `levels = ${e.levels}`, + `recorded = ${tomlEscape(isoNow(now))}`, + "", + "[system.params]", + ...Object.entries(e.params).map(([k, v]) => `${tomlKey(k)} = ${tomlNumber(v)}`), + ]; + return lines.join("\n") + "\n"; +} + +/** Serialize a FormulationEntity under [formulation]. Throws on invalid. */ +export function formulationToml(e: FormulationEntity, now?: Date): string { + const problems = validateFormulation(e); + if (problems.length) throw new Error(`invalid formulation: ${problems.join("; ")}`); + const lines = [ + "[formulation]", + `problem = ${tomlEscape(e.problem)}`, + `target = ${tomlEscape(e.target)}`, + `objective = ${tomlEscape(e.objective)}`, + `constraints = [${e.constraints.map(tomlEscape).join(", ")}]`, + `recorded = ${tomlEscape(isoNow(now))}`, + ]; + return lines.join("\n") + "\n"; +} + +/** Serialize the Run bookkeeping stub under [run]. `launched_via` is fixed to + * "bash amico-run": the amicode_solve tool records intent only — the actual + * launch is the AGENTS.md bash workflow, never this tool. Optional refs are + * omitted (not written as "") when absent. */ +export function runStubToml(stub: RunStub, now?: Date): string { + const lines = ["[run]"]; + if (stub.formulation_ref !== undefined) lines.push(`formulation_ref = ${tomlEscape(stub.formulation_ref)}`); + if (stub.system_ref !== undefined) lines.push(`system_ref = ${tomlEscape(stub.system_ref)}`); + if (stub.run_dir !== undefined) lines.push(`run_dir = ${tomlEscape(stub.run_dir)}`); + lines.push(`launched_via = ${tomlEscape("bash amico-run")}`); + if (stub.note !== undefined) lines.push(`note = ${tomlEscape(stub.note)}`); + lines.push(`recorded = ${tomlEscape(isoNow(now))}`); + return lines.join("\n") + "\n"; +} + +/** A given-but-empty ref is a caller bug (an ABSENT ref is fine — omit the key). */ +function requireNonEmptyRef(name: string, value: string | undefined): void { + if (value !== undefined && value.trim() === "") { + throw new Error(`${name} must be non-empty when given — omit it (null) if unknown`); + } +} + +/** The stage-8 send-to-device gate's automated checks — fixed, not caller data: + * they describe what the gate WILL verify, not what happened (nothing happens + * in this build). Human visual sign-off follows the auto checks. */ +const HARDWARE_CHECKS = ["fidelity>=threshold", "|drive|<=cap", "bandwidth", "leakage"] as const; + +/** Serialize the DeviceSession stub under [device_session]. `gate` is pinned to + * "pending-human-signoff" and `checks` to HARDWARE_CHECKS (see interface note). */ +export function deviceSessionStubToml(stub: DeviceSessionStub, now?: Date): string { + requireNonEmptyRef("pulse_ref", stub.pulse_ref); + requireNonEmptyRef("run_dir", stub.run_dir); + const lines = ["[device_session]"]; + if (stub.pulse_ref !== undefined) lines.push(`pulse_ref = ${tomlEscape(stub.pulse_ref)}`); + if (stub.run_dir !== undefined) lines.push(`run_dir = ${tomlEscape(stub.run_dir)}`); + lines.push(`gate = ${tomlEscape("pending-human-signoff")}`); + lines.push(`checks = [${HARDWARE_CHECKS.map(tomlEscape).join(", ")}]`); + if (stub.note !== undefined) lines.push(`note = ${tomlEscape(stub.note)}`); + lines.push(`recorded = ${tomlEscape(isoNow(now))}`); + return lines.join("\n") + "\n"; +} + +/** Serialize the Calibration stub under [calibration]. `loop` is pinned to "ILC" + * (iterative learning control — the loop that follows hardware runs) and + * `status` to "not-wired": this build records the follow-up, nothing more. */ +export function calibrationStubToml(stub: CalibrationStub, now?: Date): string { + requireNonEmptyRef("device_session_ref", stub.device_session_ref); + const lines = ["[calibration]"]; + if (stub.device_session_ref !== undefined) { + lines.push(`device_session_ref = ${tomlEscape(stub.device_session_ref)}`); + } + lines.push(`loop = ${tomlEscape("ILC")}`); + lines.push(`status = ${tomlEscape("not-wired")}`); + if (stub.note !== undefined) lines.push(`note = ${tomlEscape(stub.note)}`); + lines.push(`recorded = ${tomlEscape(isoNow(now))}`); + return lines.join("\n") + "\n"; +} diff --git a/packages/extension/opencode-plugin/score_guard.ts b/packages/extension/opencode-plugin/score_guard.ts new file mode 100644 index 00000000..ada29f5e --- /dev/null +++ b/packages/extension/opencode-plugin/score_guard.ts @@ -0,0 +1,167 @@ +// ============================================================================ +// Score-guard for the amicode_* tool pack — stage-order + gate enforcement. +// +// SIBLING-MODULE RULES (same as ./entities): this file is imported by +// amicode_tools.ts inside opencode's Bun runtime via a relative `./score_guard` +// import — keep it dependency-free (node: builtins only). Its logic is pure and +// unit-tested from test/scores/guard.test.ts. Named exports here are fine; the +// single-export constraint applies only to the plugin entry (amicode_tools.ts). +// +// STATE CONTRACT: everything lives in entitiesDir() (the caller passes it in) — +// score_manifest.json written by prepareOpencodeProject (extension side) +// interview_state.json same shape as src/scores/interview_state.ts (JSON, "" not null) +// usage.jsonl same line format as src/scores/usage.ts +// The FILE FORMATS are the contract between the extension process and this Bun +// process — the modules are deliberately parallel implementations because this +// side must not import from src/ (see amicode_tools.ts header). +// +// SEMANTICS: the guard protects ENTITY DEPENDENCIES, not conversation order. +// Blockers for entering a stage = prior non-optional stages that EMIT entities +// and are not completed. Conversational stages (no emits) never block, so the +// interview's mode/problem stages can be answered without tool calls. A stage +// with `gate:` additionally requires a pass/override record in state.gates. +// No manifest on disk → no gating (the tools stay pure bookkeeping — fallback). +// ============================================================================ + +import * as fs from "node:fs"; +import * as path from "node:path"; + +export interface StageLite { + id: string; + emits?: string[]; + optional?: boolean; + gate?: string; +} + +export interface ManifestLite { + id: string; + version: number; + stages: StageLite[]; +} + +export interface GateRecordLite { + result: "pass" | "fail" | "override"; + ts: string; + override_reason: string; +} + +export interface ScoreStateLite { + score_id: string; + score_version: number; + stage_cursor: string; + completed_stages: string[]; + answers: Record; + entity_refs: string[]; + gates: Record; +} + +export type GuardVerdict = + | { ok: true } + | { ok: false; code: "stage_order"; required_stage: string; missing_entities: string[] } + | { ok: false; code: "gate_required"; gate: string }; + +export function freshScoreState(scoreId: string, scoreVersion: number): ScoreStateLite { + return { + score_id: scoreId, + score_version: scoreVersion, + stage_cursor: "", + completed_stages: [], + answers: {}, + entity_refs: [], + gates: {}, + }; +} + +export function checkStagePrereqs(stages: StageLite[], state: ScoreStateLite, requestedStageId: string): GuardVerdict { + const idx = stages.findIndex((s) => s.id === requestedStageId); + if (idx === -1) return { ok: true }; // unknown stage: fail-open (forward compatibility) + const requested = stages[idx]; + const done = new Set(state.completed_stages); + for (let k = 0; k < idx; k++) { + const prior = stages[k]; + if (prior.optional || !prior.emits?.length || done.has(prior.id)) continue; + return { ok: false, code: "stage_order", required_stage: prior.id, missing_entities: [...prior.emits] }; + } + if (requested.gate && !done.has(requested.id)) { + const rec = state.gates[requested.gate]; + if (!rec || rec.result === "fail") return { ok: false, code: "gate_required", gate: requested.gate }; + } + return { ok: true }; +} + +const MANIFEST_FILE = "score_manifest.json"; +const STATE_FILE = "interview_state.json"; +const USAGE_FILE = "usage.jsonl"; + +export function loadManifest(dir: string): ManifestLite | undefined { + const file = path.join(dir, MANIFEST_FILE); + if (!fs.existsSync(file)) return undefined; + try { + const raw = JSON.parse(fs.readFileSync(file, "utf8")) as { manifest?: ManifestLite }; + return raw.manifest && Array.isArray(raw.manifest.stages) ? raw.manifest : undefined; + } catch { + return undefined; + } +} + +export function loadScoreState(dir: string): ScoreStateLite | undefined { + const file = path.join(dir, STATE_FILE); + if (!fs.existsSync(file)) return undefined; + try { + return JSON.parse(fs.readFileSync(file, "utf8")) as ScoreStateLite; + } catch { + return undefined; + } +} + +export function saveScoreState(dir: string, state: ScoreStateLite): void { + fs.mkdirSync(dir, { recursive: true }); + const file = path.join(dir, STATE_FILE); + const tmp = file + ".tmp"; + fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + "\n"); + fs.renameSync(tmp, file); +} + +export function appendUsage(dir: string, event: Record): void { + fs.mkdirSync(dir, { recursive: true }); + fs.appendFileSync(path.join(dir, USAGE_FILE), JSON.stringify(event) + "\n"); +} + +/** One-call guard for a tool execute(): returns an error string to hand back to + * the model when blocked (naming the missing prerequisite), else undefined — + * and on success records stage entry + usage events. No manifest → no gating. */ +export function guardAndRecordStage(dir: string, stageId: string): string | undefined { + const manifest = loadManifest(dir); + if (!manifest) return undefined; // fallback mode: pure bookkeeping, as before + let state = loadScoreState(dir); + if (!state) { + state = freshScoreState(manifest.id, manifest.version); + appendUsage(dir, { kind: "session_started", ts: new Date().toISOString(), score_id: manifest.id, score_version: manifest.version }); + } + const verdict = checkStagePrereqs(manifest.stages, state, stageId); + if (!verdict.ok) { + return ( + `Blocked by the score's stage order: ${JSON.stringify(verdict)}. ` + + (verdict.code === "stage_order" + ? `Complete stage "${verdict.required_stage}" first (records: ${verdict.missing_entities.join(", ")}) — relay this to the user conversationally.` + : `Gate "${verdict.gate}" has no passing record — its checks must pass (or be overridden with a recorded reason) first.`) + ); + } + if (!state.completed_stages.includes(stageId)) { + appendUsage(dir, { kind: "stage_entered", ts: new Date().toISOString(), stage: stageId }); + } + state.stage_cursor = stageId; + saveScoreState(dir, state); + return undefined; +} + +/** Mark a stage completed after its tool succeeded (idempotent). */ +export function completeStage(dir: string, stageId: string): void { + const state = loadScoreState(dir); + if (!state) return; + if (!state.completed_stages.includes(stageId)) { + state.completed_stages.push(stageId); + appendUsage(dir, { kind: "stage_completed", ts: new Date().toISOString(), stage: stageId }); + } + saveScoreState(dir, state); +} diff --git a/packages/extension/package.json b/packages/extension/package.json index d1c304ae..7b17918d 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -1,7 +1,7 @@ { "name": "amicode-v2", "displayName": "Amicode v2", - "description": "Amico research IDE \u2014 opencode-backed chat + native VS Code surfaces for vault, catalog, and live solve inspection.", + "description": "Amico research IDE — opencode-backed chat + native VS Code surfaces for vault, catalog, and live solve inspection.", "version": "0.0.1", "publisher": "harmoniqs", "license": "Apache-2.0", @@ -133,5 +133,8 @@ "smol-toml": "^1.3.0", "typescript": "^5.6.0", "vitest": "^2.1.0" + }, + "dependencies": { + "yaml": "^2.9.0" } } \ No newline at end of file diff --git a/packages/extension/scores/README.md b/packages/extension/scores/README.md new file mode 100644 index 00000000..f64041ab --- /dev/null +++ b/packages/extension/scores/README.md @@ -0,0 +1,87 @@ +# The repertoire — authoring scores + +A **score** is a packaged guided path: the interview a user rides from intent to a +result. Scores are **content, not code** — adding a user path means adding a +directory here; the runtime never changes. (Naming: Amico is the conductor; it +performs scores; this directory is its repertoire.) + +``` +scores/ + entitlements.toml # registered entitlement ids (typos fail CI) + memory/.md # bundled [Why?] hook content, shared across scores + / + SCORE.md # the manifest (YAML frontmatter) + Amico's voice (body) + templates/*.jl|*.py # vetted templates the score's stages instantiate +``` + +## SCORE.md anatomy + +Frontmatter = structure (what the runtime, UI, lint, and tests read). +Body = prose (per-stage narration, physics, defaults rationale, off-path guidance). + +```yaml +--- +type: score +schema_version: 1 # supported: 1; unknown FIELDS are ignored (additive policy) +id: my-score # directory name must match +version: 1 # bump on revision; in-flight sessions stay pinned to theirs +derived_from: null # or a sibling score id — lineage for forks +name: "Shown on the entry card" +outcome: "What the user will HAVE at the end" +audience: [algorithms, no-physics-assumed] +duration_estimate: "60–90 min" +device: {backend: pasqal, qpu_runnable: true, emulators: [emu-mps]} # optional +entitlements: [] # empty/absent = public; ids must be in entitlements.toml +stages: + - id: application # ordered list; loopbacks OK, no DAGs (v1) + emits: [circuit] # ONLY workflow-frames entities: circuit, system, + # formulation, pulse, run, device_session, knowledge + questions: + - id: graph + prompt: "Which graph?" + choices: [sample, upload] # choices → rendered as amicode_ask buttons + default: sample # must be one of choices; marked "(recommended)" + skip_if: "mode == simulate" # optional + memory_hooks: [some-slug] # optional; must resolve to memory/.md + - id: solve + emits: [run, pulse] + executor: cloud-altissimo # or local + template: templates/solve.jl # resolved relative to the score dir; must exist + - id: device-qpu + emits: [device_session] + gate: heavy # light|heavy — checks must pass BEFORE entering + optional: true +--- +[Amico's voice for this score — markdown + LaTeX, carried verbatim into the prompt] +``` + +## Rules the lint enforces (`pnpm --filter amicode-v2 test -- repertoire_lint`) + +- manifest validates (schema_version supported, version ≥ 1, no duplicate stages, + defaults ∈ choices, `emits` only known entities, `gate` only known classes) +- every `template` resolves inside the score dir +- every `memory_hooks` slug resolves to `scores/memory/.md` +- `derived_from` is null or an existing score id +- every entitlement id is registered in `entitlements.toml` +- new files ship in the .vsix (`test/packaging.test.ts`) + +## How it runs ("data-defined, prompt-executed") + +At session prep, `prepareOpencodeProject` loads the repertoire, filters it by the +user's entitlements (no code → public scores only; failures fall back to public — +never a dead end), compiles the selected score + the onset router into the injected +AGENTS.md, and writes `score_manifest.json` for the Bun-side plugin. The `amicode_*` +tools enforce stage order and gates against that manifest (entity dependencies +block; conversational stages don't) and record `interview_state.json` + +`usage.jsonl` — the funnel data future learned-traversal work consumes. + +If score loading fails, the runtime falls back to the hardcoded interview section +in `AGENTS.md` — a broken score can never brick the boot (and a broken score is +skipped, not fatal, in the repertoire). + +## Known v1 limits + +- **Score selection is boot-time** (score #0). Multi-score repertoires need the + router-time select→recompile step — see the scores-runtime handoff note. +- Stage funnel events come from tool-mapped stages; purely conversational stages + aren't individually tracked yet. diff --git a/packages/extension/scores/entitlements.toml b/packages/extension/scores/entitlements.toml new file mode 100644 index 00000000..2c6e3391 --- /dev/null +++ b/packages/extension/scores/entitlements.toml @@ -0,0 +1,3 @@ +# Registered entitlement ids (spec §3 contract test: "every entitlements id is registered"). +# A score naming an unregistered id is a lint error — typos must fail CI, not silently hide a score. +known = ["pasqal-hackathon-2026"] diff --git a/packages/extension/scores/memory/free-phase-objective-only.md b/packages/extension/scores/memory/free-phase-objective-only.md new file mode 100644 index 00000000..1e8384f9 --- /dev/null +++ b/packages/extension/scores/memory/free-phase-objective-only.md @@ -0,0 +1,9 @@ +# Free phases live in the objective, never the dynamics + +When a target is defined up to one or more free phases (e.g. a unitary target +where relative phase on some subspace is unphysical or absorbable), those phase +variables enter the **objective only** — they parameterize the infidelity being +minimized. They never appear in the Hamiltonian or the ODE being integrated: +the dynamics are fixed physics; the free phase is a statement about what counts +as success. Optimizing "with free phase" means the objective searches over the +phase at evaluation time — the rollout is unchanged. diff --git a/packages/extension/scores/memory/pin-globals-first-solve.md b/packages/extension/scores/memory/pin-globals-first-solve.md new file mode 100644 index 00000000..fb1466b2 --- /dev/null +++ b/packages/extension/scores/memory/pin-globals-first-solve.md @@ -0,0 +1,9 @@ +# Pin global model parameters during the initial pulse solve + +On a first solve, hold global model parameters (qubit frequency, anharmonicity, +coupling strengths) **fixed** and optimize the pulse alone. Co-optimizing +globals alongside the controls on a cold start lets the optimizer "explain +away" infidelity by drifting the model instead of shaping the pulse — you get +a great fidelity number against a system you no longer have. If model +parameters should move, make that a deliberate, separate step after the +pulse-only solve converges — never a silent default. diff --git a/packages/extension/scores/pulse-designer/SCORE.md b/packages/extension/scores/pulse-designer/SCORE.md new file mode 100644 index 00000000..1d9cd783 --- /dev/null +++ b/packages/extension/scores/pulse-designer/SCORE.md @@ -0,0 +1,136 @@ +--- +type: score +schema_version: 1 +id: pulse-designer +version: 1 +derived_from: null +name: "Design an optimized pulse" +outcome: "A solved, inspected pulse for your gate on your platform" +audience: [researchers, general] +duration_estimate: "10–20 min (plus solve time)" +entitlements: [] +stages: + - id: platform + questions: + - id: platform + prompt: "What kind of system are you working with?" + choices: ["transmon", "neutral-atom Rydberg", "other"] + default: "transmon" + - id: model + emits: [system] + questions: + - id: levels + prompt: "How many levels should the model keep?" + choices: ["3", "4"] + default: "3" + rationale_ref: "#levels-guidance" + - id: drives + prompt: "Drive parameterization and amplitude bound (drive_max)?" + default: "two quadratures, drive_max = 0.2 GHz" + - id: mode + questions: + - id: mode + prompt: "Simulate first, or go straight to solve?" + choices: ["solve", "simulate"] + default: "solve" + - id: warm_start + prompt: "Warm start from a previous pulse (pulse.jld2), or cold start?" + choices: ["cold start", "warm start"] + default: "cold start" + skip_if: "mode == simulate" + - id: problem + questions: + - id: target + prompt: "Which single-qubit gate is the target?" + choices: ["X", "Y", "Z", "H", "S", "T", "√X", "arbitrary unitary"] + default: "X" + rationale_ref: "#scope" + - id: formulate + emits: [formulation] + questions: + - id: objective + prompt: "Objective and constraints beyond the vetted default (unitary infidelity under the amplitude bound)?" + default: "vetted default only" + memory_hooks: [free-phase-objective-only, pin-globals-first-solve] + - id: solve + emits: [run, pulse] + executor: local + template: templates/solve.jl + questions: + - id: solve_params + prompt: "Gate time T (ns), timesteps N, and max_iter?" + default: "T = 10 ns, N = 50, max_iter = 60" + rationale_ref: "#regime-guidance" + - id: inspect + - id: hardware + emits: [device_session] + optional: true +--- + +You are running the **pulse-designer** interview. + +**Scope rule:** run this interview when you are the pulse-designer agent, when +the user asks to be walked through designing a pulse — and **proactively**: if +a session opens with a greeting or no specific request ("hello", "who are +you?", "what is this?"), introduce yourself as Amico in one line and ask the +stage-1 PLATFORM question. If the user already knows their parameters ("X +gate, 10 ns, defaults"), **skip straight to the solve workflow** — never force +the interview on someone with a specific ask. The user can say "fast-forward" +at any stage to jump to defaults. + +**Protocol: ONE question at a time.** Never batch questions. Ask, wait, +record, advance. After each answer, record the stage's state: call the +matching `amicode_*` tool if it is available; if not, summarize the recorded +values in one line and continue (the tools record entities — System, +Formulation, Run — they are bookkeeping, not gates). + +**Buttons for choices:** any question above with a `choices` list goes through +`amicode_ask` (question + the options, default first, marked "(recommended)") +— the chat renders them as buttons and the click arrives as the next message. +Free-form values ($\omega$, $\delta$, `T`, `N`, `max_iter`) stay plain-text +questions. If `amicode_ask` is unavailable, ask in plain text with the options +listed. + +Per-stage notes: + +1. **platform** — on answer, show the model Hamiltonian and confirm it matches + their device. Record via `amicode_pick_system`. + - transmon (fully supported end-to-end): + $\hat H/\hbar = \omega\,\hat a^\dagger\hat a + \tfrac{\delta}{2}\,\hat a^{\dagger 2}\hat a^2 + u_1(t)\,(\hat a + \hat a^\dagger) + i\,u_2(t)\,(\hat a - \hat a^\dagger)$ + - Rydberg 3-level ($|0\rangle$ dark, $|1\rangle\!\leftrightarrow\!|r\rangle$ driven, + blockade on $|rr\rangle$): show the form, record the System entity honestly + as `platform = "rydberg"` — then say plainly that this build's vetted + template is transmon-only and Rydberg solve authoring is not wired yet; + offer to record the formulation for follow-up instead of guessing at an + unvetted script. +2. **model** — convention: **`T` = scalar gate time (ns), `N` = number of + timesteps** — never conflate them. Record via `amicode_set_model`. + Levels: 3 (default) or 4 for more leakage + realism; **avoid 5+** — added levels worsen conditioning and leakage and + inflate solve cost; if the user insists, warn it may not converge. +3. **mode** — if warm-starting: `traj = load_traj("path/to/pulse.jld2")` as + the initial guess (the warm-start idiom in the project context). +4. **problem** — **single qubit only**: X, Y, Z, H, S, T, + √X, or an arbitrary single-qubit unitary. Multi-qubit gates (CNOT, CZ, + iSWAP, …) are out of scope for this single-lab build — say so plainly and + stop; don't build a coupled multi-transmon system. +5. **formulate** — the vetted template optimizes unitary infidelity under the + amplitude bound `drive_max`; record any further objectives/constraints as + follow-ups in the Formulation entity — do not improvise unvetted physics + into the script. **Never silently co-optimize global model parameters** + (frequencies, anharmonicities) — if the user wants that, it's a recorded + follow-up, not a live edit. Record via `amicode_formulate`. +6. **solve** — defaults converge to F > 0.999 in + the default regime. `N`: keep ~5–10 steps/ns (`N = 50` suits `T ≈ 10 ns`; + `T = 30 ns` → `N ≈ 200`, else the pulse is under-resolved and fidelity + drops silently; short/fast gates also want higher N and possibly larger + `drive_max`). `max_iter`: 60 near the default regime, ~150–200 for harder + cases. Then author `solve.jl` from this score's vetted template and launch + it detached per the solve workflow (`amico-run` via bash — `amicode_solve` + records the Run entity; the bash launch is still the mechanism). +7. **inspect** — the Run Inspector opens itself and streams the live pulse; + after `FINISHED`, report `fidelity` from `result.toml`. +8. **hardware** — guided stubs in this build: explain the send-to-device gate + (fidelity + amplitude/bandwidth checks, then human sign-off) and the + calibration loop that follows; record interest via `amicode_to_hardware` + and `amicode_calibrate` (bookkeeping stubs — they perform NO device I/O). diff --git a/packages/extension/scores/pulse-designer/templates/solve.jl b/packages/extension/scores/pulse-designer/templates/solve.jl new file mode 100644 index 00000000..c971dac2 --- /dev/null +++ b/packages/extension/scores/pulse-designer/templates/solve.jl @@ -0,0 +1,143 @@ +#!/usr/bin/env julia +# Amicode solve template — fill in the `# FILL IN` block, then: +# amico-run --project solve.jl +# Emits the run-dir contract (AMICODE_ITER, iter_.png, result.toml, pulse.jld2, DONE). +# Vetted against Piccolo 1.19 (the version `Pkg.add Piccolo` installs today): a +# single-qubit X gate on a 3-level transmon converges to subspace fidelity ~1.0. +using Piccolo +using CairoMakie # loads PiccoloMakieExt → gives LivePulsePlotCallback its impl +using JLD2 +using TOML +using Printf + +# ── FILL IN ────────────────────────────────────────────────────────────── +δ = 0.2 # anharmonicity (GHz, positive convention) +levels = 3 # transmon levels modeled (3 = qubit + 1 leakage; bump to 4–5 for more leakage realism) +gate = GATES[:X] +T = 10.0 # gate time (ns) +N = 50 # timesteps +drive_max = 0.2 # per-quadrature drive bound (GHz) +max_iter = 60 +# ───────────────────────────────────────────────────────────────────────── + +sys = TransmonSystem(; δ = δ, levels = levels, drive_bounds = fill(drive_max, 2)) +op = size(gate, 1) == sys.levels ? gate : EmbeddedOperator(gate, sys) + +times = collect(range(0.0, T, length = N)) +initial = 0.1 * randn(sys.n_drives, N) +qtraj = UnitaryTrajectory(sys, ZeroOrderPulse(initial, times), op) +qcp = SmoothPulseProblem(qtraj, N; + piccolo_options = PiccoloOptions(timesteps_all_equal = true), + Q = 100.0, R = 1e-2) +prob = hasproperty(qcp, :prob) ? qcp.prob : qcp + +# Per-iter live plot flows through Piccolo's `LivePulsePlotCallback`, an +# `AbstractIntermediateCallback` (the blessed, solver-agnostic per-iter plot +# idiom — see AGENTS.md). It reconstructs the pulse from the optimizer's primal +# each iteration and writes `iter_.png` into the run dir; the Run Inspector +# reads those frames. `every` is the redraw cadence. (No hand-rolled plotting: +# the PNGs are the callback's job, not the script's.) +const PLOT_EVERY = 6 +live_plot = LivePulsePlotCallback(qtraj, prob.trajectory; every = PLOT_EVERY, save_dir = ".") + +# Pulse-data telemetry (#66, prototype-grade): raw knot values per iteration as +# AMICODE_PULSE lines on stdout (→ run.log), riding the SAME solver-agnostic +# (primal, iter) hook as the live plot — the inspector renders them natively. +# Additive to the run-dir contract: consumers that don't know the lines ignore +# them. META once (shape + bounds), then one record per iteration (~1KB). +struct PulseEmitCallback <: AbstractIntermediateCallback + inner::Any # delegate (the live plot) — fires first, keeps the PNG cadence + traj::Any # prob.trajectory — synced from the primal, then read +end +function (cb::PulseEmitCallback)(primal, iter) + ok = cb.inner(primal, iter) + try + traj = cb.traj + expected = traj.dim * traj.N + traj.global_dim + if length(primal) == expected + # Own sync — the delegate only updates the trajectory on its plot cadence. + # Qualified: `update!` is also exported by Makie/CairoMakie — the + # unqualified binding is ambiguous once the plotting stack loads. + if traj.global_dim > 0 + Piccolo.NamedTrajectories.update!(traj, collect(view(primal, 1:expected)); type = :both) + else + Piccolo.NamedTrajectories.update!(traj, collect(view(primal, 1:(traj.dim * traj.N))); type = :data) + end + # Drive component name differs by problem flavor (:u current, :a + # legacy). Membership check (not `something(traj.u, traj.a)`): it + # keeps the fallback reachable without leaning on property access + # returning `nothing` for missing components (review nit, #67). + A = :u in traj.names ? traj.u : (:a in traj.names ? traj.a : missing) + A === missing && error("no drive component (:u/:a) on trajectory") + vals = join((join((@sprintf("%.6g", v) for v in row), ",") for row in eachrow(A)), ";") + @printf("AMICODE_PULSE iter=%d dt=%.6g a=%s\n", iter, first(Piccolo.get_timesteps(traj)), vals) + flush(stdout) + end + catch e + @warn "pulse emit failed" exception = e maxlog = 3 # never let telemetry kill the solve + end + return ok +end +pulse_emit = PulseEmitCallback(live_plot, prob.trajectory) + +let ls = join(("\"a_$i\"" for i in 1:sys.n_drives), ","), + bs = join(("$(-drive_max):$(drive_max)" for _ in 1:sys.n_drives), ",") + println("AMICODE_PULSE_META drives=$(sys.n_drives) knots=$N labels=$ls bounds=$bs") + flush(stdout) +end + +# AMICODE_ITER text telemetry stays on the RAW Ipopt callback — it needs the rich +# IPM state (obj_value/inf_pr/inf_du) that the agnostic `(primal, iter)` contract +# doesn't carry. Both callbacks fire once per iteration (DTO composes the raw +# callback with `intermediate_callback`); the live inspector is ipopt-only (Q74). +const CB = Piccolo.Callbacks +iters = Ref(0) +function cb_log(optimizer, st; kwargs...) + k = Int(st.iter_count); iters[] = k + @printf("AMICODE_ITER iter=%d f=%.6e inf_pr=%.3e inf_du=%.3e\n", k, st.obj_value, st.inf_pr, st.inf_du) + flush(stdout) + return true +end + +t0 = time() +solve!(qcp; max_iter = max_iter, print_level = 1, + options = IpoptOptions(intermediate_callback = pulse_emit), + callback = CB.callback_factory(cb_log)) +wall = time() - t0 + +# Fidelity over the COMPUTATIONAL subspace, from a fresh high-tolerance rollout. +# Two reasons this is the right metric: +# - subspace (not full-space): the embedded goal pins identity on the leakage +# level, which the solve doesn't enforce — full-space would read ~0.44 even +# for a perfect qubit gate. We want the gate fidelity on {|0>,|1>}. +# - rollout (not the raw final propagator): re-integrating at 1e-8 yields a +# clean unitary, avoiding the ~1e-6 norm-drift that made the raw block read >1. +Uroll = iso_vec_to_operator(unitary_rollout(get_trajectory(qcp), sys)[:, end]) +fid = unitary_fidelity(Uroll, op.operator; subspace = op.subspace) + +# End-of-solve guarantee frame — STILL through LivePulsePlotCallback (no bespoke +# plot). The live callback fires at iters 0, PLOT_EVERY, 2·PLOT_EVERY, …; a solve +# that converges in < PLOT_EVERY iters would otherwise leave only the iter-0 +# random-init frame (inspector stuck showing the initial guess). Re-invoke the +# callback once at every=1 with the FINAL primal so the last frame is the +# converged pulse. prob.trajectory is the final iterate here (DTO synced it after +# solve!), so this reconstructs the same primal the callback saw per-iter. +let final_cb = LivePulsePlotCallback(qtraj, prob.trajectory; every = 1, save_dir = ".") + tr = prob.trajectory + final_primal = tr.global_dim > 0 ? vcat(collect(tr.datavec), collect(tr.global_data)) : collect(tr.datavec) + final_cb(final_primal, iters[]) +end + +JLD2.save("pulse.jld2", "traj", prob.trajectory) # key "traj" so `load_traj` can reload it (warm-start) +open("result.toml.tmp", "w") do io + # Record the regime each run actually solved (scalar FILL-IN params), so the + # result is self-describing — not just fidelity/iterations. + TOML.print(io, Dict( + "schema_version" => "1", # run-dir contract version (@amicode/schema result schema) + "fidelity" => fid, "iterations" => iters[], "wall_seconds" => wall, + "params" => Dict("delta" => δ, "levels" => levels, "T" => T, "N" => N, + "drive_max" => drive_max, "max_iter" => max_iter), + )) +end +mv("result.toml.tmp", "result.toml"; force = true) +println("DONE fidelity=$(fid)"); flush(stdout) diff --git a/packages/extension/src/opencode_config.ts b/packages/extension/src/opencode_config.ts index 5f9e2d9a..d215e8d7 100644 --- a/packages/extension/src/opencode_config.ts +++ b/packages/extension/src/opencode_config.ts @@ -1,6 +1,10 @@ import * as fs from "node:fs"; import * as path from "node:path"; import * as os from "node:os"; +import { loadRepertoire } from "./scores/loader"; +import { readLocalEntitlements, filterRepertoire } from "./scores/entitlements"; +import { buildRouterSection } from "./scores/router"; +import { compileScore, spliceIntoAgentsMd } from "./scores/compiler"; // ============================================================================ // Prepare a per-session opencode project directory. @@ -63,14 +67,65 @@ export function resolveJuliaProject(configValue: string): string { * * `bash`/`edit` are left at "allow" (both already default to allow; bash runs * the compound `mkdir … && nohup amico-run …` launch, not worth scoping). - * `webfetch` is intentionally NOT set — the solve flow never fetches a URL. */ + * `webfetch` is intentionally NOT set — the solve flow never fetches a URL. + * + * L0 pulse-designer additions (night build 2026-07-03; registration mechanism + * probed on the stock vendored 1.17.3 — see opencode-plugin/amicode_tools.ts + * header for the full T8 decision record): + * - `plugin: []` — the + * amicode_* tool pack, executed by opencode's embedded Bun runtime (it is + * NOT part of the extension bundle). The path defaults from __dirname + * (works from both src/ under vitest and dist/ in the cjs bundle — the + * plugin dir is a sibling of both). TODO(follow-up): extension.ts should + * pass this explicitly once .vsix packaging of opencode-plugin/ is + * verified; the default keeps existing call sites working unchanged. + * - `agent: {"pulse-designer": …}` — the interview agent; its prompt defers + * to the "Pulse-designer interview" section of the injected AGENTS.md so + * the interview script lives in ONE place. + * - an `external_directory` grant for the entities dir, so the AGENT's file + * tools can read back system/formulation/run TOML the plugin wrote (the + * plugin's own fs writes are host-process calls and need no grant). Must + * stay derivation-identical to entitiesDir() in amicode_tools.ts. */ const SCRATCH_DIR = "/tmp/amicode-work"; // matches AGENTS.md step 2/3 -export function buildOpencodeConfigContent(agentsPath: string, templatePath: string): string { +/** Where the amicode_* plugin records entities — MUST match entitiesDir() in + * opencode-plugin/amicode_tools.ts ($AMICODE_ENTITIES_DIR override included, + * so the permission grant follows the plugin wherever it is pointed). */ +function entitiesDir(): string { + const env = process.env.AMICODE_ENTITIES_DIR; + if (env && env.trim() !== "") return env; + return path.join(os.homedir(), ".amico", "runs", "default", "_entities"); +} + +/** Default location of the amicode_* opencode plugin: a sibling directory of + * both src/ (vitest) and dist/ (the bundled extension), so __dirname/.. works + * from either. */ +const DEFAULT_PLUGIN_PATH = path.resolve(__dirname, "..", "opencode-plugin", "amicode_tools.ts"); + +/** Default scores repertoire root — same sibling-of-src-and-dist trick as the + * plugin path. Holds SCORE.md manifests, score-local templates, memory hooks. */ +export const DEFAULT_SCORES_ROOT = path.resolve(__dirname, "..", "scores"); + +export function buildOpencodeConfigContent( + agentsPath: string, + templatePath: string, + pluginPath: string = DEFAULT_PLUGIN_PATH, + scoresRoot: string = DEFAULT_SCORES_ROOT, +): string { const templatesDir = path.dirname(templatePath); return JSON.stringify({ $schema: "https://opencode.ai/config.json", instructions: [agentsPath], + plugin: [pluginPath], + agent: { + "pulse-designer": { + description: "Guided quantum pulse design interview", + prompt: + "You are Amico's pulse-designer. Follow the 'Pulse-designer interview' section of " + + "the project instructions exactly: one question at a time, record each stage with " + + "the amicode_* tools, and use the solve workflow for launches.", + }, + }, permission: { bash: "allow", edit: "allow", @@ -79,6 +134,8 @@ export function buildOpencodeConfigContent(agentsPath: string, templatePath: str [`${templatesDir}/**`]: "allow", // (belt-and-suspenders for the dir) [`${SCRATCH_DIR}/**`]: "allow", // solve.jl + solve.log it writes [`/private${SCRATCH_DIR}/**`]: "allow", // macOS: /tmp → /private/tmp + [`${entitiesDir()}/**`]: "allow", // amicode_* entities the agent may read back + [`${scoresRoot}/**`]: "allow", // score templates + memory hooks ([Why?]) the agent reads }, }, }); @@ -94,6 +151,10 @@ export interface OpencodeConfigOptions { /** Julia project (--project) the agent should use; already resolved (see * resolveJuliaProject). Substituted into AGENTS.md as {{JULIA_PROJECT}}. */ juliaProject: string | undefined; + /** Scores repertoire root (SCORE.md manifests). Default: the bundled scores/. */ + scoresRoot?: string; + /** Dir holding the user's entitlements.toml (access-code stub). Default: ~/.amico/amicode. */ + entitlementsDir?: string; } export interface OpencodeProject { @@ -115,7 +176,37 @@ export function prepareOpencodeProject(opts: OpencodeConfigOptions): OpencodePro const filled = raw .replaceAll("{{JULIA_PROJECT}}", opts.juliaProject ?? resolveJuliaProject("")) .replaceAll("{{TEMPLATE_PATH}}", opts.templateSrc); - fs.writeFileSync(agentsPath, filled, "utf8"); + + // Score runtime ("data-defined, prompt-executed", scores spec §6): compile the + // selected score (v1: boot-time selection of score #0, pulse-designer) over the + // hardcoded interview section, prefix the onset router, and drop the manifest + // transport for the Bun-side plugin. FALLBACK: any failure leaves the substituted + // AGENTS.md exactly as before — the hardcoded section IS the fallback content; + // score trouble must never brick the boot. + let finalContent = filled; + try { + const scoresRoot = opts.scoresRoot ?? DEFAULT_SCORES_ROOT; + const load = loadRepertoire(scoresRoot); + const ents = readLocalEntitlements(opts.entitlementsDir ?? path.join(os.homedir(), ".amico", "amicode")); + const visible = filterRepertoire(load.scores, ents.entitlements); + const score0 = visible.find((s) => s.manifest.id === "pulse-designer"); + if (score0) { + finalContent = spliceIntoAgentsMd(filled, buildRouterSection(visible), compileScore(score0)); + // Manifest transport: the opencode plugin (Bun runtime, separate process tree) + // locates ALL its state via entitiesDir() — so the guard's copy goes there + // (see opencode-plugin/score_guard.ts header). The projectDir copy is the + // extension-side record of what this session was prepared with. + const manifestJson = + JSON.stringify({ manifest: score0.manifest, score_dir: score0.dir, project_dir: projectDir }, null, 2) + "\n"; + fs.writeFileSync(path.join(projectDir, "score_manifest.json"), manifestJson); + fs.mkdirSync(entitiesDir(), { recursive: true }); + fs.writeFileSync(path.join(entitiesDir(), "score_manifest.json"), manifestJson); + } + } catch (e) { + console.warn(`amicode: score compilation failed, using built-in interview fallback: ${e}`); + finalContent = filled; + } + fs.writeFileSync(agentsPath, finalContent, "utf8"); // The agent reads the template from its bundled absolute path (the session // cwd is the workspace, not this temp dir — so no copy is made here). diff --git a/packages/extension/src/scores/compiler.ts b/packages/extension/src/scores/compiler.ts new file mode 100644 index 00000000..c2121bd8 --- /dev/null +++ b/packages/extension/src/scores/compiler.ts @@ -0,0 +1,59 @@ +import * as path from "node:path"; +import { Score } from "./loader"; + +// Compile a score into the injected-prompt section — "data-defined, prompt-executed" +// (spec §6). The heading is kept EXACTLY "## Pulse-designer interview" for score #0 +// compatibility: the pulse-designer agent prompt in buildOpencodeConfigContent refers +// to that section by name. Pure and deterministic: same score → same string. + +export function compileScore(score: Score): string { + const m = score.manifest; + const lines: string[] = [ + `## Pulse-designer interview`, + "", + `> Compiled from score \`${m.id}\` v${m.version} — \`SCORE.md\` is the source of truth; do not edit this section by hand.`, + "", + "**Interview contract:** ONE question at a time — never batch. Ask, wait, record,", + "advance. Questions with an options list go through `amicode_ask` (options in the", + "given order, default first and marked \"(recommended)\"); free-form questions stay", + "plain text. A stage marked *(optional)* may be skipped. A stage with a gate must", + "not be entered until the gate's checks pass.", + "", + "### Stages (in order)", + "", + ]; + m.stages.forEach((s, i) => { + const flags = [s.optional ? "(optional)" : "", s.gate ? `🔒 gate: ${s.gate} — checks must pass before entering` : ""] + .filter(Boolean) + .join(" "); + lines.push(`${i + 1}. **${s.id}**${flags ? " " + flags : ""}`); + if (s.emits?.length) lines.push(` - emits: ${s.emits.join(", ")} — record via the matching \`amicode_*\` tool`); + if (s.executor) lines.push(` - executor: \`${s.executor}\``); + if (s.template) lines.push(` - vetted template (absolute): \`${path.join(score.dir, s.template)}\``); + for (const q of s.questions ?? []) { + const choices = q.choices + ? ` — options: ${q.choices.map((c) => (c === q.default ? `${c} (recommended)` : c)).join(" | ")}` + : q.default + ? ` — default: ${q.default}` + : ""; + lines.push(` - Q \`${q.id}\`: "${q.prompt}"${choices}`); + if (q.skip_if) lines.push(` - skip if: ${q.skip_if}`); + if (q.memory_hooks?.length) lines.push(` - [Why?] hooks: ${q.memory_hooks.join(", ")} (read \`scores/memory/.md\` on request)`); + } + }); + lines.push("", "---", "", score.body.trim(), ""); + return lines.join("\n"); +} + +// Replace the "## Pulse-designer interview" section (through the next h2) with the +// compiled content, prefixed by the router section. If the heading is missing the +// compiled content is appended — the injection must never lose content. +export function spliceIntoAgentsMd(agentsMd: string, routerSection: string, compiledScore: string): string { + const block = `${routerSection}\n\n${compiledScore}`; + const start = agentsMd.indexOf("## Pulse-designer interview"); + if (start === -1) return `${agentsMd}\n\n${block}`; + const rest = agentsMd.slice(start + 1); + const nextH2 = rest.search(/\n## /); + const end = nextH2 === -1 ? agentsMd.length : start + 1 + nextH2 + 1; + return agentsMd.slice(0, start) + block + "\n" + agentsMd.slice(end); +} diff --git a/packages/extension/src/scores/entitlements.ts b/packages/extension/src/scores/entitlements.ts new file mode 100644 index 00000000..0e42deb8 --- /dev/null +++ b/packages/extension/src/scores/entitlements.ts @@ -0,0 +1,49 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { parse as parseToml } from "smol-toml"; +import { Score } from "./loader"; + +// D3 *interface* — the access-code redemption service (separate plan) implements +// EntitlementProvider later; the extension never needs to know which one it got. +export interface EntitlementResult { + entitlements: string[]; + error?: "invalid_code" | "expired_code"; +} + +export interface EntitlementProvider { + resolve(): Promise; +} + +// v1 local stub: reads /entitlements.toml — `codes = [...]` (+ optional +// `expired = [...]`). Missing file = public-only, silently. Malformed = named error +// with public fallback: an entitlement failure must never dead-end the session. +// Sync core so the (synchronous) session-prep path can use it; the async +// EntitlementProvider interface is what the future redemption service implements. +export function readLocalEntitlements(configDir: string): EntitlementResult { + const file = path.join(configDir, "entitlements.toml"); + if (!fs.existsSync(file)) return { entitlements: [] }; + let parsed: { codes?: string[]; expired?: string[] }; + try { + parsed = parseToml(fs.readFileSync(file, "utf8")) as typeof parsed; + } catch { + return { entitlements: [], error: "invalid_code" }; + } + const result: EntitlementResult = { entitlements: parsed.codes ?? [] }; + if ((parsed.expired ?? []).length > 0) result.error = "expired_code"; + return result; +} + +export class LocalEntitlementProvider implements EntitlementProvider { + constructor(private readonly configDir: string) {} + + async resolve(): Promise { + return readLocalEntitlements(this.configDir); + } +} + +// Empty/absent entitlements = public score, always visible (spec §5). +export function filterRepertoire(scores: Score[], ents: string[]): Score[] { + return scores.filter( + (s) => (s.manifest.entitlements ?? []).length === 0 || s.manifest.entitlements!.some((e) => ents.includes(e)), + ); +} diff --git a/packages/extension/src/scores/interview_state.ts b/packages/extension/src/scores/interview_state.ts new file mode 100644 index 00000000..5222f18b --- /dev/null +++ b/packages/extension/src/scores/interview_state.ts @@ -0,0 +1,56 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; + +// Session-scoped interview state, [score] slice (spec §6; interview-UX §4 allows +// JSON or TOML — JSON here matches the plugin's established system.json sidecar +// pattern, since the opencode plugin deliberately carries no TOML parser). +// completed_stages + gates are the additive extension the stage guard reads. +// score_version pins the score version the session started on: revising a score +// never disturbs an in-flight session (spec §8 / success criterion 8). + +export interface GateRecord { + result: "pass" | "fail" | "override"; + ts: string; + override_reason: string; // "" when not an override — never null (state-file house rule) +} + +export interface ScoreState { + score_id: string; + score_version: number; + stage_cursor: string; + completed_stages: string[]; + answers: Record; + entity_refs: string[]; + gates: Record; +} + +const FILE = "interview_state.json"; + +export function newState(scoreId: string, scoreVersion: number): ScoreState { + return { + score_id: scoreId, + score_version: scoreVersion, + stage_cursor: "", + completed_stages: [], + answers: {}, + entity_refs: [], + gates: {}, + }; +} + +export function loadState(dir: string): ScoreState | undefined { + const file = path.join(dir, FILE); + if (!fs.existsSync(file)) return undefined; + try { + return JSON.parse(fs.readFileSync(file, "utf8")) as ScoreState; + } catch { + return undefined; + } +} + +export function saveState(dir: string, state: ScoreState): void { + const file = path.join(dir, FILE); + const tmp = file + ".tmp"; + fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + "\n"); + fs.renameSync(tmp, file); +} diff --git a/packages/extension/src/scores/lint.ts b/packages/extension/src/scores/lint.ts new file mode 100644 index 00000000..b36673c3 --- /dev/null +++ b/packages/extension/src/scores/lint.ts @@ -0,0 +1,27 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { RepertoireLoad } from "./loader"; + +// Repertoire-wide contract lint — spec §3. Entity/gate/version/schema_version rules are +// already enforced by validateScoreManifest at load time; this covers the cross-file rules. +export function lintRepertoire(load: RepertoireLoad, memoryRoot: string, knownEntitlements: string[]): string[] { + const errs: string[] = []; + for (const e of load.errors) errs.push(`${e.path}: ${e.errors.join("; ")}`); + const ids = new Set(load.scores.map((s) => s.manifest.id)); + for (const score of load.scores) { + const label = score.manifest.id; + for (const stage of score.manifest.stages) { + if (stage.template && !fs.existsSync(path.join(score.dir, stage.template))) + errs.push(`${label}: stage ${stage.id}: template does not resolve: ${stage.template}`); + for (const q of stage.questions ?? []) + for (const hook of q.memory_hooks ?? []) + if (!fs.existsSync(path.join(memoryRoot, `${hook}.md`))) + errs.push(`${label}: stage ${stage.id}: question ${q.id}: memory hook does not resolve: ${hook}`); + } + const from = score.manifest.derived_from; + if (from && !ids.has(from)) errs.push(`${label}: derived_from names an unknown score id: ${from}`); + for (const ent of score.manifest.entitlements ?? []) + if (!knownEntitlements.includes(ent)) errs.push(`${label}: unregistered entitlement id: ${ent}`); + } + return errs; +} diff --git a/packages/extension/src/scores/loader.ts b/packages/extension/src/scores/loader.ts new file mode 100644 index 00000000..049d8dd0 --- /dev/null +++ b/packages/extension/src/scores/loader.ts @@ -0,0 +1,42 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { parse as parseYaml } from "yaml"; +import { ScoreManifest, validateScoreManifest } from "./schema"; + +export interface Score { + manifest: ScoreManifest; + body: string; + dir: string; +} + +export interface RepertoireLoad { + scores: Score[]; + errors: { path: string; errors: string[] }[]; +} + +export function parseScoreMd(content: string, sourcePath = ""): { manifest: ScoreManifest; body: string } { + const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/); + if (!m) throw new Error(`${sourcePath}: missing --- frontmatter block`); + const manifest = parseYaml(m[1]) as ScoreManifest; + const errs = validateScoreManifest(manifest); + if (errs.length) throw new Error(`${sourcePath}: invalid score manifest:\n ${errs.join("\n ")}`); + return { manifest, body: m[2] ?? "" }; +} + +// A broken score must never take down the repertoire — it is reported, not thrown. +export function loadRepertoire(root: string): RepertoireLoad { + const out: RepertoireLoad = { scores: [], errors: [] }; + if (!fs.existsSync(root)) return out; + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory() || entry.name === "memory") continue; + const scorePath = path.join(root, entry.name, "SCORE.md"); + if (!fs.existsSync(scorePath)) continue; + try { + const { manifest, body } = parseScoreMd(fs.readFileSync(scorePath, "utf8"), scorePath); + out.scores.push({ manifest, body, dir: path.join(root, entry.name) }); + } catch (e) { + out.errors.push({ path: scorePath, errors: [String(e)] }); + } + } + return out; +} diff --git a/packages/extension/src/scores/router.ts b/packages/extension/src/scores/router.ts new file mode 100644 index 00000000..acc98fcc --- /dev/null +++ b/packages/extension/src/scores/router.ts @@ -0,0 +1,43 @@ +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. +const SYSTEM_FIRST_SCORE = "pulse-designer"; + +export function buildRouterSection(visible: Score[]): string { + const cards = visible.filter((s) => s.manifest.id !== SYSTEM_FIRST_SCORE); + 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', + "`amicode_ask` when available, with these options:", + "", + ]; + if (cards.length > 0) { + lines.push("**Start from an application** — offer these entry cards:", ""); + for (const s of cards) { + const m = s.manifest; + const badge = m.device ? (m.device.qpu_runnable ? "QPU-runnable" : "emulator-only") : ""; + const bits = [m.outcome, m.duration_estimate, badge].filter(Boolean).join(" · "); + lines.push(`- \`${m.id}\` — **${m.name}**: ${bits}`); + } + lines.push(""); + } + lines.push( + `**Start from a system** — run the \`${SYSTEM_FIRST_SCORE}\` score (the platform-first interview below).`, + "", + "**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.", + ); + return lines.join("\n"); +} diff --git a/packages/extension/src/scores/schema.ts b/packages/extension/src/scores/schema.ts new file mode 100644 index 00000000..419921a0 --- /dev/null +++ b/packages/extension/src/scores/schema.ts @@ -0,0 +1,79 @@ +// Score manifest schema — spec §3 (spec-20260703-025314-amicode-scores-front-of-chain). +// Additive policy (spec §8): unknown fields are ignored; validation only rejects what is +// present-and-wrong or required-and-missing, so older runtimes tolerate newer scores. +export const KNOWN_ENTITIES = ["circuit", "system", "formulation", "pulse", "run", "device_session", "knowledge"] as const; +export const GATE_CLASSES = ["light", "heavy"] as const; +export const SUPPORTED_SCHEMA_VERSIONS = [1] as const; + +export interface Question { + id: string; + prompt: string; + choices?: string[]; + default?: string; + skip_if?: string; + memory_hooks?: string[]; + rationale_ref?: string; + autonomy?: string; +} + +export interface Stage { + id: string; + emits?: string[]; + questions?: Question[]; + executor?: string; + template?: string; + backend?: string; + gate?: (typeof GATE_CLASSES)[number]; + optional?: boolean; +} + +export interface ScoreManifest { + type: "score"; + schema_version: number; + id: string; + version: number; + derived_from: string | null; + name: string; + outcome: string; + audience: string[]; + duration_estimate?: string; + device?: { backend: string; qpu_runnable: boolean; emulators?: string[] }; + entitlements?: string[]; + stages: Stage[]; +} + +export function validateScoreManifest(raw: unknown): string[] { + const errs: string[] = []; + const m = raw as Partial; + if (m?.type !== "score") errs.push(`type must be "score"`); + if (!SUPPORTED_SCHEMA_VERSIONS.includes(m?.schema_version as 1)) + errs.push(`unsupported schema_version: ${m?.schema_version}`); + if (typeof m?.id !== "string" || !m.id) errs.push("id is required"); + if (!Number.isInteger(m?.version) || (m!.version as number) < 1) + errs.push(`version must be a positive integer, got ${m?.version}`); + if (typeof m?.name !== "string" || !m.name) errs.push("name is required"); + if (typeof m?.outcome !== "string" || !m.outcome) errs.push("outcome is required"); + if (!Array.isArray(m?.stages) || m!.stages!.length === 0) { + errs.push("stages must be a non-empty list"); + return errs; + } + const seen = new Set(); + for (const s of m.stages!) { + if (!s.id) { + errs.push("every stage needs an id"); + continue; + } + if (seen.has(s.id)) errs.push(`duplicate stage id: ${s.id}`); + seen.add(s.id); + for (const e of s.emits ?? []) + if (!(KNOWN_ENTITIES as readonly string[]).includes(e)) errs.push(`stage ${s.id}: unknown entity in emits: ${e}`); + if (s.gate && !(GATE_CLASSES as readonly string[]).includes(s.gate)) errs.push(`stage ${s.id}: unknown gate class: ${s.gate}`); + for (const q of s.questions ?? []) { + if (!q.id) errs.push(`stage ${s.id}: question missing id`); + if (!q.prompt) errs.push(`stage ${s.id}: question ${q.id ?? "?"} missing prompt`); + if (q.default && q.choices && !q.choices.includes(q.default)) + errs.push(`stage ${s.id}: question ${q.id}: default not among choices`); + } + } + return errs; +} diff --git a/packages/extension/src/scores/usage.ts b/packages/extension/src/scores/usage.ts new file mode 100644 index 00000000..cb9bf17d --- /dev/null +++ b/packages/extension/src/scores/usage.ts @@ -0,0 +1,93 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; + +// Usage capture (spec §8: "usage is the design input"). v1 captures, does not learn: +// append-only JSONL per session, timestamps supplied by the caller. This is the +// decision→outcome substrate the learned-traversal work consumes later. + +export type UsageEvent = + | { kind: "session_started"; ts: string; score_id: string; score_version: number } + | { kind: "stage_entered"; ts: string; stage: string } + | { kind: "stage_completed"; ts: string; stage: string } + | { kind: "question_answered"; ts: string; stage: string; question_id: string; default_taken: boolean } + | { kind: "off_path"; ts: string; from_stage: string } + | { kind: "gate"; ts: string; gate: string; result: "pass" | "fail" | "override"; override_reason?: string } + | { kind: "resumed"; ts: string; stage: string }; + +const FILE = "usage.jsonl"; + +export function appendUsage(dir: string, event: UsageEvent): void { + fs.appendFileSync(path.join(dir, FILE), JSON.stringify(event) + "\n"); +} + +export function readUsage(dir: string): UsageEvent[] { + const file = path.join(dir, FILE); + if (!fs.existsSync(file)) return []; + const events: UsageEvent[] = []; + for (const line of fs.readFileSync(file, "utf8").split("\n")) { + if (!line.trim()) continue; + try { + events.push(JSON.parse(line) as UsageEvent); + } catch { + // torn trailing write — tolerate, the funnel skeleton survives + } + } + return events; +} + +export interface Traversal { + score_id: string; + score_version: number; + funnel: { stage: string; entered: boolean; completed: boolean }[]; + off_path_count: number; + defaults_taken: number; + questions_answered: number; + gates: { gate: string; result: string }[]; +} + +export function reconstructTraversal(events: UsageEvent[]): Traversal { + const t: Traversal = { + score_id: "", + score_version: 0, + funnel: [], + off_path_count: 0, + defaults_taken: 0, + questions_answered: 0, + gates: [], + }; + const byStage = new Map(); + for (const e of events) { + switch (e.kind) { + case "session_started": + t.score_id = e.score_id; + t.score_version = e.score_version; + break; + case "stage_entered": { + if (!byStage.has(e.stage)) { + const row = { stage: e.stage, entered: true, completed: false }; + byStage.set(e.stage, row); + t.funnel.push(row); + } + break; + } + case "stage_completed": { + const row = byStage.get(e.stage); + if (row) row.completed = true; + break; + } + case "question_answered": + t.questions_answered += 1; + if (e.default_taken) t.defaults_taken += 1; + break; + case "off_path": + t.off_path_count += 1; + break; + case "gate": + t.gates.push({ gate: e.gate, result: e.result }); + break; + case "resumed": + break; + } + } + return t; +} diff --git a/packages/extension/test/agents_md.test.ts b/packages/extension/test/agents_md.test.ts index be17d45b..90baf625 100644 --- a/packages/extension/test/agents_md.test.ts +++ b/packages/extension/test/agents_md.test.ts @@ -43,3 +43,61 @@ describe('AGENTS.md teaches the D9/D10 script-authoring workflow', () => { expect(AGENTS).not.toMatch(/load_pulse/) }) }) + +describe('AGENTS.md pulse-designer interview (Layer 0)', () => { + it('scopes the interview to the pulse-designer persona and never forces it on a specific ask', () => { + expect(AGENTS).toMatch(/pulse-designer/) + expect(AGENTS).toMatch(/skip straight to\s+the\s+workflow/i) + expect(AGENTS).toMatch(/fast-forward/i) + }) + it('identity: Amico/Amicode, never self-describes as opencode; interview kicks off proactively on greetings', () => { + expect(AGENTS).toMatch(/You are \*\*Amico\*\*/) + expect(AGENTS).toMatch(/NOT "opencode"/) + expect(AGENTS).toMatch(/never describe yourself as an interactive CLI tool/i) + expect(AGENTS).toMatch(/\*\*proactively\*\*/i) + expect(AGENTS).toMatch(/greeting or no specific request/i) + }) + it('enforces one-question-at-a-time cadence', () => { + expect(AGENTS).toMatch(/ONE question at a time/) + expect(AGENTS).toMatch(/Never batch/i) + }) + it('walks the stage chain in order', () => { + const stages = ['PLATFORM', 'MODEL', 'MODE', 'PROBLEM', 'FORMULATION', 'SOLVE PARAMS', 'INSPECT', 'HARDWARE / CALIBRATE'] + // Match the bold stage markers — bare indexOf collides on prefixes (MODE ⊂ MODEL). + const idx = stages.map((s) => AGENTS.indexOf(`**${s}**`)) + idx.forEach((i, k) => expect(i, `stage ${stages[k]} present`).toBeGreaterThan(-1)) + for (let k = 1; k < idx.length; k++) expect(idx[k], `${stages[k]} after ${stages[k - 1]}`).toBeGreaterThan(idx[k - 1]) + }) + it('shows the transmon Hamiltonian in LaTeX and is honest about Rydberg scope', () => { + expect(AGENTS).toContain('\\hat H/\\hbar') + expect(AGENTS).toMatch(/transmon-only/i) + expect(AGENTS).toMatch(/rydberg/i) + }) + it('names the amicode_* recording tools as bookkeeping, not gates, with bash still the launch mechanism', () => { + for (const t of [ + 'amicode_ask', + 'amicode_pick_system', + 'amicode_set_model', + 'amicode_formulate', + 'amicode_solve', + 'amicode_to_hardware', + 'amicode_calibrate', + ]) { + expect(AGENTS).toContain(t) + } + expect(AGENTS).toMatch(/bookkeeping, not gates/) + expect(AGENTS).toMatch(/bash\s+launch is still the mechanism/i) + }) + it('keeps the guardrails: T-vs-N convention and no silent global co-optimization', () => { + expect(AGENTS).toMatch(/`T` = scalar gate time/) + expect(AGENTS).toMatch(/`N` = number of timesteps/) + expect(AGENTS).toMatch(/Never silently\s+co-optimize/i) + }) + it('leaves no unknown {{...}} placeholder after session-prep substitution', () => { + const substituted = AGENTS.replace(/\{\{TEMPLATE_PATH\}\}/g, '/abs/solve_template.jl').replace( + /\{\{JULIA_PROJECT\}\}/g, + '/abs/julia', + ) + expect(substituted).not.toMatch(/\{\{[A-Z_]+\}\}/) + }) +}) diff --git a/packages/extension/test/amicode_tools.test.ts b/packages/extension/test/amicode_tools.test.ts new file mode 100644 index 00000000..d3c4c73b --- /dev/null +++ b/packages/extension/test/amicode_tools.test.ts @@ -0,0 +1,214 @@ +// Tests for the amicode_* tool pack's entity layer (opencode-plugin/entities.ts). +// +// entities.ts is deliberately dependency-free (it is imported by the opencode +// plugin, which executes inside opencode's embedded Bun runtime, NOT in the +// extension bundle) — so these tests exercise it as plain functions. Round-trips +// go through `smol-toml`, the SAME parser @amicode/schema and the extension use +// (run_dir_reader.ts, schema/src/index.ts) — what these serializers emit must be +// readable by the validators downstream. +// +// The plugin module itself (amicode_tools.ts) is NOT imported here: it holds a +// module-scope console.log + fs side effects and must keep a single plugin-function +// export (opencode's getLegacyPlugins throws on any extra export). Its runtime +// loading is verified against the real binary (see the night-build handoff), not +// in vitest. +import { describe, it, expect } from 'vitest' +import { parse } from 'smol-toml' +import { + systemToml, + formulationToml, + runStubToml, + deviceSessionStubToml, + calibrationStubToml, + validateSystem, + validateFormulation, + updateSystem, + type SystemEntity, + type FormulationEntity, +} from '../opencode-plugin/entities' + +const SYS: SystemEntity = { + platform: 'transmon', + levels: 3, + params: { omega: 4.8, delta: -0.2 }, +} + +const FORM: FormulationEntity = { + problem: 'gate_synthesis', + target: 'X', + objective: 'unitary infidelity', + constraints: ['amplitude bound (drive_max)', 'smoothness'], +} + +describe('systemToml', () => { + it('emits valid TOML that round-trips through smol-toml (the repo parser)', () => { + const doc = parse(systemToml(SYS)) as any + expect(doc.system).toBeDefined() // [system] header + expect(doc.system.platform).toBe('transmon') + expect(doc.system.levels).toBe(3) + expect(doc.system.params.omega).toBeCloseTo(4.8) + expect(doc.system.params.delta).toBeCloseTo(-0.2) + }) + it('stamps an ISO-8601 `recorded` field (quoted string — parseable, no TomlDate surprises)', () => { + const doc = parse(systemToml(SYS)) as any + expect(typeof doc.system.recorded).toBe('string') + expect(Number.isNaN(Date.parse(doc.system.recorded))).toBe(false) + }) + it('accepts the levels boundary values 2 and 6', () => { + expect(() => systemToml({ ...SYS, levels: 2 })).not.toThrow() + expect(() => systemToml({ ...SYS, levels: 6 })).not.toThrow() + }) + it('rejects an unknown platform', () => { + expect(() => systemToml({ ...SYS, platform: 'flux-capacitor' as any })).toThrow(/platform/) + }) + it('rejects levels < 2, > 6, and non-integers', () => { + expect(() => systemToml({ ...SYS, levels: 1 })).toThrow(/levels/) + expect(() => systemToml({ ...SYS, levels: 7 })).toThrow(/levels/) + expect(() => systemToml({ ...SYS, levels: 3.5 })).toThrow(/levels/) + }) + it('rejects non-finite param values (NaN/Infinity have no TOML representation)', () => { + expect(() => systemToml({ ...SYS, params: { omega: NaN } })).toThrow(/param/) + expect(() => systemToml({ ...SYS, params: { omega: Infinity } })).toThrow(/param/) + }) + it('quotes param keys that are not TOML bare keys', () => { + const doc = parse(systemToml({ ...SYS, params: { 'drive max': 0.2 } })) as any + expect(doc.system.params['drive max']).toBeCloseTo(0.2) + }) +}) + +describe('formulationToml', () => { + it('round-trips problem/target/objective/constraints under [formulation]', () => { + const doc = parse(formulationToml(FORM)) as any + expect(doc.formulation.problem).toBe('gate_synthesis') + expect(doc.formulation.target).toBe('X') + expect(doc.formulation.objective).toBe('unitary infidelity') + expect(doc.formulation.constraints).toEqual(FORM.constraints) + expect(Number.isNaN(Date.parse(doc.formulation.recorded))).toBe(false) + }) + it('escapes quotes, backslashes, and newlines in string values (round-trip exact)', () => { + const nasty = 'say "hi" \\ then\nnewline\ttab' + const doc = parse(formulationToml({ ...FORM, target: nasty, constraints: [nasty] })) as any + expect(doc.formulation.target).toBe(nasty) + expect(doc.formulation.constraints).toEqual([nasty]) + }) + it('rejects an empty or whitespace-only target', () => { + expect(() => formulationToml({ ...FORM, target: '' })).toThrow(/target/) + expect(() => formulationToml({ ...FORM, target: ' ' })).toThrow(/target/) + }) + it('rejects an empty problem', () => { + expect(() => formulationToml({ ...FORM, problem: '' })).toThrow(/problem/) + }) +}) + +describe('validateSystem / validateFormulation', () => { + it('return [] for valid entities', () => { + expect(validateSystem(SYS)).toEqual([]) + expect(validateFormulation(FORM)).toEqual([]) + }) + it('name the offending field in each problem message', () => { + expect(validateSystem({ ...SYS, platform: 'nope' as any }).join(' ')).toMatch(/platform/) + expect(validateSystem({ ...SYS, levels: 99 }).join(' ')).toMatch(/levels/) + expect(validateFormulation({ ...FORM, target: '' }).join(' ')).toMatch(/target/) + }) +}) + +describe('updateSystem (the amicode_set_model merge)', () => { + it('merges levels and params, preserving untouched params and the platform', () => { + const merged = updateSystem(SYS, { levels: 4, params: { drive_max: 0.2, delta: -0.25 } }) + expect(merged.platform).toBe('transmon') + expect(merged.levels).toBe(4) + expect(merged.params.omega).toBeCloseTo(4.8) // untouched param preserved + expect(merged.params.delta).toBeCloseTo(-0.25) // overwritten + expect(merged.params.drive_max).toBeCloseTo(0.2) // added + }) + it('does not mutate the input entity', () => { + const before = JSON.parse(JSON.stringify(SYS)) + updateSystem(SYS, { levels: 5, params: { omega: 5.1 } }) + expect(SYS).toEqual(before) + }) + it('leaves levels alone when the patch omits it', () => { + expect(updateSystem(SYS, { params: { drive_max: 0.3 } }).levels).toBe(3) + }) + it('throws when the merge would produce an invalid entity', () => { + expect(() => updateSystem(SYS, { levels: 9 })).toThrow(/levels/) + expect(() => updateSystem(SYS, { params: { omega: NaN } })).toThrow(/param/) + }) +}) + +describe('runStubToml (bookkeeping stub — NOT amico-run\'s run.toml)', () => { + it('round-trips refs + launched_via under [run]', () => { + const doc = parse(runStubToml({ + formulation_ref: '/home/u/.amico/runs/default/_entities/formulation.toml', + system_ref: '/home/u/.amico/runs/default/_entities/system.toml', + run_dir: '/home/u/.amico/runs/default/20260703-021500-abcd', + note: 'X gate, defaults', + })) as any + expect(doc.run.launched_via).toBe('bash amico-run') // the tool never launches — bash does + expect(doc.run.formulation_ref).toMatch(/formulation\.toml$/) + expect(doc.run.system_ref).toMatch(/system\.toml$/) + expect(doc.run.run_dir).toMatch(/20260703-021500-abcd$/) + expect(doc.run.note).toBe('X gate, defaults') + expect(Number.isNaN(Date.parse(doc.run.recorded))).toBe(false) + }) + it('omits absent optional refs instead of writing empty strings', () => { + const doc = parse(runStubToml({})) as any + expect(doc.run.launched_via).toBe('bash amico-run') + expect('formulation_ref' in doc.run).toBe(false) + expect('system_ref' in doc.run).toBe(false) + expect('note' in doc.run).toBe(false) + }) +}) + +describe('deviceSessionStubToml (stage-8 guided stub — NO device I/O in this build)', () => { + it('round-trips refs + the fixed gate/checks under [device_session]', () => { + const doc = parse(deviceSessionStubToml({ + pulse_ref: '/home/u/.amico/runs/default/20260703-021500-abcd/pulse.jld2', + run_dir: '/home/u/.amico/runs/default/20260703-021500-abcd', + note: 'X gate pulse, F=0.9999', + })) as any + expect(doc.device_session.gate).toBe('pending-human-signoff') // never auto-approved + expect(doc.device_session.checks).toEqual([ // the send-to-device gate's auto checks + 'fidelity>=threshold', '|drive|<=cap', 'bandwidth', 'leakage', + ]) + expect(doc.device_session.pulse_ref).toMatch(/pulse\.jld2$/) + expect(doc.device_session.run_dir).toMatch(/20260703-021500-abcd$/) + expect(doc.device_session.note).toBe('X gate pulse, F=0.9999') + expect(Number.isNaN(Date.parse(doc.device_session.recorded))).toBe(false) + }) + it('omits absent optional refs; gate + checks are always present', () => { + const doc = parse(deviceSessionStubToml({})) as any + expect(doc.device_session.gate).toBe('pending-human-signoff') + expect(doc.device_session.checks).toHaveLength(4) + expect('pulse_ref' in doc.device_session).toBe(false) + expect('run_dir' in doc.device_session).toBe(false) + expect('note' in doc.device_session).toBe(false) + }) + it('rejects given-but-empty refs (a caller bug, not an omission)', () => { + expect(() => deviceSessionStubToml({ pulse_ref: '' })).toThrow(/pulse_ref/) + expect(() => deviceSessionStubToml({ run_dir: ' ' })).toThrow(/run_dir/) + }) +}) + +describe('calibrationStubToml (guided follow-up stub — loop not wired in this build)', () => { + it('round-trips the ref + fixed loop/status under [calibration]', () => { + const doc = parse(calibrationStubToml({ + device_session_ref: '/home/u/.amico/runs/default/_entities/device_session.toml', + note: 'after first hardware shots', + })) as any + expect(doc.calibration.loop).toBe('ILC') // the loop that follows hardware runs + expect(doc.calibration.status).toBe('not-wired') // honest: recorded follow-up only tonight + expect(doc.calibration.device_session_ref).toMatch(/device_session\.toml$/) + expect(doc.calibration.note).toBe('after first hardware shots') + expect(Number.isNaN(Date.parse(doc.calibration.recorded))).toBe(false) + }) + it('omits absent optionals; loop + status are always present', () => { + const doc = parse(calibrationStubToml({})) as any + expect(doc.calibration.loop).toBe('ILC') + expect(doc.calibration.status).toBe('not-wired') + expect('device_session_ref' in doc.calibration).toBe(false) + expect('note' in doc.calibration).toBe(false) + }) + it('rejects a given-but-empty device_session_ref', () => { + expect(() => calibrationStubToml({ device_session_ref: '' })).toThrow(/device_session_ref/) + }) +}) diff --git a/packages/extension/test/opencode_config.test.ts b/packages/extension/test/opencode_config.test.ts index 9620d58d..be12b68e 100644 --- a/packages/extension/test/opencode_config.test.ts +++ b/packages/extension/test/opencode_config.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest' import { existsSync, mkdtempSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs' import { tmpdir, homedir } from 'node:os' -import { join } from 'node:path' +import { join, isAbsolute } from 'node:path' import { execFileSync } from 'node:child_process' import { prepareOpencodeProject, resolveJuliaProject, buildOpencodeConfigContent } from '../src/opencode_config' @@ -47,6 +47,42 @@ describe('buildOpencodeConfigContent', () => { expect(cfg.permission.edit).toBe('allow') // fills the FILL-IN block expect(cfg.permission.webfetch).toBeUndefined() // unused by the solve flow — dropped }) + it('registers the amicode_* plugin by ABSOLUTE default path — and the file actually exists', () => { + const cfg = JSON.parse(buildOpencodeConfigContent('/abs/AGENTS.md', TPL)) + expect(Array.isArray(cfg.plugin)).toBe(true) + expect(cfg.plugin).toHaveLength(1) + expect(isAbsolute(cfg.plugin[0])).toBe(true) // opencode imports it by abs path + expect(cfg.plugin[0].endsWith(join('opencode-plugin', 'amicode_tools.ts'))).toBe(true) + expect(existsSync(cfg.plugin[0])).toBe(true) // __dirname default resolves to the real file + expect(existsSync(join(cfg.plugin[0], '..', 'entities.ts'))).toBe(true) // its relative import target too + }) + it('honors an explicit pluginPath (the follow-up extension.ts wiring)', () => { + const cfg = JSON.parse(buildOpencodeConfigContent('/abs/AGENTS.md', TPL, '/elsewhere/amicode_tools.ts')) + expect(cfg.plugin).toEqual(['/elsewhere/amicode_tools.ts']) + }) + it('declares the pulse-designer agent whose prompt defers to the AGENTS.md interview', () => { + const cfg = JSON.parse(buildOpencodeConfigContent('/abs/AGENTS.md', TPL)) + const pd = cfg.agent['pulse-designer'] + expect(pd.description).toBe('Guided quantum pulse design interview') + expect(pd.prompt).toContain('one question at a time') // the interview protocol + expect(pd.prompt).toContain("'Pulse-designer interview'") // script lives in AGENTS.md, not here + expect(pd.prompt).toContain('amicode_') // record stages via the tool pack + expect(pd.prompt).toContain('solve workflow') // launches stay on the bash workflow + }) + it('grants external_directory on the entities dir (default + $AMICODE_ENTITIES_DIR override)', () => { + const defGrant = join(homedir(), '.amico', 'runs', 'default', '_entities') + '/**' + const cfg = JSON.parse(buildOpencodeConfigContent('/abs/AGENTS.md', TPL)) + expect(cfg.permission.external_directory[defGrant]).toBe('allow') + const prev = process.env.AMICODE_ENTITIES_DIR + process.env.AMICODE_ENTITIES_DIR = '/custom/entities' + try { + const cfg2 = JSON.parse(buildOpencodeConfigContent('/abs/AGENTS.md', TPL)) + expect(cfg2.permission.external_directory['/custom/entities/**']).toBe('allow') // grant follows the plugin + } finally { + if (prev === undefined) delete process.env.AMICODE_ENTITIES_DIR + else process.env.AMICODE_ENTITIES_DIR = prev + } + }) it('never embeds a credential in the config content (D11 no-store/no-inject regression guard)', () => { // amico owns no secret: the config it writes into OPENCODE_CONFIG_CONTENT must // never carry a provider key, even when one is present in the environment. @@ -104,6 +140,15 @@ describe.skipIf(!existsSync(OC_BIN))('opencode config injection + merge (1.17.3) // the user's global config SURVIVED the deep-merge: expect(cfg.model).toBe('anthropic/claude-sonnet-4-6') // provider/model preserved (Q129 needs this) expect(cfg.permission.doom_loop).toBe('deny') // user permission key preserved (#22) + // L0 pulse-designer registration survived resolution against the REAL binary. + // NOTE: `debug config` IMPORTS listed plugins before printing JSON to stdout + // (verified on 1.17.3) — so JSON.parse(out) succeeding above doubles as a + // regression guard that amicode_tools.ts loads cleanly AND never writes to + // stdout at module scope (its load line must stay on stderr). + expect(cfg.plugin).toHaveLength(1) + expect(cfg.plugin[0].endsWith(join('opencode-plugin', 'amicode_tools.ts'))).toBe(true) + expect(cfg.agent['pulse-designer'].description).toBe('Guided quantum pulse design interview') + expect(cfg.agent['pulse-designer'].prompt).toContain('one question at a time') }) }) diff --git a/packages/extension/test/packaging.test.ts b/packages/extension/test/packaging.test.ts index eb21ab9f..0d22f72c 100644 --- a/packages/extension/test/packaging.test.ts +++ b/packages/extension/test/packaging.test.ts @@ -16,6 +16,10 @@ const REQUIRED = [ 'extension/demo/run/run.log', // inspector reads run.log for the demo's stats row; *.log-gitignored so easy to drop 'extension/media/brand.css', // style variables (design-owned) — must ship, else an unstyled inspector 'extension/media/layout.css', // layout selectors (design-owned) — must ship, else an unstyled inspector + 'extension/scores/pulse-designer/SCORE.md', // score #0 — the interview is data; a dropped repertoire = silent prose fallback + 'extension/scores/pulse-designer/templates/solve.jl', // score-local vetted template (lint requires it resolves) + 'extension/scores/memory/free-phase-objective-only.md', + 'extension/scores/entitlements.toml', // entitlement registry — gating breaks silently without it ] // Guards against a silently-dropped runtime asset (the β.2 .gitignore-fallback diff --git a/packages/extension/test/scores/compiler.test.ts b/packages/extension/test/scores/compiler.test.ts new file mode 100644 index 00000000..51cff543 --- /dev/null +++ b/packages/extension/test/scores/compiler.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from "vitest"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { compileScore, spliceIntoAgentsMd } from "../../src/scores/compiler"; +import { loadRepertoire } from "../../src/scores/loader"; + +const SCORES_ROOT = path.resolve(__dirname, "..", "..", "scores"); + +function score0() { + const load = loadRepertoire(SCORES_ROOT); + const s = load.scores.find((x) => x.manifest.id === "pulse-designer"); + if (!s) throw new Error("score #0 missing"); + return s; +} + +describe("compileScore (score #0)", () => { + const md = compileScore(score0()); + + it("keeps the heading the agent prompt references", () => { + expect(md.startsWith("## Pulse-designer interview")).toBe(true); + }); + it("emits every stage id in manifest order", () => { + const ids = ["platform", "model", "mode", "problem", "formulate", "solve", "inspect", "hardware"]; + const idx = ids.map((s) => md.indexOf(`**${s}**`)); + idx.forEach((i, k) => expect(i, `stage ${ids[k]}`).toBeGreaterThan(-1)); + for (let k = 1; k < idx.length; k++) expect(idx[k]).toBeGreaterThan(idx[k - 1]); + }); + it("marks defaults (recommended) and routes choice questions via amicode_ask", () => { + expect(md).toContain("transmon (recommended)"); + expect(md).toContain("amicode_ask"); + }); + it("substitutes the score-relative template to an absolute path", () => { + expect(md).toContain(path.join(SCORES_ROOT, "pulse-designer", "templates", "solve.jl")); + }); + it("carries the prose body verbatim (LaTeX intact)", () => { + expect(md).toContain("\\hat H/\\hbar"); + expect(md).toContain("Never silently co-optimize"); + }); + it("mentions memory hooks for the [Why?] affordance", () => { + expect(md).toContain("free-phase-objective-only"); + }); + it("is deterministic", () => { + expect(compileScore(score0())).toBe(md); + }); + it("leaves no unknown {{...}} placeholders", () => { + expect(md).not.toMatch(/\{\{[A-Z_]+\}\}/); + }); +}); + +describe("spliceIntoAgentsMd", () => { + const agents = fs.readFileSync(path.resolve(__dirname, "..", "..", "AGENTS.md"), "utf8"); + + it("replaces the interview section, keeps surrounding sections", () => { + const out = spliceIntoAgentsMd(agents, "## Onset router\nROUTER", "## Pulse-designer interview\nCOMPILED"); + expect(out).toContain("## Onset router"); + expect(out).toContain("COMPILED"); + expect(out).toContain("## Identity"); // section before, untouched + expect(out).toContain("## Scope & parameter guidance"); // section after, untouched + // the hardcoded interview body is gone from the spliced output + expect(out).not.toContain("Stages, in order:"); + // exactly one interview heading remains + expect(out.split("## Pulse-designer interview")).toHaveLength(2); + }); + it("appends when the heading is missing (never loses content)", () => { + const out = spliceIntoAgentsMd("# Something else\n", "## R", "## C"); + expect(out).toContain("# Something else"); + expect(out).toContain("## C"); + }); +}); diff --git a/packages/extension/test/scores/entitlements_router.test.ts b/packages/extension/test/scores/entitlements_router.test.ts new file mode 100644 index 00000000..ea5c67d5 --- /dev/null +++ b/packages/extension/test/scores/entitlements_router.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { LocalEntitlementProvider, filterRepertoire } from "../../src/scores/entitlements"; +import { buildRouterSection } from "../../src/scores/router"; +import { Score } from "../../src/scores/loader"; + +function score(id: string, ents: string[], extra: Partial = {}): Score { + return { + manifest: { + type: "score", schema_version: 1, id, version: 1, derived_from: null, + name: `Name of ${id}`, outcome: `Outcome of ${id}`, audience: ["t"], + entitlements: ents, stages: [{ id: "one" }], ...extra, + }, + body: "", + dir: `/scores/${id}`, + }; +} + +describe("filterRepertoire (spec §5 entitlement semantics)", () => { + const pub = score("pulse-designer", []); + const gated = score("pasqal-mis", ["pasqal-hackathon-2026"], { + device: { backend: "pasqal", qpu_runnable: true }, + }); + + it("no code → public scores only", () => { + expect(filterRepertoire([pub, gated], []).map((s) => s.manifest.id)).toEqual(["pulse-designer"]); + }); + it("valid entitlement → gated scores visible", () => { + expect(filterRepertoire([pub, gated], ["pasqal-hackathon-2026"]).map((s) => s.manifest.id)).toEqual([ + "pulse-designer", + "pasqal-mis", + ]); + }); + it("absent entitlements field = public", () => { + const s = score("x", []); + delete (s.manifest as any).entitlements; + expect(filterRepertoire([s], [])).toHaveLength(1); + }); +}); + +describe("LocalEntitlementProvider", () => { + it("missing file → no entitlements, no error", async () => { + const p = new LocalEntitlementProvider(path.join(os.tmpdir(), "nope-" + Date.now())); + expect(await p.resolve()).toEqual({ entitlements: [] }); + }); + it("valid file → entitlements", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ents-")); + fs.writeFileSync(path.join(dir, "entitlements.toml"), `codes = ["pasqal-hackathon-2026"]\n`); + const p = new LocalEntitlementProvider(dir); + expect(await p.resolve()).toEqual({ entitlements: ["pasqal-hackathon-2026"] }); + }); + it("malformed file → named error + empty entitlements (public fallback, never a dead end)", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ents-")); + fs.writeFileSync(path.join(dir, "entitlements.toml"), "codes = not-toml["); + const p = new LocalEntitlementProvider(dir); + expect(await p.resolve()).toEqual({ entitlements: [], error: "invalid_code" }); + }); + it("expired entry → named error + surviving valid codes", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ents-")); + fs.writeFileSync( + path.join(dir, "entitlements.toml"), + `codes = ["pasqal-hackathon-2026"]\nexpired = ["old-code-2025"]\n`, + ); + const p = new LocalEntitlementProvider(dir); + expect(await p.resolve()).toEqual({ entitlements: ["pasqal-hackathon-2026"], error: "expired_code" }); + }); +}); + +describe("buildRouterSection", () => { + const pub = score("pulse-designer", []); + const gated = score("pasqal-mis", ["pasqal-hackathon-2026"], { + device: { backend: "pasqal", qpu_runnable: true }, + duration_estimate: "60–90 min", + }); + + 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("Bring your own problem"); + expect(md).toContain("Resume where you left off"); + 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")); + 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); + }); + it("entry cards carry outcome, duration, and device badge", () => { + const md = buildRouterSection([gated]); + expect(md).toContain("Outcome of pasqal-mis"); + expect(md).toContain("60–90 min"); + expect(md).toContain("QPU"); + }); + it("no application scores → no empty entry-card section", () => { + const md = buildRouterSection([pub]); + expect(md).not.toContain("Start from an application"); + }); + it("is deterministic", () => { + expect(buildRouterSection([pub, gated])).toBe(buildRouterSection([pub, gated])); + }); +}); diff --git a/packages/extension/test/scores/guard.test.ts b/packages/extension/test/scores/guard.test.ts new file mode 100644 index 00000000..091d3647 --- /dev/null +++ b/packages/extension/test/scores/guard.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { + checkStagePrereqs, + loadManifest, + loadScoreState, + saveScoreState, + freshScoreState, + type StageLite, +} from "../../opencode-plugin/score_guard"; + +const STAGES: StageLite[] = [ + { id: "platform" }, + { id: "model", emits: ["system"] }, + { id: "mode" }, + { id: "problem" }, + { id: "formulate", emits: ["formulation"] }, + { id: "solve", emits: ["run", "pulse"] }, + { id: "inspect" }, + { id: "device-sim", emits: ["device_session"], gate: "light" }, + { id: "hardware", emits: ["device_session"], optional: true }, +]; + +function state(completed: string[] = [], gates: Record = {}) { + const s = freshScoreState("pulse-designer", 1); + s.completed_stages = completed; + s.gates = gates as any; + return s; +} + +describe("checkStagePrereqs — entity dependencies, not conversation order", () => { + it("in-order entry is ok", () => { + expect(checkStagePrereqs(STAGES, state(["platform", "model"]), "formulate")).toEqual({ ok: true }); + }); + it("blocks a stage whose emitting prerequisite is incomplete", () => { + const r = checkStagePrereqs(STAGES, state(["platform"]), "formulate"); + expect(r).toEqual({ ok: false, code: "stage_order", required_stage: "model", missing_entities: ["system"] }); + }); + it("conversational (non-emitting) stages never block", () => { + // mode + problem incomplete — formulate only needs model's entities + expect(checkStagePrereqs(STAGES, state(["model"]), "formulate")).toEqual({ ok: true }); + }); + it("solve requires the formulation", () => { + const r = checkStagePrereqs(STAGES, state(["model"]), "solve"); + expect(r).toEqual({ ok: false, code: "stage_order", required_stage: "formulate", missing_entities: ["formulation"] }); + }); + it("optional emitting stages do not block later stages", () => { + // hardware is optional; nothing after it here, but ensure optional is excluded from blockers + expect(checkStagePrereqs(STAGES, state(["model", "formulate", "solve"]), "inspect")).toEqual({ ok: true }); + }); + it("loopback: re-entering a completed stage is allowed", () => { + expect(checkStagePrereqs(STAGES, state(["platform", "model", "formulate"]), "model")).toEqual({ ok: true }); + }); + it("gate stage without a passing record is blocked", () => { + const r = checkStagePrereqs(STAGES, state(["model", "formulate", "solve"]), "device-sim"); + expect(r).toEqual({ ok: false, code: "gate_required", gate: "light" }); + }); + it("gate stage with a pass record is allowed", () => { + const r = checkStagePrereqs(STAGES, state(["model", "formulate", "solve"], { light: { result: "pass" } }), "device-sim"); + expect(r).toEqual({ ok: true }); + }); + it("gate stage with an override record is allowed", () => { + const r = checkStagePrereqs(STAGES, state(["model", "formulate", "solve"], { light: { result: "override" } }), "device-sim"); + expect(r).toEqual({ ok: true }); + }); + it("unknown stage id → ok (fail-open for forward compatibility)", () => { + expect(checkStagePrereqs(STAGES, state(), "future-stage")).toEqual({ ok: true }); + }); +}); + +describe("manifest + state IO (entitiesDir contract)", () => { + it("loadManifest reads score_manifest.json, undefined when absent/corrupt", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "guard-")); + expect(loadManifest(dir)).toBeUndefined(); + fs.writeFileSync(path.join(dir, "score_manifest.json"), JSON.stringify({ manifest: { id: "x", version: 1, stages: STAGES } })); + expect(loadManifest(dir)?.id).toBe("x"); + fs.writeFileSync(path.join(dir, "score_manifest.json"), "{torn"); + expect(loadManifest(dir)).toBeUndefined(); + }); + it("score state round-trips and pins the version", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "guard-")); + expect(loadScoreState(dir)).toBeUndefined(); + const s = freshScoreState("pulse-designer", 1); + s.completed_stages.push("platform"); + saveScoreState(dir, s); + const loaded = loadScoreState(dir)!; + expect(loaded.score_version).toBe(1); + expect(loaded.completed_stages).toEqual(["platform"]); + }); +}); diff --git a/packages/extension/test/scores/interview_state.test.ts b/packages/extension/test/scores/interview_state.test.ts new file mode 100644 index 00000000..77874208 --- /dev/null +++ b/packages/extension/test/scores/interview_state.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { loadState, saveState, newState, ScoreState } from "../../src/scores/interview_state"; + +function tmp() { + return fs.mkdtempSync(path.join(os.tmpdir(), "istate-")); +} + +describe("interview_state [score]", () => { + it("fresh dir → undefined (caller starts a new session)", () => { + expect(loadState(tmp())).toBeUndefined(); + }); + + it("round-trips the full [score] shape", () => { + const dir = tmp(); + const state: ScoreState = { + score_id: "pulse-designer", + score_version: 1, + stage_cursor: "model", + completed_stages: ["platform"], + answers: { platform: "transmon" }, + entity_refs: ["_entities/system.toml"], + gates: { light: { result: "pass", ts: "2026-07-03T04:00:00Z", override_reason: "" } }, + }; + saveState(dir, state); + expect(loadState(dir)).toEqual(state); + }); + + it("absent optionals serialize as empty, not null", () => { + const dir = tmp(); + saveState(dir, newState("pulse-designer", 1)); + const raw = fs.readFileSync(path.join(dir, "interview_state.json"), "utf8"); + expect(raw).not.toContain("null"); + const loaded = loadState(dir)!; + expect(loaded.completed_stages).toEqual([]); + expect(loaded.answers).toEqual({}); + expect(loaded.gates).toEqual({}); + }); + + it("version pinning: loadState never upgrades a pinned version", () => { + const dir = tmp(); + saveState(dir, newState("pulse-designer", 1)); + // Repertoire moves to version 2; the in-flight session stays pinned. + const loaded = loadState(dir)!; + expect(loaded.score_version).toBe(1); + saveState(dir, { ...loaded, stage_cursor: "solve" }); + expect(loadState(dir)!.score_version).toBe(1); + }); + + it("corrupt state file → undefined, not a crash", () => { + const dir = tmp(); + fs.writeFileSync(path.join(dir, "interview_state.json"), "{not json"); + expect(loadState(dir)).toBeUndefined(); + }); +}); diff --git a/packages/extension/test/scores/loader.test.ts b/packages/extension/test/scores/loader.test.ts new file mode 100644 index 00000000..10aa4388 --- /dev/null +++ b/packages/extension/test/scores/loader.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { parseScoreMd, loadRepertoire } from "../../src/scores/loader"; + +const GOOD = `--- +type: score +schema_version: 1 +id: demo +version: 1 +derived_from: null +name: "Demo score" +outcome: "A demo outcome" +audience: [testers] +entitlements: [] +stages: + - id: one + questions: + - {id: q1, prompt: "Pick?", choices: [a, b], default: a} + - id: two + emits: [system] +--- +# Body + +The Hamiltonian is $\\hat H/\\hbar = \\omega \\hat a^\\dagger\\hat a$ — preserved verbatim. +`; + +describe("parseScoreMd", () => { + it("splits frontmatter from body, body verbatim", () => { + const { manifest, body } = parseScoreMd(GOOD, "demo/SCORE.md"); + expect(manifest.id).toBe("demo"); + expect(manifest.stages).toHaveLength(2); + expect(body).toContain("$\\hat H/\\hbar = \\omega \\hat a^\\dagger\\hat a$"); + }); + it("throws with the source path on missing frontmatter", () => { + expect(() => parseScoreMd("no frontmatter here", "x/SCORE.md")).toThrow(/x\/SCORE\.md/); + }); + it("throws with validation errors on an invalid manifest", () => { + const bad = GOOD.replace("\nversion: 1", "\nversion: 0"); + expect(() => parseScoreMd(bad, "y/SCORE.md")).toThrow(/positive integer/); + }); +}); + +describe("loadRepertoire", () => { + it("isolates broken scores — never throws, reports errors", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "scores-")); + fs.mkdirSync(path.join(root, "good")); + fs.writeFileSync(path.join(root, "good", "SCORE.md"), GOOD); + fs.mkdirSync(path.join(root, "broken")); + fs.writeFileSync(path.join(root, "broken", "SCORE.md"), "---\ntype: nonsense\n---\nbody"); + fs.mkdirSync(path.join(root, "memory")); // reserved dir, skipped + fs.mkdirSync(path.join(root, "empty")); // no SCORE.md, skipped + + const load = loadRepertoire(root); + expect(load.scores).toHaveLength(1); + expect(load.scores[0].manifest.id).toBe("demo"); + expect(load.scores[0].dir).toBe(path.join(root, "good")); + expect(load.errors).toHaveLength(1); + expect(load.errors[0].path).toContain("broken"); + }); + it("returns empty on a missing root", () => { + expect(loadRepertoire("/nonexistent/scores")).toEqual({ scores: [], errors: [] }); + }); +}); diff --git a/packages/extension/test/scores/prep_integration.test.ts b/packages/extension/test/scores/prep_integration.test.ts new file mode 100644 index 00000000..c3d547a1 --- /dev/null +++ b/packages/extension/test/scores/prep_integration.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { prepareOpencodeProject, buildOpencodeConfigContent, DEFAULT_SCORES_ROOT } from "../../src/opencode_config"; + +// Hermeticity: prepareOpencodeProject writes the plugin's manifest transport to +// entitiesDir(), which defaults into $HOME — point it at a tmp dir for the test run. +const ENTITIES_TMP = fs.mkdtempSync(path.join(os.tmpdir(), "prep-entities-")); +let prevEntitiesDir: string | undefined; +beforeAll(() => { + prevEntitiesDir = process.env.AMICODE_ENTITIES_DIR; + process.env.AMICODE_ENTITIES_DIR = ENTITIES_TMP; +}); +afterAll(() => { + if (prevEntitiesDir === undefined) delete process.env.AMICODE_ENTITIES_DIR; + else process.env.AMICODE_ENTITIES_DIR = prevEntitiesDir; +}); + +const AGENTS_SRC = path.resolve(__dirname, "..", "..", "AGENTS.md"); +const TEMPLATE_SRC = path.resolve(__dirname, "..", "..", "templates", "solve_template.jl"); + +function prep(overrides: Partial[0]> = {}) { + return prepareOpencodeProject({ + agentsSrc: AGENTS_SRC, + templateSrc: TEMPLATE_SRC, + juliaProject: "/abs/julia", + // isolate from any real ~/.amico/amicode/entitlements.toml on this machine + entitlementsDir: fs.mkdtempSync(path.join(os.tmpdir(), "no-ents-")), + ...overrides, + }); +} + +describe("prepareOpencodeProject × scores (spec §6)", () => { + it("splices router + compiled score #0 over the hardcoded interview section", () => { + const proj = prep(); + const agents = fs.readFileSync(proj.agentsPath, "utf8"); + expect(agents).toContain("## Onset router"); + expect(agents).toContain("Compiled from score `pulse-designer` v1"); + expect(agents).toContain("## Pulse-designer interview"); // heading preserved for the agent prompt + expect(agents).not.toContain("Stages, in order:"); // hardcoded body replaced + expect(agents).toContain("## Identity"); // engine sections intact + expect(agents).toContain("AMICODE_ITER"); // run-dir contract intact + expect(agents).not.toMatch(/\{\{[A-Z_]+\}\}/); // substitution complete, incl. compiled content + }); + + it("writes the score_manifest.json plugin transport (projectDir record + entitiesDir copy)", () => { + const proj = prep(); + const manifest = JSON.parse(fs.readFileSync(path.join(proj.projectDir, "score_manifest.json"), "utf8")); + expect(manifest.manifest.id).toBe("pulse-designer"); + expect(manifest.manifest.version).toBe(1); + expect(manifest.project_dir).toBe(proj.projectDir); + expect(manifest.score_dir).toBe(path.join(DEFAULT_SCORES_ROOT, "pulse-designer")); + // the copy the Bun-side guard actually reads (entitiesDir contract) + const guardCopy = JSON.parse(fs.readFileSync(path.join(ENTITIES_TMP, "score_manifest.json"), "utf8")); + expect(guardCopy.manifest.id).toBe("pulse-designer"); + }); + + it("FALLBACK: a corrupt scores root leaves the substituted AGENTS.md unchanged (never brick the boot)", () => { + const badRoot = fs.mkdtempSync(path.join(os.tmpdir(), "bad-scores-")); + fs.mkdirSync(path.join(badRoot, "pulse-designer")); + fs.writeFileSync(path.join(badRoot, "pulse-designer", "SCORE.md"), "---\ntype: junk\n---\n"); + const proj = prep({ scoresRoot: badRoot }); + const agents = fs.readFileSync(proj.agentsPath, "utf8"); + expect(agents).toContain("Stages, in order:"); // hardcoded interview kept as fallback + expect(agents).not.toContain("## Onset router"); + expect(fs.existsSync(path.join(proj.projectDir, "score_manifest.json"))).toBe(false); + }); + + it("missing scores root behaves like fallback (no throw)", () => { + const proj = prep({ scoresRoot: "/nonexistent/scores" }); + expect(fs.readFileSync(proj.agentsPath, "utf8")).toContain("Stages, in order:"); + }); +}); + +describe("buildOpencodeConfigContent × scores", () => { + it("grants external_directory on the scores root (templates + memory hooks)", () => { + const cfg = JSON.parse(buildOpencodeConfigContent("/abs/AGENTS.md", "/abs/templates/solve_template.jl")); + expect(cfg.permission.external_directory[`${DEFAULT_SCORES_ROOT}/**`]).toBe("allow"); + }); + it("grant follows a custom scores root", () => { + const cfg = JSON.parse( + buildOpencodeConfigContent("/abs/AGENTS.md", "/abs/templates/solve_template.jl", "/p/plugin.ts", "/custom/scores"), + ); + expect(cfg.permission.external_directory["/custom/scores/**"]).toBe("allow"); + }); +}); diff --git a/packages/extension/test/scores/repertoire_lint.test.ts b/packages/extension/test/scores/repertoire_lint.test.ts new file mode 100644 index 00000000..81ab81ba --- /dev/null +++ b/packages/extension/test/scores/repertoire_lint.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { parse as parseToml } from "smol-toml"; +import { loadRepertoire } from "../../src/scores/loader"; +import { lintRepertoire } from "../../src/scores/lint"; + +const EXT_ROOT = path.resolve(__dirname, "..", ".."); +const REAL_SCORES = path.join(EXT_ROOT, "scores"); + +function mkScore(root: string, id: string, opts: { template?: string; hooks?: string[]; derived?: string; ents?: string[] } = {}) { + const dir = path.join(root, id); + fs.mkdirSync(dir, { recursive: true }); + const q = opts.hooks ? `\n questions:\n - {id: q1, prompt: "P?", memory_hooks: [${opts.hooks.join(", ")}]}` : ""; + const tpl = opts.template ? `\n template: ${opts.template}` : ""; + fs.writeFileSync( + path.join(dir, "SCORE.md"), + `--- +type: score +schema_version: 1 +id: ${id} +version: 1 +derived_from: ${opts.derived ?? "null"} +name: "S ${id}" +outcome: "O" +audience: [t] +entitlements: [${(opts.ents ?? []).join(", ")}] +stages: + - id: one${q}${tpl} +--- +body`, + ); + return dir; +} + +function tmpRoot() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "lint-")); + fs.mkdirSync(path.join(root, "memory"), { recursive: true }); + return root; +} + +describe("lintRepertoire", () => { + it("flags an unresolvable template path", () => { + const root = tmpRoot(); + mkScore(root, "a", { template: "templates/missing.jl" }); + const errs = lintRepertoire(loadRepertoire(root), path.join(root, "memory"), []); + expect(errs.join()).toMatch(/template.*missing\.jl/i); + }); + it("flags an unresolvable memory hook", () => { + const root = tmpRoot(); + mkScore(root, "a", { hooks: ["no-such-hook"] }); + const errs = lintRepertoire(loadRepertoire(root), path.join(root, "memory"), []); + expect(errs.join()).toMatch(/memory hook.*no-such-hook/i); + }); + it("accepts a resolvable memory hook", () => { + const root = tmpRoot(); + fs.writeFileSync(path.join(root, "memory", "real-hook.md"), "fact"); + mkScore(root, "a", { hooks: ["real-hook"] }); + expect(lintRepertoire(loadRepertoire(root), path.join(root, "memory"), [])).toEqual([]); + }); + it("flags derived_from pointing at an unknown score id", () => { + const root = tmpRoot(); + mkScore(root, "a", { derived: "ghost" }); + const errs = lintRepertoire(loadRepertoire(root), path.join(root, "memory"), []); + expect(errs.join()).toMatch(/derived_from.*ghost/i); + }); + it("accepts derived_from pointing at a sibling score", () => { + const root = tmpRoot(); + mkScore(root, "base"); + mkScore(root, "fork", { derived: "base" }); + expect(lintRepertoire(loadRepertoire(root), path.join(root, "memory"), [])).toEqual([]); + }); + it("flags an unregistered entitlement id", () => { + const root = tmpRoot(); + mkScore(root, "a", { ents: ["typo-hackathon"] }); + const errs = lintRepertoire(loadRepertoire(root), path.join(root, "memory"), ["pasqal-hackathon-2026"]); + expect(errs.join()).toMatch(/entitlement.*typo-hackathon/i); + }); + it("carries loader errors as lint failures", () => { + const root = tmpRoot(); + const dir = path.join(root, "broken"); + fs.mkdirSync(dir); + fs.writeFileSync(path.join(dir, "SCORE.md"), "---\ntype: junk\n---\n"); + const errs = lintRepertoire(loadRepertoire(root), path.join(root, "memory"), []); + expect(errs.join()).toMatch(/broken/); + }); + + it("the REAL shipped repertoire lints clean", () => { + const registry = parseToml(fs.readFileSync(path.join(REAL_SCORES, "entitlements.toml"), "utf8")) as { known: string[] }; + const load = loadRepertoire(REAL_SCORES); + expect(lintRepertoire(load, path.join(REAL_SCORES, "memory"), registry.known)).toEqual([]); + }); +}); diff --git a/packages/extension/test/scores/schema.test.ts b/packages/extension/test/scores/schema.test.ts new file mode 100644 index 00000000..f8cd87f3 --- /dev/null +++ b/packages/extension/test/scores/schema.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from "vitest"; +import { validateScoreManifest, KNOWN_ENTITIES } from "../../src/scores/schema"; + +const VALID = { + type: "score", schema_version: 1, id: "pasqal-mis", version: 1, derived_from: null, + name: "Solve a graph problem", outcome: "An optimized waveform", audience: ["algorithms"], + duration_estimate: "60–90 min", + device: { backend: "pasqal", qpu_runnable: true, emulators: ["emu-mps"] }, + entitlements: ["pasqal-hackathon-2026"], + stages: [ + { id: "application", emits: ["circuit"], questions: [{ id: "graph", prompt: "Which graph?", choices: ["sample", "upload"], default: "sample" }] }, + { id: "solve", emits: ["run", "pulse"], executor: "cloud-altissimo", template: "templates/solve.jl" }, + { id: "device-sim", emits: ["device_session"], backend: "emu-mps", gate: "light" }, + { id: "device-qpu", emits: ["device_session"], backend: "fresnel", gate: "heavy" }, + ], +}; + +describe("validateScoreManifest", () => { + it("accepts a valid manifest", () => expect(validateScoreManifest(VALID)).toEqual([])); + it("rejects an unknown entity in emits", () => { + const m = structuredClone(VALID); (m.stages[0] as any).emits = ["blob"]; + expect(validateScoreManifest(m).join()).toMatch(/unknown entity.*blob/i); + }); + it("rejects an unknown gate class", () => { + const m = structuredClone(VALID); (m.stages[2] as any).gate = "medium"; + expect(validateScoreManifest(m).join()).toMatch(/unknown gate/i); + }); + it("rejects non-positive version", () => { + const m = structuredClone(VALID); m.version = 0; + expect(validateScoreManifest(m).join()).toMatch(/version/); + }); + it("rejects unsupported schema_version", () => { + const m = structuredClone(VALID); m.schema_version = 99; + expect(validateScoreManifest(m).join()).toMatch(/schema_version/); + }); + it("rejects duplicate stage ids", () => { + const m = structuredClone(VALID); m.stages.push({ id: "solve" } as any); + expect(validateScoreManifest(m).join()).toMatch(/duplicate stage/i); + }); + it("rejects a question missing id or prompt", () => { + const m = structuredClone(VALID); (m.stages[0] as any).questions = [{ prompt: "no id" }]; + expect(validateScoreManifest(m).join()).toMatch(/question.*id/i); + }); + it("rejects a default not among choices", () => { + const m = structuredClone(VALID); + (m.stages[0] as any).questions = [{ id: "q", prompt: "p", choices: ["a", "b"], default: "c" }]; + expect(validateScoreManifest(m).join()).toMatch(/default not among choices/i); + }); + it("IGNORES unknown fields (additive schema policy, spec §8)", () => { + const m = structuredClone(VALID); (m as any).future_field = { x: 1 }; + (m.stages[0] as any).future_stage_field = true; + expect(validateScoreManifest(m)).toEqual([]); + }); + it("rejects empty stages", () => { + const m = structuredClone(VALID); m.stages = []; + expect(validateScoreManifest(m).join()).toMatch(/stages/); + }); + it("exports the workflow-frames entity vocabulary", () => + expect(KNOWN_ENTITIES).toEqual(["circuit", "system", "formulation", "pulse", "run", "device_session", "knowledge"])); +}); diff --git a/packages/extension/test/scores/usage.test.ts b/packages/extension/test/scores/usage.test.ts new file mode 100644 index 00000000..8857595d --- /dev/null +++ b/packages/extension/test/scores/usage.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { appendUsage, readUsage, reconstructTraversal, UsageEvent } from "../../src/scores/usage"; + +function tmp() { + return fs.mkdtempSync(path.join(os.tmpdir(), "usage-")); +} + +const T = "2026-07-03T04:00:00Z"; + +describe("usage capture", () => { + it("appends one JSON line per event and reads them back", () => { + const dir = tmp(); + appendUsage(dir, { kind: "session_started", ts: T, score_id: "pulse-designer", score_version: 1 }); + appendUsage(dir, { kind: "stage_entered", ts: T, stage: "platform" }); + const lines = fs.readFileSync(path.join(dir, "usage.jsonl"), "utf8").trimEnd().split("\n"); + expect(lines).toHaveLength(2); + expect(readUsage(dir)).toHaveLength(2); + }); + + it("reader tolerates a trailing partial line", () => { + const dir = tmp(); + appendUsage(dir, { kind: "stage_entered", ts: T, stage: "platform" }); + fs.appendFileSync(path.join(dir, "usage.jsonl"), '{"kind":"stage_ent'); // torn write + expect(readUsage(dir)).toHaveLength(1); + }); + + it("empty/missing file → no events", () => { + expect(readUsage(tmp())).toEqual([]); + }); + + it("reconstructs a traversal funnel exactly (spec success criterion 8)", () => { + const events: UsageEvent[] = [ + { kind: "session_started", ts: T, score_id: "pulse-designer", score_version: 1 }, + { kind: "stage_entered", ts: T, stage: "platform" }, + { kind: "question_answered", ts: T, stage: "platform", question_id: "platform", default_taken: true }, + { kind: "stage_completed", ts: T, stage: "platform" }, + { kind: "stage_entered", ts: T, stage: "model" }, + { kind: "off_path", ts: T, from_stage: "model" }, + { kind: "stage_entered", ts: T, stage: "solve" }, + { kind: "gate", ts: T, gate: "light", result: "pass" }, + { kind: "stage_completed", ts: T, stage: "solve" }, + ]; + expect(reconstructTraversal(events)).toEqual({ + score_id: "pulse-designer", + score_version: 1, + funnel: [ + { stage: "platform", entered: true, completed: true }, + { stage: "model", entered: true, completed: false }, + { stage: "solve", entered: true, completed: true }, + ], + off_path_count: 1, + defaults_taken: 1, + questions_answered: 1, + gates: [{ gate: "light", result: "pass" }], + }); + }); +}); diff --git a/packages/extension/test/slow/interview_e2e.test.ts b/packages/extension/test/slow/interview_e2e.test.ts new file mode 100644 index 00000000..21cd54bf --- /dev/null +++ b/packages/extension/test/slow/interview_e2e.test.ts @@ -0,0 +1,222 @@ +import { describe, it, expect, afterAll } from 'vitest' +import { existsSync, mkdtempSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs' +import { tmpdir, homedir } from 'node:os' +import { join } from 'node:path' +import { spawn, type ChildProcess } from 'node:child_process' +import { buildOpencodeConfigContent, prepareOpencodeProject, resolveJuliaProject } from '../../src/opencode_config' + +// ============================================================================ +// T13 e2e — pulse-designer interview against the REAL vendored binary. +// +// Boots `opencode serve` with the SAME OPENCODE_CONFIG_CONTENT injection the +// extension performs (real builder import — no transcribed config, no drift; +// the sanctioned pattern from test/opencode_config.test.ts), extended with the +// Layer-0 registration: the pulse-designer agent block + the amicode_* plugin. +// +// Tiers (each skips independently, so the suite is green in any machine state): +// A. creds-free, hermetic HOME — agent registration visible via GET /agent +// B. creds-free, hermetic HOME — plugin module loads on session creation +// C. creds-gated, REAL HOME — two live interview turns (one-question +// cadence + LaTeX). Needs `opencode auth login` (or ANTHROPIC_API_KEY). +// +// NOTE: /health is NOT a real route at v1.17.3 (SPA fallback answers it) — +// readiness is polled on `GET /` + the listening log line instead. +// ============================================================================ + +const EXT = join(__dirname, '..', '..') +const OC_BIN = join(EXT, 'vendor', 'opencode', `${process.platform}-${process.arch}`, 'opencode') +const PLUGIN = join(EXT, 'opencode-plugin', 'amicode_tools.ts') +const AGENTS_SRC = join(EXT, 'AGENTS.md') + +const AUTH_JSON = join(homedir(), '.local', 'share', 'opencode', 'auth.json') +function hasCreds(): boolean { + if (process.env.AMICODE_E2E_LIVE === '1') return true // force: e.g. opencode's free anonymous tier resolves without auth.json + if (process.env.ANTHROPIC_API_KEY) return true + try { + return Object.keys(JSON.parse(readFileSync(AUTH_JSON, 'utf8'))).length > 0 + } catch { + return false + } +} + +/** The extension's real config content — since the L0 registration landed in + * buildOpencodeConfigContent itself (agent block + plugin path), the builder + * output is used verbatim: zero test-local drift. */ +function layer0Config(agentsPath: string): string { + return buildOpencodeConfigContent(agentsPath, join(EXT, 'templates', 'solve_template.jl')) +} + +interface Server { child: ChildProcess; url: string; log: () => string } +const servers: ChildProcess[] = [] + +async function serve(opts: { hermetic: boolean; port: number }): Promise { + let env: NodeJS.ProcessEnv + let agentsPath: string + if (opts.hermetic) { + const home = mkdtempSync(join(tmpdir(), 'e2ehome-')) + mkdirSync(join(home, '.config', 'opencode'), { recursive: true }) + writeFileSync(join(home, '.config', 'opencode', 'opencode.json'), JSON.stringify({})) + agentsPath = join(home, 'AGENTS.md') + writeFileSync(agentsPath, readFileSync(AGENTS_SRC, 'utf8')) // unsubstituted is fine for A/B + env = { ...process.env, HOME: home, XDG_CONFIG_HOME: join(home, '.config'), XDG_DATA_HOME: join(home, '.local', 'share') } + } else { + // Real home: user creds + global config load (deliberate, tiers C/D). AGENTS.md + // goes through the extension's REAL session prep so {{TEMPLATE_PATH}} / + // {{JULIA_PROJECT}} are substituted — stage 6 depends on the real paths. + const project = prepareOpencodeProject({ + agentsSrc: AGENTS_SRC, + templateSrc: join(EXT, 'templates', 'solve_template.jl'), + juliaProject: resolveJuliaProject(''), + }) + agentsPath = project.agentsPath + env = { ...process.env } + } + env.OPENCODE_CONFIG_CONTENT = layer0Config(agentsPath) + let buf = '' + const child = spawn(OC_BIN, ['serve', '--port', String(opts.port)], { env, stdio: ['ignore', 'pipe', 'pipe'] }) + servers.push(child) + child.stdout!.on('data', (c) => (buf += c)) + child.stderr!.on('data', (c) => (buf += c)) + const url = `http://127.0.0.1:${opts.port}` + const deadline = Date.now() + 30_000 + for (;;) { + try { + const r = await fetch(url + '/', { signal: AbortSignal.timeout(1000) }) + if (r.ok) break + } catch { /* not up yet */ } + if (Date.now() > deadline) throw new Error(`serve not ready in 30s; log:\n${buf.slice(0, 2000)}`) + await new Promise((r) => setTimeout(r, 300)) + } + return { child, url, log: () => buf } +} + +afterAll(() => { + for (const c of servers) { + c.kill('SIGTERM') + } +}) + +describe.skipIf(!existsSync(OC_BIN))('L0 registration against the real binary (creds-free)', () => { + it('A: pulse-designer appears in GET /agent', { timeout: 60_000 }, async () => { + const s = await serve({ hermetic: true, port: 14310 }) + const agents = (await (await fetch(s.url + '/agent')).json()) as Array<{ name: string }> + expect(agents.map((a) => a.name)).toContain('pulse-designer') + }) + + it.skipIf(!existsSync(PLUGIN))('B: amicode_tools plugin loads on session creation', { timeout: 60_000 }, async () => { + const s = await serve({ hermetic: true, port: 14311 }) + const r = await fetch(s.url + '/session', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }) + expect(r.ok).toBe(true) + const deadline = Date.now() + 15_000 + while (!s.log().includes('[amicode-tools]') && Date.now() < deadline) await new Promise((r) => setTimeout(r, 300)) + expect(s.log(), 'plugin load line in serve log').toContain('[amicode-tools]') + }) +}) + +describe.skipIf(!existsSync(OC_BIN) || !hasCreds())('live interview turns (creds required)', () => { + it('C: opens with ONE platform question, then LaTeX on "transmon"', { timeout: 300_000 }, async () => { + const s = await serve({ hermetic: false, port: 14312 }) + const ses = (await ( + await fetch(s.url + '/session', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }) + ).json()) as { id: string } + + const turn = async (text: string): Promise => { + const r = await fetch(`${s.url}/session/${ses.id}/message`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ agent: 'pulse-designer', parts: [{ type: 'text', text }] }), + }) + expect(r.ok, `message POST ${r.status}`).toBe(true) + const msg = (await r.json()) as { parts?: Array<{ type: string; text?: string }> } + return (msg.parts ?? []).filter((p) => p.type === 'text').map((p) => p.text).join('\n') + } + + const q1 = await turn('help me design a pulse') + expect(q1.toLowerCase()).toMatch(/system|platform/) + // One question AT A TIME = stage 1 only. Multiple "?" inside the platform + // question (listing options) is fine; asking stage-2+ topics in the same + // breath is the real protocol violation. + expect(q1.toLowerCase(), 'no stage-batching in turn 1').not.toMatch(/max_iter|timestep|objective|constraint|drive_max|how many levels/) + + const q2 = await turn('transmon') + expect(q2).toMatch(/\\hat|H\s*\/\s*\\hbar|hamiltonian/i) + + writeFileSync( + join(tmpdir(), `amicode-e2e-transcript-${Date.now()}.md`), + `# tier C transcript\n\n## turn 1 (help me design a pulse)\n\n${q1}\n\n## turn 2 (transmon)\n\n${q2}\n`, + ) + }) + + it.skipIf(process.env.AMICODE_E2E_FULLCHAIN !== '1')( + 'D: full chain — interview through a REAL launched solve (MVP DoD)', + { timeout: 900_000 }, + async () => { + const RUNS = join(homedir(), '.amico', 'runs', 'default') + const before = new Set(existsSync(RUNS) ? require('node:fs').readdirSync(RUNS) : []) + + const s = await serve({ hermetic: false, port: 14314 }) + const ses = (await ( + await fetch(s.url + '/session', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }) + ).json()) as { id: string } + const turn = async (text: string): Promise => { + const r = await fetch(`${s.url}/session/${ses.id}/message`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ agent: 'pulse-designer', parts: [{ type: 'text', text }] }), + }) + expect(r.ok, `message POST ${r.status}`).toBe(true) + const msg = (await r.json()) as { parts?: Array<{ type: string; text?: string }> } + return (msg.parts ?? []).filter((p) => p.type === 'text').map((p) => p.text).join('\n') + } + + // Keyword-routed answers — the model controls stage order, we answer whatever + // it asks. Bounded turns; exit as soon as it reports the launch. + const route = (q: string): string => { + const l = q.toLowerCase() + if (/launched|run inspector/.test(l)) return '' + if (/system|platform/.test(l) && !/frequency|levels/.test(l)) return 'transmon' + if (/omega|frequency|\\omega|delta|anharmonicity/.test(l)) return 'omega = 4.8 GHz, delta = -0.2 GHz' + if (/levels|parameteriz|drive_max|drive bound|amplitude/.test(l)) return '3 levels, default drives' + if (/simulate|warm start|straight to solve|mode/.test(l)) return 'straight to solve, no warm start' + if (/gate|target|state prep|problem/.test(l)) return 'an X gate' + if (/objective|constraint/.test(l)) return 'defaults are fine' + if (/max_iter|iterations|gate time|timesteps|solve param|\bT\b|\bN\b/.test(l)) return 'T = 10 ns, N = 50, max_iter = 60 — launch it' + return 'defaults are fine — continue' + } + + const transcript: string[] = [] + let reply = await turn('help me design a pulse for my transmon — walk me through it') + transcript.push(`## turn 1\n\n${reply}`) + let launched = /solve launched|run inspector/i.test(reply) + for (let t = 2; t <= 14 && !launched; t++) { + const answer = route(reply) + reply = await turn(answer) + transcript.push(`## turn ${t} (sent: ${answer})\n\n${reply}`) + launched = /solve launched|run inspector/i.test(reply) + } + writeFileSync(join(tmpdir(), `amicode-e2e-fullchain-${Date.now()}.md`), transcript.join('\n\n')) + expect(launched, 'agent reported the launch').toBe(true) + + // A NEW run-dir appears and completes. + const deadline = Date.now() + 420_000 + let newRun: string | undefined + for (;;) { + const now = existsSync(RUNS) ? (require('node:fs').readdirSync(RUNS) as string[]) : [] + newRun = now.find((d) => !before.has(d) && d.startsWith('r')) + if (newRun && existsSync(join(RUNS, newRun, 'FINISHED'))) break + if (Date.now() > deadline) throw new Error(`no FINISHED run-dir (newRun=${newRun})`) + await new Promise((r) => setTimeout(r, 5000)) + } + const result = readFileSync(join(RUNS, newRun!, 'result.toml'), 'utf8') + const fidelity = Number(/fidelity\s*=\s*([0-9.eE+-]+)/.exec(result)?.[1]) + expect(fidelity, `fidelity from ${newRun}`).toBeGreaterThan(0.99) + + // Entity bookkeeping (soft — free-tier models may skip tool calls; a miss is + // a prompt-strength finding, not a chain failure). + const entDir = join(homedir(), '.amico', 'runs', 'default', '_entities') + if (!existsSync(join(entDir, 'system.toml'))) { + console.warn('[tier D] amicode_pick_system was not called — record as prompt-strength finding') + } + }, + ) +}) diff --git a/packages/extension/test/slow/scores_e2e.test.ts b/packages/extension/test/slow/scores_e2e.test.ts new file mode 100644 index 00000000..f460403f --- /dev/null +++ b/packages/extension/test/slow/scores_e2e.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect, afterAll } from 'vitest' +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir, homedir } from 'node:os' +import { join } from 'node:path' +import { spawn, type ChildProcess } from 'node:child_process' +import { buildOpencodeConfigContent, prepareOpencodeProject, resolveJuliaProject } from '../../src/opencode_config' +import { loadState } from '../../src/scores/interview_state' +import { readUsage, reconstructTraversal } from '../../src/scores/usage' + +// ============================================================================ +// Scores-runtime e2e — router → score #0 → pinned interview_state + usage funnel. +// +// Follows test/slow/interview_e2e.test.ts exactly (same serve/turn pattern, same +// live-gating: skips without AMICODE_E2E_LIVE=1 / creds — a SKIP is not a PASS). +// Differences: AGENTS.md goes through the REAL prepareOpencodeProject, which now +// splices the onset router + compiled score #0 and writes the score_manifest +// transport; AMICODE_ENTITIES_DIR is pinned to a fresh tmp dir so the Bun-side +// guard state (interview_state.json, usage.jsonl) is hermetic and assertable. +// No solve is run here — tier D of the night e2e owns that. +// ============================================================================ + +const EXT = join(__dirname, '..', '..') +const OC_BIN = join(EXT, 'vendor', 'opencode', `${process.platform}-${process.arch}`, 'opencode') + +const AUTH_JSON = join(homedir(), '.local', 'share', 'opencode', 'auth.json') +function hasCreds(): boolean { + if (process.env.AMICODE_E2E_LIVE === '1') return true + if (process.env.ANTHROPIC_API_KEY) return true + try { + return Object.keys(JSON.parse(readFileSync(AUTH_JSON, 'utf8'))).length > 0 + } catch { + return false + } +} + +const ENTITIES = mkdtempSync(join(tmpdir(), 'scores-e2e-entities-')) +const servers: ChildProcess[] = [] +afterAll(() => { + for (const c of servers) c.kill('SIGTERM') +}) + +async function serveWithScores(port: number) { + // entitiesDir must match between the extension-side builder (permission grant + + // manifest transport) and the Bun-side plugin — pin it before either runs. + process.env.AMICODE_ENTITIES_DIR = ENTITIES + const project = prepareOpencodeProject({ + agentsSrc: join(EXT, 'AGENTS.md'), + templateSrc: join(EXT, 'templates', 'solve_template.jl'), + juliaProject: resolveJuliaProject(''), + entitlementsDir: mkdtempSync(join(tmpdir(), 'scores-e2e-noents-')), // no code → public repertoire + }) + const env = { ...process.env, AMICODE_ENTITIES_DIR: ENTITIES } + env.OPENCODE_CONFIG_CONTENT = buildOpencodeConfigContent(project.agentsPath, join(EXT, 'templates', 'solve_template.jl')) + let buf = '' + const child = spawn(OC_BIN, ['serve', '--port', String(port)], { env, stdio: ['ignore', 'pipe', 'pipe'] }) + servers.push(child) + child.stdout!.on('data', (c) => (buf += c)) + child.stderr!.on('data', (c) => (buf += c)) + const url = `http://127.0.0.1:${port}` + const deadline = Date.now() + 30_000 + for (;;) { + try { + const r = await fetch(url + '/', { signal: AbortSignal.timeout(1000) }) + if (r.ok) break + } catch { /* not up yet */ } + if (Date.now() > deadline) throw new Error(`serve not ready in 30s; log:\n${buf.slice(0, 2000)}`) + await new Promise((r) => setTimeout(r, 300)) + } + return { url, log: () => buf, agentsPath: project.agentsPath } +} + +describe.skipIf(!existsSync(OC_BIN) || !hasCreds())('scores runtime live e2e (creds required)', () => { + it('router opens, score #0 interview starts, state pinned + usage funnel recorded', { timeout: 300_000 }, async () => { + const s = await serveWithScores(14320) + + // Sanity: the session prep actually compiled the score (not the fallback). + const agents = readFileSync(s.agentsPath, 'utf8') + expect(agents).toContain('## Onset router') + expect(agents).toContain('Compiled from score `pulse-designer` v1') + + const ses = (await ( + await fetch(s.url + '/session', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }) + ).json()) as { id: string } + const turn = async (text: string): Promise => { + const r = await fetch(`${s.url}/session/${ses.id}/message`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ agent: 'pulse-designer', parts: [{ type: 'text', text }] }), + }) + expect(r.ok, `message POST ${r.status}`).toBe(true) + const msg = (await r.json()) as { parts?: Array<{ type: string; text?: string }> } + return (msg.parts ?? []).filter((p) => p.type === 'text').map((p) => p.text).join('\n') + } + + const transcript: string[] = [] + + // Turn 1: open-ended → the onset router's options (or a proactive stage-1 kickoff — + // both are protocol-legal; what matters is it offers a way in, one question only). + const t1 = await turn('hi — what can I do here?') + transcript.push(`## turn 1 (hi — what can I do here?)\n\n${t1}`) + expect(t1.toLowerCase()).toMatch(/start from a system|design.*pulse|what do you want to do|platform|system/) + expect(t1.toLowerCase(), 'no stage-batching in turn 1').not.toMatch(/max_iter|timestep|objective|constraint|drive_max/) + + // Turn 2: choose the system-first path → the PLATFORM question, alone. + const t2 = await turn('start from a system — walk me through designing a pulse') + transcript.push(`## turn 2 (start from a system)\n\n${t2}`) + expect(t2.toLowerCase()).toMatch(/system|platform/) + expect(t2.toLowerCase(), 'no stage-batching in turn 2').not.toMatch(/max_iter|timestep|objective|constraint|drive_max/) + + // Turn 3: answer → LaTeX confirm + amicode_pick_system records stage/platform. + const t3 = await turn('transmon') + transcript.push(`## turn 3 (transmon)\n\n${t3}`) + expect(t3).toMatch(/\\hat|H\s*\/\s*\\hbar|hamiltonian/i) + + // The guard state is written by the plugin when the tool fires; free-tier models + // occasionally skip the tool call — one explicit nudge turn is allowed before + // the hard assertion (rerun-once policy covers residual sampling noise). + if (!loadState(ENTITIES)) { + const t4 = await turn('please record that with your amicode tools before we continue') + transcript.push(`## turn 4 (nudge)\n\n${t4}`) + } + writeFileSync(join(tmpdir(), `scores-e2e-transcript-${Date.now()}.md`), transcript.join('\n\n')) + + // Success criterion 1+8 (scores spec §10): pinned state + reconstructable funnel. + const state = loadState(ENTITIES) + expect(state, 'interview_state.json written by the guard').toBeDefined() + expect(state!.score_id).toBe('pulse-designer') + expect(state!.score_version).toBe(1) + + const traversal = reconstructTraversal(readUsage(ENTITIES)) + expect(traversal.score_id).toBe('pulse-designer') + expect(traversal.funnel.map((f) => f.stage)).toContain('platform') + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 22c76a57..7929543a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,6 +31,10 @@ importers: version: 2.1.9(@types/node@22.19.19) packages/extension: + dependencies: + yaml: + specifier: ^2.9.0 + version: 2.9.0 devDependencies: '@amicode/amico-run': specifier: workspace:* @@ -1784,6 +1788,11 @@ packages: yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + yauzl@3.4.0: resolution: {integrity: sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==} engines: {node: '>=12'} @@ -3498,6 +3507,8 @@ snapshots: yallist@4.0.0: {} + yaml@2.9.0: {} + yauzl@3.4.0: dependencies: pend: 1.2.0