From 4429b1528abef8c5c1be189b5e4e0bfde0abf035 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 3 Jul 2026 02:28:31 -0400 Subject: [PATCH 001/135] feat(L0): pulse-designer interview spine in AGENTS.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scoped to the pulse-designer persona (never hijacks the default build agent), one-question-at-a-time, 8 stages PLATFORM→CALIBRATE, transmon fully wired / Rydberg recorded-honestly, amicode_* tools as bookkeeping with bash+amico-run still the launch mechanism. Tests: interview block + no-unknown-placeholder assertion (only {{TEMPLATE_PATH}}/{{JULIA_PROJECT}} substituted). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/extension/AGENTS.md | 58 ++++++++++++++++++++++- packages/extension/test/agents_md.test.ts | 43 +++++++++++++++++ 2 files changed, 99 insertions(+), 2 deletions(-) diff --git a/packages/extension/AGENTS.md b/packages/extension/AGENTS.md index 73093f03..cb4c4b35 100644 --- a/packages/extension/AGENTS.md +++ b/packages/extension/AGENTS.md @@ -33,8 +33,62 @@ 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 only when you are the **pulse-designer** +agent, or the user asks to be walked through designing a pulse. If the user +already knows their parameters ("X gate, 10 ns, defaults"), **skip straight to +the workflow above** — never force the interview on someone who doesn't need it. +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). + +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, set no expectations of + device I/O in this build. ## Scope & parameter guidance diff --git a/packages/extension/test/agents_md.test.ts b/packages/extension/test/agents_md.test.ts index be17d45b..4e033532 100644 --- a/packages/extension/test/agents_md.test.ts +++ b/packages/extension/test/agents_md.test.ts @@ -43,3 +43,46 @@ 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', () => { + expect(AGENTS).toMatch(/pulse-designer/) + expect(AGENTS).toMatch(/skip straight to\s+the\s+workflow/i) + expect(AGENTS).toMatch(/fast-forward/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_pick_system', 'amicode_set_model', 'amicode_formulate', 'amicode_solve']) { + 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_]+\}\}/) + }) +}) From 7c4351dee8f27ea70c9f57556e9f7e42e1fd0c33 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 3 Jul 2026 02:33:05 -0400 Subject: [PATCH 002/135] =?UTF-8?q?test(L0):=20interview=20e2e=20slow=20su?= =?UTF-8?q?ite=20=E2=80=94=20tiered=20A/B/C=20(registration=20/=20plugin?= =?UTF-8?q?=20load=20/=20live=20turns)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier A green vs stock 1.17.3; B gates on the plugin file, C on creds (none on this machine tonight — morning step: opencode auth login). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../extension/test/slow/interview_e2e.test.ts | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 packages/extension/test/slow/interview_e2e.test.ts 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..ddb5282f --- /dev/null +++ b/packages/extension/test/slow/interview_e2e.test.ts @@ -0,0 +1,139 @@ +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 } 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.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 + the Layer-0 agent/plugin registration. */ +function layer0Config(agentsPath: string): string { + const cfg = JSON.parse(buildOpencodeConfigContent(agentsPath, join(EXT, 'templates', 'solve_template.jl'))) + cfg.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.", + }, + } + if (existsSync(PLUGIN)) cfg.plugin = [PLUGIN] + return JSON.stringify(cfg) +} + +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 { + agentsPath = AGENTS_SRC // real home: user creds + global config load (deliberate, tier C) + 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/) + expect((q1.match(/\?/g) ?? []).length, 'one question at a time').toBeLessThanOrEqual(2) + + const q2 = await turn('transmon') + expect(q2).toMatch(/\\hat|H\s*\/\s*\\hbar|hamiltonian/i) + }) +}) From 735f4259dc225222ea1dbd5b2ed4eb71a7602ecb Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 3 Jul 2026 02:45:02 -0400 Subject: [PATCH 003/135] feat(L0): amicode_* tool pack v0 + config registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four bookkeeping tools (pick_system / set_model / formulate / solve-record) executed by opencode's embedded Bun runtime; entities as TOML under ~/.amico/runs/default/_entities ($AMICODE_ENTITIES_DIR override). Registration via buildOpencodeConfigContent: plugin abs-path + pulse-designer agent block + entities-dir permission grant (T8 probe: both mechanisms live at 1.17.3). Plugin stays outside the extension bundle/tsconfig; load line on stderr (debug-config stdout purity — regression-guarded). 99→122 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../opencode-plugin/amicode_tools.ts | 288 ++++++++++++++++++ .../extension/opencode-plugin/entities.ts | 186 +++++++++++ packages/extension/src/opencode_config.ts | 52 +++- packages/extension/test/amicode_tools.test.ts | 158 ++++++++++ .../extension/test/opencode_config.test.ts | 47 ++- 5 files changed, 728 insertions(+), 3 deletions(-) create mode 100644 packages/extension/opencode-plugin/amicode_tools.ts create mode 100644 packages/extension/opencode-plugin/entities.ts create mode 100644 packages/extension/test/amicode_tools.test.ts diff --git a/packages/extension/opencode-plugin/amicode_tools.ts b/packages/extension/opencode-plugin/amicode_tools.ts new file mode 100644 index 00000000..b5d53bbe --- /dev/null +++ b/packages/extension/opencode-plugin/amicode_tools.ts @@ -0,0 +1,288 @@ +// ============================================================================ +// 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, + updateSystem, + validateSystem, + validateFormulation, + PLATFORMS, + type SystemEntity, + type FormulationEntity, + type RunStub, +} from "./entities"; + +// 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_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 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); + 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 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); + 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 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)); + 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 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 ")}.` : ""; + return ( + `Run entity recorded → ${file} — launch via the workflow's amico-run bash command ` + + `if not already launched.${warn}` + ); + }, + }, + }, +}); diff --git a/packages/extension/opencode-plugin/entities.ts b/packages/extension/opencode-plugin/entities.ts new file mode 100644 index 00000000..2a0e1894 --- /dev/null +++ b/packages/extension/opencode-plugin/entities.ts @@ -0,0 +1,186 @@ +// ============================================================================ +// 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; +} + +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"; +} diff --git a/packages/extension/src/opencode_config.ts b/packages/extension/src/opencode_config.ts index b6631082..35636b44 100644 --- a/packages/extension/src/opencode_config.ts +++ b/packages/extension/src/opencode_config.ts @@ -68,14 +68,61 @@ 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, runsRoot: 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"); + +export function buildOpencodeConfigContent( + agentsPath: string, + templatePath: string, + runsRoot: string, + pluginPath: string = DEFAULT_PLUGIN_PATH, +): 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", @@ -85,6 +132,7 @@ export function buildOpencodeConfigContent(agentsPath: string, templatePath: str [`${SCRATCH_DIR}/**`]: "allow", // solve.jl + solve.log it writes [`/private${SCRATCH_DIR}/**`]: "allow", // macOS: /tmp → /private/tmp [`${runsRoot}/**`]: "allow", // run read-backs: FINISHED/result.toml/run.log + [`${entitiesDir()}/**`]: "allow", // amicode_* entities the agent may read back }, }, }); diff --git a/packages/extension/test/amicode_tools.test.ts b/packages/extension/test/amicode_tools.test.ts new file mode 100644 index 00000000..6691da5e --- /dev/null +++ b/packages/extension/test/amicode_tools.test.ts @@ -0,0 +1,158 @@ +// 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, + 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) + }) +}) diff --git a/packages/extension/test/opencode_config.test.ts b/packages/extension/test/opencode_config.test.ts index f1961796..5ae0b5e2 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' @@ -51,6 +51,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, '/home/u/.amico/runs/default')) + 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, '/home/u/.amico/runs/default', '/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, '/home/u/.amico/runs/default')) + 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, '/home/u/.amico/runs/default')) + 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, '/home/u/.amico/runs/default')) + 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. @@ -111,6 +147,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') }) }) From 328816269b4b35fa893af55d2eeceacef0be8da7 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 3 Jul 2026 02:45:02 -0400 Subject: [PATCH 004/135] =?UTF-8?q?test(L0):=20tier=20C=20live=20=E2=80=94?= =?UTF-8?q?=20full=20A/B/C=20green=20vs=20the=20branded=20binary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Free anonymous provider (opencode/big-pickle) resolves without auth.json → AMICODE_E2E_LIVE=1 forces the creds gate. Cadence assertion = stage-batching check (multiple '?' inside one platform question is fine); transcript saved to tmpdir for the experiment note. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../extension/test/slow/interview_e2e.test.ts | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/packages/extension/test/slow/interview_e2e.test.ts b/packages/extension/test/slow/interview_e2e.test.ts index ddb5282f..77fb241e 100644 --- a/packages/extension/test/slow/interview_e2e.test.ts +++ b/packages/extension/test/slow/interview_e2e.test.ts @@ -30,6 +30,7 @@ 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 @@ -38,18 +39,11 @@ function hasCreds(): boolean { } } -/** The extension's real config content + the Layer-0 agent/plugin registration. */ +/** 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 { - const cfg = JSON.parse(buildOpencodeConfigContent(agentsPath, join(EXT, 'templates', 'solve_template.jl'))) - cfg.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.", - }, - } - if (existsSync(PLUGIN)) cfg.plugin = [PLUGIN] - return JSON.stringify(cfg) + return buildOpencodeConfigContent(agentsPath, join(EXT, 'templates', 'solve_template.jl')) } interface Server { child: ChildProcess; url: string; log: () => string } @@ -131,9 +125,17 @@ describe.skipIf(!existsSync(OC_BIN) || !hasCreds())('live interview turns (creds const q1 = await turn('help me design a pulse') expect(q1.toLowerCase()).toMatch(/system|platform/) - expect((q1.match(/\?/g) ?? []).length, 'one question at a time').toBeLessThanOrEqual(2) + // 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`, + ) }) }) From 5dabb870b0384f87b96fa23a32797087f1a9afe5 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 3 Jul 2026 02:50:07 -0400 Subject: [PATCH 005/135] =?UTF-8?q?test(L0):=20tier=20D=20=E2=80=94=20full?= =?UTF-8?q?=20chain=20interview=E2=86=92launched=20solve=20(MVP=20DoD)=20P?= =?UTF-8?q?ASSES?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real-home mode now substitutes AGENTS.md via prepareOpencodeProject (stage 6 needs real template/julia paths). Keyword-routed answers, bounded turns, run-dir poll. Result: 10 turns on the free model → all four amicode_* tools fired (System→Formulation→Run stub) → authored solve.jl → amico-run launch → F=0.9998977/60 iters. AMICODE_E2E_FULLCHAIN=1 gates it. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../extension/test/slow/interview_e2e.test.ts | 85 ++++++++++++++++++- 1 file changed, 83 insertions(+), 2 deletions(-) diff --git a/packages/extension/test/slow/interview_e2e.test.ts b/packages/extension/test/slow/interview_e2e.test.ts index 77fb241e..21cd54bf 100644 --- a/packages/extension/test/slow/interview_e2e.test.ts +++ b/packages/extension/test/slow/interview_e2e.test.ts @@ -3,7 +3,7 @@ import { existsSync, mkdtempSync, mkdirSync, writeFileSync, readFileSync } from import { tmpdir, homedir } from 'node:os' import { join } from 'node:path' import { spawn, type ChildProcess } from 'node:child_process' -import { buildOpencodeConfigContent } from '../../src/opencode_config' +import { buildOpencodeConfigContent, prepareOpencodeProject, resolveJuliaProject } from '../../src/opencode_config' // ============================================================================ // T13 e2e — pulse-designer interview against the REAL vendored binary. @@ -60,7 +60,15 @@ async function serve(opts: { hermetic: boolean; port: number }): Promise 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 { - agentsPath = AGENTS_SRC // real home: user creds + global config load (deliberate, tier C) + // 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) @@ -138,4 +146,77 @@ describe.skipIf(!existsSync(OC_BIN) || !hasCreds())('live interview turns (creds `# 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') + } + }, + ) }) From f941a7e562b224d0bae0e489368b236590fee57f Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 3 Jul 2026 03:01:20 -0400 Subject: [PATCH 006/135] =?UTF-8?q?feat(L0):=20hardware/calibrate=20guided?= =?UTF-8?q?=20stubs=20=E2=80=94=20the=20pack=20covers=20all=208=20stages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit amicode_to_hardware (device_session stub; gate + checks PINNED by the serializer so a stub can never claim an approval that didn't happen) and amicode_calibrate (ILC stub, auto-refs device_session.toml). No device I/O — explanations say so explicitly, mirroring AGENTS.md stage 8, which now names both tools. 6 tools registered (verified via /experimental/tool/ids). 122→128 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/extension/AGENTS.md | 5 +- .../opencode-plugin/amicode_tools.ts | 94 +++++++++++++++++++ .../extension/opencode-plugin/entities.ts | 63 +++++++++++++ packages/extension/test/agents_md.test.ts | 9 +- packages/extension/test/amicode_tools.test.ts | 56 +++++++++++ 5 files changed, 224 insertions(+), 3 deletions(-) diff --git a/packages/extension/AGENTS.md b/packages/extension/AGENTS.md index cb4c4b35..b28d7385 100644 --- a/packages/extension/AGENTS.md +++ b/packages/extension/AGENTS.md @@ -87,8 +87,9 @@ Stages, in order: 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, set no expectations of - device I/O in this build. + 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 index b5d53bbe..b7358279 100644 --- a/packages/extension/opencode-plugin/amicode_tools.ts +++ b/packages/extension/opencode-plugin/amicode_tools.ts @@ -56,6 +56,8 @@ import { systemToml, formulationToml, runStubToml, + deviceSessionStubToml, + calibrationStubToml, updateSystem, validateSystem, validateFormulation, @@ -63,6 +65,8 @@ import { type SystemEntity, type FormulationEntity, type RunStub, + type DeviceSessionStub, + type CalibrationStub, } from "./entities"; // Load line goes to STDERR, not stdout: `opencode debug config` imports plugin @@ -284,5 +288,95 @@ export const AmicodeTools = async (_input: unknown) => ({ ); }, }, + + 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 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 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 index 2a0e1894..4747467b 100644 --- a/packages/extension/opencode-plugin/entities.ts +++ b/packages/extension/opencode-plugin/entities.ts @@ -46,6 +46,26 @@ export interface RunStub { 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; @@ -184,3 +204,46 @@ export function runStubToml(stub: RunStub, now?: Date): string { 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/test/agents_md.test.ts b/packages/extension/test/agents_md.test.ts index 4e033532..79dec0b8 100644 --- a/packages/extension/test/agents_md.test.ts +++ b/packages/extension/test/agents_md.test.ts @@ -67,7 +67,14 @@ describe('AGENTS.md pulse-designer interview (Layer 0)', () => { 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_pick_system', 'amicode_set_model', 'amicode_formulate', 'amicode_solve']) { + for (const t of [ + '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/) diff --git a/packages/extension/test/amicode_tools.test.ts b/packages/extension/test/amicode_tools.test.ts index 6691da5e..d3c4c73b 100644 --- a/packages/extension/test/amicode_tools.test.ts +++ b/packages/extension/test/amicode_tools.test.ts @@ -18,6 +18,8 @@ import { systemToml, formulationToml, runStubToml, + deviceSessionStubToml, + calibrationStubToml, validateSystem, validateFormulation, updateSystem, @@ -156,3 +158,57 @@ describe('runStubToml (bookkeeping stub — NOT amico-run\'s run.toml)', () => { 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/) + }) +}) From 7eac2b5d9449d7618bb1d12ddce069a1aaa2e943 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 3 Jul 2026 03:29:17 -0400 Subject: [PATCH 007/135] feat(L0): Amico identity + proactive interview kickoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'Who are you?' now answers 'I'm Amico — Amicode's pulse-design copilot' (never 'opencode, an interactive CLI tool'), and a greeting/no-intent session opener triggers the stage-1 PLATFORM question instead of a generic assistant reply. Specific asks keep the skip rule. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/extension/AGENTS.md | 20 +++++++++++++++----- packages/extension/test/agents_md.test.ts | 9 ++++++++- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/packages/extension/AGENTS.md b/packages/extension/AGENTS.md index b28d7385..ce54f5fa 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. @@ -39,11 +46,14 @@ the bash launch. `amico-run --help` prints usage. ## Pulse-designer interview -**Scope rule:** run this interview only when you are the **pulse-designer** -agent, or the user asks to be walked through designing a pulse. If the user -already knows their parameters ("X gate, 10 ns, defaults"), **skip straight to -the workflow above** — never force the interview on someone who doesn't need it. -The user can say "fast-forward" at any stage to jump to defaults. +**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 diff --git a/packages/extension/test/agents_md.test.ts b/packages/extension/test/agents_md.test.ts index 79dec0b8..31c7b83e 100644 --- a/packages/extension/test/agents_md.test.ts +++ b/packages/extension/test/agents_md.test.ts @@ -45,11 +45,18 @@ describe('AGENTS.md teaches the D9/D10 script-authoring workflow', () => { }) describe('AGENTS.md pulse-designer interview (Layer 0)', () => { - it('scopes the interview to the pulse-designer persona and never forces it', () => { + 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) From 9d28f02b3b6aec744c17f6d000ce497e015df5cf Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 3 Jul 2026 03:50:26 -0400 Subject: [PATCH 008/135] =?UTF-8?q?feat(L0):=20amicode=5Fask=20=E2=80=94?= =?UTF-8?q?=20multiple-choice=20questions=20as=20button=20payloads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Interview option-stages (platform, sim-vs-solve, problem/gate) now go through amicode_ask(question, options[2-6]); the renderer draws buttons from the tool part's input and a click sends the option text as the next user message. Free-form values stay plain questions. 7 tools registered (runtime-verified). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/extension/AGENTS.md | 7 +++++ .../opencode-plugin/amicode_tools.ts | 31 +++++++++++++++++++ packages/extension/test/agents_md.test.ts | 1 + 3 files changed, 39 insertions(+) diff --git a/packages/extension/AGENTS.md b/packages/extension/AGENTS.md index ce54f5fa..1cf41ba8 100644 --- a/packages/extension/AGENTS.md +++ b/packages/extension/AGENTS.md @@ -61,6 +61,13 @@ advance. After each answer, record the stage's state: call the matching 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 / diff --git a/packages/extension/opencode-plugin/amicode_tools.ts b/packages/extension/opencode-plugin/amicode_tools.ts index b7358279..6c2c6321 100644 --- a/packages/extension/opencode-plugin/amicode_tools.ts +++ b/packages/extension/opencode-plugin/amicode_tools.ts @@ -131,6 +131,37 @@ const RYDBERG_SCOPE_NOTE = // 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). " + diff --git a/packages/extension/test/agents_md.test.ts b/packages/extension/test/agents_md.test.ts index 31c7b83e..90baf625 100644 --- a/packages/extension/test/agents_md.test.ts +++ b/packages/extension/test/agents_md.test.ts @@ -75,6 +75,7 @@ describe('AGENTS.md pulse-designer interview (Layer 0)', () => { }) 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', From 1a5b2a80513dc1f5ce1a49166cc0faaa2c3977a0 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 3 Jul 2026 04:32:03 -0400 Subject: [PATCH 009/135] fix(L0): amicode_ask discipline + optional per-option details MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live-demo findings: the free model repeated the question in prose and answered it itself. Tool return now says STOP HERE explicitly; AGENTS.md mirrors it. New optional details[] arg — one dim qualifier per button (minimalist card, slightly more context). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/extension/AGENTS.md | 12 ++++++++---- .../extension/opencode-plugin/amicode_tools.ts | 16 +++++++++++++--- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/packages/extension/AGENTS.md b/packages/extension/AGENTS.md index 1cf41ba8..edcf42c7 100644 --- a/packages/extension/AGENTS.md +++ b/packages/extension/AGENTS.md @@ -63,10 +63,14 @@ 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. +`amicode_ask` (question + 2–6 options, plus optional one-line `details` per +option — e.g. "fully supported end-to-end" / "recorded for follow-up") — the +chat renders the options as buttons and the user's click arrives as their next +message. **After calling `amicode_ask`, end your turn immediately: no prose +repeat of the question, no commentary, and NEVER answer the question yourself.** +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: diff --git a/packages/extension/opencode-plugin/amicode_tools.ts b/packages/extension/opencode-plugin/amicode_tools.ts index 6c2c6321..9a4fcf5c 100644 --- a/packages/extension/opencode-plugin/amicode_tools.ts +++ b/packages/extension/opencode-plugin/amicode_tools.ts @@ -147,18 +147,28 @@ export const AmicodeTools = async (_input: unknown) => ({ items: { type: "string" }, description: "2-6 short option labels, one per button.", }, + details: { + type: ["array", "null"], + items: { type: "string" }, + description: + "Optional one-per-option short qualifier rendered dimly under each button " + + "(e.g. \"fully supported end-to-end\"). Same length as options, or null.", + }, }, - async execute(a: { question: string; options: string[] }) { + async execute(a: { question: string; options: string[]; details?: string[] | null }) { 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."; + if (a.details != null && (!Array.isArray(a.details) || a.details.length !== opts.length)) + return "Cannot ask: details must be null or exactly one per option."; // 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.` + `Question presented with ${opts.length} option buttons. STOP HERE: write no ` + + `further text this turn, do NOT repeat the question in prose, and NEVER pick ` + + `an option yourself — the user's next message is their click.` ); }, }, From 5c83b38e0fc5fdce6c1035386587547560944dc8 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 3 Jul 2026 04:56:46 -0400 Subject: [PATCH 010/135] brand: digi Harmoniqs H-robot mark replaces the amico smile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pixel <0110> face in brand yellow on the H silhouette (from the website robot); currentColor body serves dark+light. Same asset ships in the fork's logo/favicon (kept in sync manually — canonical geometry recorded there in AMICODE-PATCHES.md). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/extension/media/amico.svg | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/extension/media/amico.svg b/packages/extension/media/amico.svg index 42b926a6..f61916dd 100644 --- a/packages/extension/media/amico.svg +++ b/packages/extension/media/amico.svg @@ -1,7 +1,19 @@ - - - - - - + + + + + + + + + + + + + + + + + + From 4eca7bbd96e96071c44df5e37951ab9f74ef0624 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 3 Jul 2026 05:07:35 -0400 Subject: [PATCH 011/135] feat: fixed opencode port (amicode.opencodePort, default 43117) + awaited stop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Work found staged in the night tree (parallel session / Aaron) — folded in after validating build + full suite green. Fixes the Remote-SSH pain: forward 43117 once, restarts reuse it; stop() now resolves on child exit so a fixed-port restart can't race the old process for the socket. Set 0 to restore per-start ephemeral ports. --- packages/extension/package.json | 5 +++++ packages/extension/src/extension.ts | 9 ++++++-- packages/extension/src/server_manager.ts | 27 +++++++++++++++++------- 3 files changed, 31 insertions(+), 10 deletions(-) diff --git a/packages/extension/package.json b/packages/extension/package.json index d1c304ae..68b34930 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -93,6 +93,11 @@ "default": "", "description": "Override path to the opencode CLI (dev only). Empty = use the vendored binary." }, + "amicode.opencodePort": { + "type": "number", + "default": 43117, + "description": "Fixed port for the spawned opencode server. Set to 0 to pick a free ephemeral port on each start instead." + }, "amicode.juliaProject": { "type": "string", "default": "", diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 2dc3428c..af4187b5 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -116,9 +116,14 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // spawn env — opencode resolves its provider from its own env / config / // auth.json. The spawn env carries only PATH (so amico-run resolves) and the // amico instructions/permission config. + const configuredPort = vscode.workspace.getConfiguration("amicode").get("opencodePort", 0); + if (configuredPort > 0) { + opencodeChannel.appendLine(`[boot] amicode.opencodePort = ${configuredPort} (static)`); + } serverManager = new ServerManager({ binary, cwd: opencodeProject.projectDir, + port: configuredPort > 0 ? configuredPort : undefined, env: { PATH: `${amicoRunBinDir ? amicoRunBinDir + ":" : ""}${process.env.PATH ?? ""}`, // Inject the amico solve workflow as opencode `instructions` (loaded for @@ -130,7 +135,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { }, channel: opencodeChannel, }); - ctx.subscriptions.push({ dispose: () => serverManager?.stop() }); + ctx.subscriptions.push({ dispose: () => void serverManager?.stop() }); // SSE event channel — opens once opencode is healthy. sseClient = new OpencodeEventClient({ channel: opencodeChannel, statusBar }); @@ -186,7 +191,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { }), vscode.commands.registerCommand("amicode.restartServer", async () => { opencodeChannel.appendLine(`[boot] restart requested`); - serverManager?.stop(); + await serverManager?.stop(); statusBar?.setServerReady(false); opencodeReadyUrl = undefined; try { diff --git a/packages/extension/src/server_manager.ts b/packages/extension/src/server_manager.ts index 960d4d06..0f37304a 100644 --- a/packages/extension/src/server_manager.ts +++ b/packages/extension/src/server_manager.ts @@ -9,7 +9,7 @@ import type { Readable } from "node:stream"; // the opencode-v2 decompiled extension (handover §6). // // Lifecycle: -// 1. acquire a free TCP port +// 1. use the configured port, or acquire a free TCP port if none was given // 2. spawn `opencode serve --port=` with env injected // 3. poll http://127.0.0.1:/health until 200 (max 30s) // 4. report ready; expose .url + .port + .child @@ -27,6 +27,8 @@ export interface ServerOptions { env: Record; /** OutputChannel for opencode stdout/stderr capture. */ channel: vscode.OutputChannel; + /** Fixed port to serve on. 0 (default) picks a free ephemeral port each start. */ + port?: number; } export class ServerManager { @@ -46,7 +48,7 @@ export class ServerManager { if (this.child) { throw new Error("opencode server already running"); } - const port = await pickFreePort(); + const port = this.opts.port ?? (await pickFreePort()); this._port = port; this.opts.channel.appendLine(`[server] spawning opencode serve --port=${port} (cwd=${this.opts.cwd})`); @@ -78,16 +80,25 @@ export class ServerManager { return url; } - stop(): void { - if (!this.child) return; + /** Resolves once the child has actually exited (bounded by the SIGKILL fallback) — + * callers restarting onto a fixed port must await this or the new spawn can race + * the old process for the socket. */ + stop(): Promise { + if (!this.child) return Promise.resolve(); this.opts.channel.appendLine(`[server] stopping opencode (pid=${this.child.pid})`); - try { this.child.kill("SIGTERM"); } catch {} const c = this.child; - setTimeout(() => { - try { c.kill("SIGKILL"); } catch {} - }, 3_000); this.child = undefined; this._ready = false; + return new Promise((resolve) => { + const killTimer = setTimeout(() => { + try { c.kill("SIGKILL"); } catch {} + }, 3_000); + c.once("exit", () => { + clearTimeout(killTimer); + resolve(); + }); + try { c.kill("SIGTERM"); } catch { clearTimeout(killTimer); resolve(); } + }); } } From ac047301198c5331ae2b14bb6f2421add13e4e81 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 3 Jul 2026 04:26:33 -0400 Subject: [PATCH 012/135] feat(scores): manifest schema + additive validation --- packages/extension/package.json | 5 +- packages/extension/src/scores/schema.ts | 79 +++++++++++++++++++ packages/extension/test/scores/schema.test.ts | 60 ++++++++++++++ pnpm-lock.yaml | 11 +++ 4 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 packages/extension/src/scores/schema.ts create mode 100644 packages/extension/test/scores/schema.test.ts diff --git a/packages/extension/package.json b/packages/extension/package.json index 68b34930..bb257568 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", @@ -138,5 +138,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/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/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/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 From 3f9ea596ed01990a00f657772b2ae4014ddbe69c Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 3 Jul 2026 04:27:24 -0400 Subject: [PATCH 013/135] feat(scores): SCORE.md loader with per-score error isolation --- packages/extension/src/scores/loader.ts | 42 ++++++++++++ packages/extension/test/scores/loader.test.ts | 65 +++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 packages/extension/src/scores/loader.ts create mode 100644 packages/extension/test/scores/loader.test.ts 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/test/scores/loader.test.ts b/packages/extension/test/scores/loader.test.ts new file mode 100644 index 00000000..fe6bcd70 --- /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("version: 1", "version: 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: [] }); + }); +}); From e8b19273b1b9ce1e69b14128f47573aab6aca596 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 3 Jul 2026 04:28:09 -0400 Subject: [PATCH 014/135] fix(scores): loader test targeted schema_version by accident --- packages/extension/test/scores/loader.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/extension/test/scores/loader.test.ts b/packages/extension/test/scores/loader.test.ts index fe6bcd70..10aa4388 100644 --- a/packages/extension/test/scores/loader.test.ts +++ b/packages/extension/test/scores/loader.test.ts @@ -37,7 +37,7 @@ describe("parseScoreMd", () => { 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("version: 1", "version: 0"); + const bad = GOOD.replace("\nversion: 1", "\nversion: 0"); expect(() => parseScoreMd(bad, "y/SCORE.md")).toThrow(/positive integer/); }); }); From 1ac34967eefe102667dbc198d24fdab525117cf8 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 3 Jul 2026 04:31:09 -0400 Subject: [PATCH 015/135] feat(scores): repertoire lint (templates, memory hooks, lineage, entitlement registry) --- packages/extension/scores/entitlements.toml | 3 + packages/extension/src/scores/lint.ts | 27 ++++++ .../test/scores/repertoire_lint.test.ts | 94 +++++++++++++++++++ 3 files changed, 124 insertions(+) create mode 100644 packages/extension/scores/entitlements.toml create mode 100644 packages/extension/src/scores/lint.ts create mode 100644 packages/extension/test/scores/repertoire_lint.test.ts 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/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/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([]); + }); +}); From e2e55b8a801e4b8be61c77c0a5352b370dbc4721 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 3 Jul 2026 04:34:21 -0400 Subject: [PATCH 016/135] =?UTF-8?q?feat(scores):=20score=20#0=20=E2=80=94?= =?UTF-8?q?=20pulse-designer=20interview=20as=20data=20(+=20vsix=20packagi?= =?UTF-8?q?ng)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../memory/free-phase-objective-only.md | 9 ++ .../scores/memory/pin-globals-first-solve.md | 9 ++ .../extension/scores/pulse-designer/SCORE.md | 136 +++++++++++++++++ .../scores/pulse-designer/templates/solve.jl | 143 ++++++++++++++++++ packages/extension/test/packaging.test.ts | 4 + 5 files changed, 301 insertions(+) create mode 100644 packages/extension/scores/memory/free-phase-objective-only.md create mode 100644 packages/extension/scores/memory/pin-globals-first-solve.md create mode 100644 packages/extension/scores/pulse-designer/SCORE.md create mode 100644 packages/extension/scores/pulse-designer/templates/solve.jl 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/test/packaging.test.ts b/packages/extension/test/packaging.test.ts index fd15acc1..6b8cc103 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 From 555fadb5e91f595ed8416a3132c029a572a9be04 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 3 Jul 2026 04:37:28 -0400 Subject: [PATCH 017/135] feat(scores): entitlement gating + onset router section --- packages/extension/src/scores/entitlements.ts | 43 +++++++ packages/extension/src/scores/router.ts | 43 +++++++ .../test/scores/entitlements_router.test.ts | 106 ++++++++++++++++++ 3 files changed, 192 insertions(+) create mode 100644 packages/extension/src/scores/entitlements.ts create mode 100644 packages/extension/src/scores/router.ts create mode 100644 packages/extension/test/scores/entitlements_router.test.ts diff --git a/packages/extension/src/scores/entitlements.ts b/packages/extension/src/scores/entitlements.ts new file mode 100644 index 00000000..b3261e18 --- /dev/null +++ b/packages/extension/src/scores/entitlements.ts @@ -0,0 +1,43 @@ +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. +export class LocalEntitlementProvider implements EntitlementProvider { + constructor(private readonly configDir: string) {} + + async resolve(): Promise { + const file = path.join(this.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; + } +} + +// 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/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/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])); + }); +}); From 9db43677ffd2a42f9184b609d4107cf5471c25a2 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 3 Jul 2026 04:38:16 -0400 Subject: [PATCH 018/135] feat(scores): interview_state [score] slice with version pinning (JSON, plugin-sidecar pattern) --- .../extension/src/scores/interview_state.ts | 56 ++++++++++++++++++ .../test/scores/interview_state.test.ts | 57 +++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 packages/extension/src/scores/interview_state.ts create mode 100644 packages/extension/test/scores/interview_state.test.ts 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/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(); + }); +}); From 4d6f55e7734d8a4e3a6f703e2c7a37733531788a Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 3 Jul 2026 04:39:17 -0400 Subject: [PATCH 019/135] feat(scores): usage capture module (funnel, off-path, gate events) --- packages/extension/src/scores/usage.ts | 93 ++++++++++++++++++++ packages/extension/test/scores/usage.test.ts | 60 +++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 packages/extension/src/scores/usage.ts create mode 100644 packages/extension/test/scores/usage.test.ts 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/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" }], + }); + }); +}); From d832b75cb0331ac663677fd302d21b2fa70422cf Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 3 Jul 2026 04:43:31 -0400 Subject: [PATCH 020/135] feat(scores): compiler + router spliced into the injection seam, score_manifest transport, scores-root grant, never-brick fallback --- packages/extension/src/opencode_config.ts | 43 ++++++++++- packages/extension/src/scores/compiler.ts | 59 +++++++++++++++ packages/extension/src/scores/entitlements.ts | 28 +++++--- .../extension/test/scores/compiler.test.ts | 69 ++++++++++++++++++ .../test/scores/prep_integration.test.ts | 71 +++++++++++++++++++ 5 files changed, 258 insertions(+), 12 deletions(-) create mode 100644 packages/extension/src/scores/compiler.ts create mode 100644 packages/extension/test/scores/compiler.test.ts create mode 100644 packages/extension/test/scores/prep_integration.test.ts diff --git a/packages/extension/src/opencode_config.ts b/packages/extension/src/opencode_config.ts index 35636b44..102d166e 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. @@ -103,11 +107,16 @@ function entitiesDir(): string { * 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, runsRoot: string, pluginPath: string = DEFAULT_PLUGIN_PATH, + scoresRoot: string = DEFAULT_SCORES_ROOT, ): string { const templatesDir = path.dirname(templatePath); return JSON.stringify({ @@ -133,6 +142,7 @@ export function buildOpencodeConfigContent( [`/private${SCRATCH_DIR}/**`]: "allow", // macOS: /tmp → /private/tmp [`${runsRoot}/**`]: "allow", // run read-backs: FINISHED/result.toml/run.log [`${entitiesDir()}/**`]: "allow", // amicode_* entities the agent may read back + [`${scoresRoot}/**`]: "allow", // score templates + memory hooks ([Why?]) the agent reads }, }, }); @@ -148,6 +158,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 { @@ -169,7 +183,34 @@ 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) + // reads this file to enforce stage order — see opencode-plugin/amicode_tools.ts. + fs.writeFileSync( + path.join(projectDir, "score_manifest.json"), + JSON.stringify({ manifest: score0.manifest, score_dir: score0.dir, project_dir: projectDir }, null, 2) + "\n", + ); + } + } 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 index b3261e18..0e42deb8 100644 --- a/packages/extension/src/scores/entitlements.ts +++ b/packages/extension/src/scores/entitlements.ts @@ -17,21 +17,27 @@ export interface EntitlementProvider { // 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 { - const file = path.join(this.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; + return readLocalEntitlements(this.configDir); } } 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/prep_integration.test.ts b/packages/extension/test/scores/prep_integration.test.ts new file mode 100644 index 00000000..01e489c1 --- /dev/null +++ b/packages/extension/test/scores/prep_integration.test.ts @@ -0,0 +1,71 @@ +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 { prepareOpencodeProject, buildOpencodeConfigContent, DEFAULT_SCORES_ROOT } from "../../src/opencode_config"; + +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", () => { + 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")); + }); + + 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"); + }); +}); From 114f4e8be74b2c751c3d7e6151684ec8526e64e5 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 3 Jul 2026 04:48:26 -0400 Subject: [PATCH 021/135] feat(scores): stage-order + gate guard enforced in the amicode_* tools (score_guard sibling module, entitiesDir transport) --- .../opencode-plugin/amicode_tools.ts | 17 ++ .../extension/opencode-plugin/score_guard.ts | 167 ++++++++++++++++++ packages/extension/src/opencode_config.ts | 13 +- packages/extension/test/scores/guard.test.ts | 92 ++++++++++ .../test/scores/prep_integration.test.ts | 20 ++- 5 files changed, 302 insertions(+), 7 deletions(-) create mode 100644 packages/extension/opencode-plugin/score_guard.ts create mode 100644 packages/extension/test/scores/guard.test.ts diff --git a/packages/extension/opencode-plugin/amicode_tools.ts b/packages/extension/opencode-plugin/amicode_tools.ts index 9a4fcf5c..36fc7d3a 100644 --- a/packages/extension/opencode-plugin/amicode_tools.ts +++ b/packages/extension/opencode-plugin/amicode_tools.ts @@ -68,6 +68,7 @@ import { 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 @@ -193,6 +194,8 @@ export const AmicodeTools = async (_input: unknown) => ({ }, }, 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; @@ -200,6 +203,7 @@ export const AmicodeTools = async (_input: unknown) => ({ 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` + @@ -235,6 +239,8 @@ export const AmicodeTools = async (_input: unknown) => ({ }, }, 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 : {}) }; @@ -245,6 +251,7 @@ export const AmicodeTools = async (_input: unknown) => ({ 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)}`; @@ -276,6 +283,8 @@ export const AmicodeTools = async (_input: unknown) => ({ }, }, 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, @@ -285,6 +294,7 @@ export const AmicodeTools = async (_input: unknown) => ({ 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}; ` + @@ -310,6 +320,8 @@ export const AmicodeTools = async (_input: unknown) => ({ }, 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"); @@ -323,6 +335,7 @@ export const AmicodeTools = async (_input: unknown) => ({ ...(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}` @@ -350,6 +363,8 @@ export const AmicodeTools = async (_input: unknown) => ({ }, }, 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; @@ -391,6 +406,8 @@ export const AmicodeTools = async (_input: unknown) => ({ }, }, 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; 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/src/opencode_config.ts b/packages/extension/src/opencode_config.ts index 102d166e..e01bfee3 100644 --- a/packages/extension/src/opencode_config.ts +++ b/packages/extension/src/opencode_config.ts @@ -200,11 +200,14 @@ export function prepareOpencodeProject(opts: OpencodeConfigOptions): OpencodePro if (score0) { finalContent = spliceIntoAgentsMd(filled, buildRouterSection(visible), compileScore(score0)); // Manifest transport: the opencode plugin (Bun runtime, separate process tree) - // reads this file to enforce stage order — see opencode-plugin/amicode_tools.ts. - fs.writeFileSync( - path.join(projectDir, "score_manifest.json"), - JSON.stringify({ manifest: score0.manifest, score_dir: score0.dir, project_dir: projectDir }, null, 2) + "\n", - ); + // 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}`); 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/prep_integration.test.ts b/packages/extension/test/scores/prep_integration.test.ts index 01e489c1..c3d547a1 100644 --- a/packages/extension/test/scores/prep_integration.test.ts +++ b/packages/extension/test/scores/prep_integration.test.ts @@ -1,9 +1,22 @@ -import { describe, it, expect } from "vitest"; +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"); @@ -31,13 +44,16 @@ describe("prepareOpencodeProject × scores (spec §6)", () => { expect(agents).not.toMatch(/\{\{[A-Z_]+\}\}/); // substitution complete, incl. compiled content }); - it("writes the score_manifest.json plugin transport", () => { + 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)", () => { From c3259f175ddd110bf37f5241cf86c6cdcfef7dbe Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 3 Jul 2026 04:52:07 -0400 Subject: [PATCH 022/135] =?UTF-8?q?test(scores):=20live=20e2e=20=E2=80=94?= =?UTF-8?q?=20router=20=E2=86=92=20score=20#0=20=E2=86=92=20pinned=20state?= =?UTF-8?q?=20+=20usage=20funnel=20(PASS=2025s)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../extension/test/slow/scores_e2e.test.ts | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 packages/extension/test/slow/scores_e2e.test.ts 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') + }) +}) From 71d74f5f8a6454d7f81c268ea0dd8569cf75e935 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 3 Jul 2026 04:53:03 -0400 Subject: [PATCH 023/135] docs(scores): repertoire authoring README --- packages/extension/scores/README.md | 87 +++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 packages/extension/scores/README.md 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. From 11d5a7c3ca0dc108c82f162b264c2982bcb8d9f0 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 3 Jul 2026 05:11:18 -0400 Subject: [PATCH 024/135] scores: mirror 778e1bb ask-discipline into score #0 + anchor-on-recorded-state (v2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compiled score is now what runtime users see — the STOP-HERE ask discipline and optional per-option details move in from the AGENTS.md fallback, plus the anti-drift anchor from Aaron's live session (rail said rydberg while the interview asked transmon-omega): re-read the System entity before stage-2+ questions; correct the record first if it's wrong. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../extension/scores/pulse-designer/SCORE.md | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/extension/scores/pulse-designer/SCORE.md b/packages/extension/scores/pulse-designer/SCORE.md index 1d9cd783..41a30167 100644 --- a/packages/extension/scores/pulse-designer/SCORE.md +++ b/packages/extension/scores/pulse-designer/SCORE.md @@ -2,7 +2,7 @@ type: score schema_version: 1 id: pulse-designer -version: 1 +version: 2 derived_from: null name: "Design an optimized pulse" outcome: "A solved, inspected pulse for your gate on your platform" @@ -85,11 +85,20 @@ 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. +`amicode_ask` (question + the options, default first, marked "(recommended)", +plus optional one-line `details` per option — e.g. "fully supported end-to-end" +/ "recorded for follow-up") — the chat renders them as buttons and the click +arrives as the next message. **After calling `amicode_ask`, end your turn +immediately: no prose repeat of the question, no commentary, and NEVER answer +the question yourself.** 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. + +**Anchor on recorded state:** before asking any stage-2+ parameter question, +re-read the recorded System entity (what the rail shows) and anchor on it — +never ask questions that contradict what is recorded. If the record is wrong +(wrong platform, stale value), correct it via the matching `amicode_*` tool +FIRST, then continue. Per-stage notes: From 7cf39234442a53cc7f447b5ad16baefc5e9c481d Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 3 Jul 2026 05:12:39 -0400 Subject: [PATCH 025/135] test(scores): version-agnostic prep assertions + banner/manifest version cross-check Score content bumps (v1->v2 tonight) must not red the suite; instead assert the compiled banner and the manifest agree on whatever version ships. --- packages/extension/test/scores/prep_integration.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/extension/test/scores/prep_integration.test.ts b/packages/extension/test/scores/prep_integration.test.ts index c3d547a1..fa092ce6 100644 --- a/packages/extension/test/scores/prep_integration.test.ts +++ b/packages/extension/test/scores/prep_integration.test.ts @@ -36,7 +36,7 @@ describe("prepareOpencodeProject × scores (spec §6)", () => { 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).toMatch(/Compiled from score `pulse-designer` v\d+/); // version-agnostic: content bumps must not red this suite 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 @@ -48,7 +48,10 @@ describe("prepareOpencodeProject × scores (spec §6)", () => { 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.manifest.version).toBeGreaterThanOrEqual(1); // tracks SCORE.md frontmatter + // the compiled banner and the manifest must agree on the version (no drift) + const agentsForVersion = fs.readFileSync(proj.agentsPath, "utf8"); + expect(agentsForVersion).toContain(`Compiled from score \`pulse-designer\` v${manifest.manifest.version}`); 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) From 33bc3e2d53482e55f9b7d9c1089e837a5e99351a Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 3 Jul 2026 05:20:57 -0400 Subject: [PATCH 026/135] feat(L0): consolidate on opencode's NATIVE question tool; amicode_ask deprecated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live finding: 1.17.3 ships a turn-blocking question tool (tool/question.ts) with options+descriptions+custom answers — the model reached for it unprompted and its semantics structurally kill self-answering and prose repeats. Score #0 v3 + AGENTS.md fallback now route all choice questions through it; amicode_ask stays registered but deprecated. One mechanism, upstream-owned. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/extension/AGENTS.md | 20 +++++++++--------- .../opencode-plugin/amicode_tools.ts | 6 +++--- .../extension/scores/pulse-designer/SCORE.md | 21 ++++++++++--------- 3 files changed, 24 insertions(+), 23 deletions(-) diff --git a/packages/extension/AGENTS.md b/packages/extension/AGENTS.md index edcf42c7..1b06189e 100644 --- a/packages/extension/AGENTS.md +++ b/packages/extension/AGENTS.md @@ -61,16 +61,16 @@ advance. After each answer, record the stage's state: call the matching 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, plus optional one-line `details` per -option — e.g. "fully supported end-to-end" / "recorded for follow-up") — the -chat renders the options as buttons and the user's click arrives as their next -message. **After calling `amicode_ask`, end your turn immediately: no prose -repeat of the question, no commentary, and NEVER answer the question yourself.** -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. +**Asking choice questions:** when a stage's answer is a small option set +(PLATFORM; simulate-vs-solve; gate synthesis vs state prep; which gate), ask it +via the native **`question` tool** — ONE question per call; the default option +FIRST with "(Recommended)" appended; a short description per option where it +helps. The form blocks the turn until the user answers — **call the tool and +stop: no prose repeat of the question, and never pre-empt the answer.** +Free-form values ($\omega$, $\delta$, `T`, `N`, `max_iter`) may use `question` +(custom answers are on by default) or plain text. The older `amicode_ask` tool +is **deprecated** — prefer `question`; fall back to plain text with the options +listed only if both are unavailable. Stages, in order: diff --git a/packages/extension/opencode-plugin/amicode_tools.ts b/packages/extension/opencode-plugin/amicode_tools.ts index 36fc7d3a..b889a1f5 100644 --- a/packages/extension/opencode-plugin/amicode_tools.ts +++ b/packages/extension/opencode-plugin/amicode_tools.ts @@ -134,9 +134,9 @@ 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). " + + "DEPRECATED — prefer the native `question` tool (turn-blocking form with options, " + + "descriptions, and custom answers). Kept for compatibility: presents ONE multiple-choice " + + "question as clickable buttons; the user's next message is their answer. " + "End your turn after calling this — never answer on the user's behalf.", args: { question: { diff --git a/packages/extension/scores/pulse-designer/SCORE.md b/packages/extension/scores/pulse-designer/SCORE.md index 41a30167..2bd12a69 100644 --- a/packages/extension/scores/pulse-designer/SCORE.md +++ b/packages/extension/scores/pulse-designer/SCORE.md @@ -2,7 +2,7 @@ type: score schema_version: 1 id: pulse-designer -version: 2 +version: 3 derived_from: null name: "Design an optimized pulse" outcome: "A solved, inspected pulse for your gate on your platform" @@ -84,15 +84,16 @@ 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)", -plus optional one-line `details` per option — e.g. "fully supported end-to-end" -/ "recorded for follow-up") — the chat renders them as buttons and the click -arrives as the next message. **After calling `amicode_ask`, end your turn -immediately: no prose repeat of the question, no commentary, and NEVER answer -the question yourself.** 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. +**Asking choice questions:** any question above with a `choices` list goes +through the native **`question` tool** — ONE question per call; the default +option FIRST with "(Recommended)" appended to its label; a short description +per option where it helps (e.g. "fully supported end-to-end" / "recorded for +follow-up"). The form blocks the turn until the user answers — so **call the +tool and stop: do not also ask the question in prose, and never pre-empt the +answer.** Free-form values ($\omega$, $\delta$, `T`, `N`, `max_iter`) may use +`question` (custom answers are enabled by default) or plain text. The older +`amicode_ask` tool is **deprecated** — prefer `question`; fall back to plain +text with listed options only if both are unavailable. **Anchor on recorded state:** before asking any stage-2+ parameter question, re-read the recorded System entity (what the rail shows) and anchor on it — From 51e8b4bb72a44a82af5aae0b2d3046975afd6160 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 3 Jul 2026 05:33:51 -0400 Subject: [PATCH 027/135] =?UTF-8?q?feat(L0):=20curated=20'What=20can=20Ami?= =?UTF-8?q?code=20do=3F'=20answer=20=E2=80=94=20no=20webfetch,=20no=20engi?= =?UTF-8?q?ne=20talk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The starter chip hit a Webfetch to the engine's website and a flat 3-sentence reply. The canonical question now has a canonical answer: capability bullets (interview, fast-path, live inspector, warm-start/resume, hardware preview), honest scope line, then next-step options via the question tool. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/extension/AGENTS.md | 29 +++++++++++++++++++++++ packages/extension/test/agents_md.test.ts | 6 +++++ 2 files changed, 35 insertions(+) diff --git a/packages/extension/AGENTS.md b/packages/extension/AGENTS.md index 1b06189e..5dbcc3f8 100644 --- a/packages/extension/AGENTS.md +++ b/packages/extension/AGENTS.md @@ -44,6 +44,35 @@ 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. +## Answering "What can Amicode do?" + +When the user asks what Amicode is, does, or can do (any phrasing), answer from +THIS section — **never webfetch**, and never describe the underlying engine, +runtime, or other products: Amicode is the product, you are Amico. Render +roughly this, warmly and tersely: + +> I'm Amico — Amicode's pulse-design copilot. Here's what we can do together: +> +> - **Design a pulse through a guided interview** — platform → model +> ($\omega$, $\delta$, levels) → objectives & constraints → solve params. +> Every step is recorded as entities (System · Formulation · Run) — the rail +> at the top tracks them. +> - **Fast-path solves** — already know your parameters? "X gate, 10 ns, +> defaults" skips the interview entirely. +> - **Watch solves live** — the Run Inspector streams the pulse plot and +> fidelity every iteration; finished runs keep their full record. +> - **Warm-start & resume** — seed a new solve from a previous pulse, or pick +> an interview back up where you left off. +> - **Hardware & calibration (preview)** — I record send-to-device intent and +> calibration follow-ups; device I/O isn't wired in this build. +> +> **Today's scope:** single-qubit gates on transmons, end to end (X, Y, Z, H, +> S, T, √X, arbitrary unitaries). Rydberg systems are recorded honestly for +> follow-up. + +Then offer next steps with the `question` tool — e.g. "Design a pulse +(Recommended)" / "Fast X-gate solve" / "Just explore". + ## Pulse-designer interview **Scope rule:** run this interview when you are the **pulse-designer** agent, diff --git a/packages/extension/test/agents_md.test.ts b/packages/extension/test/agents_md.test.ts index 90baf625..41006ce5 100644 --- a/packages/extension/test/agents_md.test.ts +++ b/packages/extension/test/agents_md.test.ts @@ -50,6 +50,12 @@ describe('AGENTS.md pulse-designer interview (Layer 0)', () => { expect(AGENTS).toMatch(/skip straight to\s+the\s+workflow/i) expect(AGENTS).toMatch(/fast-forward/i) }) + it('capabilities question has a curated answer: no webfetch, no engine talk', () => { + expect(AGENTS).toMatch(/## Answering "What can Amicode do\?"/) + expect(AGENTS).toMatch(/never webfetch/i) + expect(AGENTS).toMatch(/never describe the underlying engine/i) + expect(AGENTS).toMatch(/Today's scope/) + }) 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"/) From bedc549235f0f9b7c343372a51f422e5cd2a7ce4 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 3 Jul 2026 06:20:33 -0400 Subject: [PATCH 028/135] feat: private-mirror vendoring + team testing guide + Rydberg CZ template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fetch_opencode gains repo/tag manifest fields with authenticated gh download for the private harmoniqs/opencode release (206 tests). TESTING.md = the 7-point branch test script for the team. solve_rydberg_cz.jl: 2-atom 3-level QuEra deep-blockade CZ, public-Piccolo-only (fixed-phase NLP + post-hoc virtual-Z scan; free_phase kwarg is Piccolissimo-gated) — vetting solve in flight, score wiring lands with its result. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/extension/TESTING.md | 67 +++++++ packages/extension/scripts/fetch_opencode.mjs | 34 +++- .../extension/templates/solve_rydberg_cz.jl | 178 ++++++++++++++++++ .../extension/test/fetch_opencode.test.ts | 19 ++ 4 files changed, 296 insertions(+), 2 deletions(-) create mode 100644 packages/extension/TESTING.md create mode 100644 packages/extension/templates/solve_rydberg_cz.jl diff --git a/packages/extension/TESTING.md b/packages/extension/TESTING.md new file mode 100644 index 00000000..18afb583 --- /dev/null +++ b/packages/extension/TESTING.md @@ -0,0 +1,67 @@ +# Testing the Amicode night build (branch: `aaron/night-l0-pulse-designer`, PR #75) + +What this branch adds on top of main: the **pulse-designer interview** (Amico asks, you click), +the **entity rail** (System · Formulation · Run tracked live), **scores** (interview-as-data, +`packages/extension/scores/`), a **branded fork binary** (says AMICODE, H-robot mark, AMICO +question forms, H spinner), **7 `amicode_*` tools**, and a **Rydberg CZ template** alongside the +transmon one. Nothing in main's contracts changed — `amico-run`, run-dir, schemas, inspector are +untouched. + +## Prerequisites + +- Access to `harmoniqs/amicode` **and** `harmoniqs/opencode` (the private fork mirror — ask Aaron + if you get a 404), with `gh` CLI authed (`gh auth status`). +- Julia ≥ 1.12 (`curl -fsSL https://install.julialang.org | sh`), Node ≥ 20, `corepack enable`. + +## Install (~20 min, dominated by Julia precompile) + +```bash +git clone git@github.com:harmoniqs/amicode.git && cd amicode +git checkout aaron/night-l0-pulse-designer +corepack enable && pnpm install +pnpm --filter amicode-v2 package # builds + fetches the BRANDED binary from the mirror release +bash packages/extension/scripts/install.sh # Julia project + VSIX install + lab.toml +node packages/extension/scripts/healthcheck.mjs # expect 4/4 ✓ +``` + +**LLM provider:** `opencode auth login` (or `export ANTHROPIC_API_KEY=…`). Without it you get +opencode's free anonymous tier — it works, but expect occasional interview sloppiness (wrong +tool args, protocol drift). A Sonnet-class model is the intended experience. + +**Remote-SSH users:** the server port is fixed at **43117** — forward it once in the Ports view; +restarts reuse it. + +## What to test (in rough order) + +1. **Start screen** — open the Amicode chat: H-robot, AMICODE wordmark, tagline, ①②③, five + starter chips. Click **"Design a pulse — walk me through it."** +2. **The interview** — Amico should ask ONE question at a time, with clickable **AMICO · + Question** forms (options + descriptions + "type your own"). The **entity rail** at the top + should fill in as you answer (System → Formulation → Run). +3. **Transmon end-to-end** — X gate, defaults (T=10 ns, N=50): solve launches detached, the + **Run Inspector** pops with the live pulse, expect **F ≥ 0.999** in ~1–2 min warm. +4. **Fast path** — new session, type "optimize an X gate on my transmon, defaults" — should skip + the interview and launch directly. +5. **Rydberg** — pick "neutral-atom Rydberg" in the interview: CZ (the native gate) via + `templates/solve_rydberg_cz.jl` (QuEra gate-zone params; fixed-phase NLP + post-hoc virtual-Z + scan — both fidelities land in `result.toml`). +6. **"What can Amicode do?"** — should give the curated capability pitch (no web fetches). +7. **Thinking spinner** — the pulsing H glyph in the header/timeline while the model works. + +## Report + +- Anything that violates "one question at a time," answers its own question, or contradicts the + rail state → screenshot + the session transcript to the PR #75 thread. +- Solve failures → attach the run-dir's `run.log` (`~/.amico/runs/default//`). +- UX opinions welcome — most of tonight's build was steered live by exactly that. + +## Known caveats (honest list) + +- Free-tier model is non-deterministic on interview discipline; real creds fix most of it. +- `amicode_ask` is deprecated (native `question` tool replaced it) — old sessions may still show + its button cards. +- Hardware/calibrate stages are **guided stubs** — no device I/O, and they say so. +- Non-English locales in the chat UI still say OpenCode in places (en is the branded locale and + the default). +- The fork mirror (`harmoniqs/opencode`) is **private and must stay private** (MIT attribution + preserved; patch stack documented in `AMICODE-PATCHES.md` there). diff --git a/packages/extension/scripts/fetch_opencode.mjs b/packages/extension/scripts/fetch_opencode.mjs index c0ae9aab..811ab9d9 100644 --- a/packages/extension/scripts/fetch_opencode.mjs +++ b/packages/extension/scripts/fetch_opencode.mjs @@ -32,8 +32,20 @@ export function resolvePlatform(manifest, flag) { return key } +/** Release coordinates: default = upstream sst/opencode at v; a manifest + * with `repo`/`tag` set points at our fork's release instead (harmoniqs/opencode, + * private — downloads go through the authenticated `gh` path in that case). */ +export function releaseCoords(manifest) { + return { + repo: manifest.repo ?? 'sst/opencode', + tag: manifest.tag ?? `v${manifest.version}`, + private: manifest.repo != null, // our mirror is private; upstream is not + } +} + export function assetUrl(manifest, platform) { - return `https://github.com/sst/opencode/releases/download/v${manifest.version}/${manifest.platforms[platform].asset}` + const { repo, tag } = releaseCoords(manifest) + return `https://github.com/${repo}/releases/download/${tag}/${manifest.platforms[platform].asset}` } export const sha256 = (buf) => createHash('sha256').update(buf).digest('hex') @@ -47,6 +59,21 @@ async function defaultDownload(url) { return Buffer.from(await r.arrayBuffer()) } +/** Private-release download via the gh CLI (the team's auth path for our + * private repos). Plain fetch 404s on private assets — gh handles the token. */ +function ghDownload(repo, tag, asset) { + const work = mkdtempSync(join(PKG_ROOT, '.ghdl-')) + try { + execFileSync('gh', ['release', 'download', tag, '--repo', repo, '--pattern', asset, '--dir', work], + { stdio: ['ignore', 'ignore', 'inherit'] }) + return readFileSync(join(work, asset)) + } catch (e) { + throw new Error(`gh release download failed for ${repo}@${tag} ${asset}: ${e.message} — is \`gh\` installed and authed for ${repo}?`) + } finally { + rmSync(work, { recursive: true, force: true }) + } +} + export async function fetchOpencode({ root = PKG_ROOT, platform, download = defaultDownload } = {}) { const manifest = loadManifest(root) const key = resolvePlatform(manifest, platform) @@ -59,7 +86,10 @@ export async function fetchOpencode({ root = PKG_ROOT, platform, download = defa return { skipped: true, path: bin } // offline repeat builds } - const bytes = await download(assetUrl(manifest, key)) + const coords = releaseCoords(manifest) + const bytes = coords.private && download === defaultDownload + ? ghDownload(coords.repo, coords.tag, asset) + : await download(assetUrl(manifest, key)) const got = sha256(bytes) if (got !== want) { // Possible supply-chain signal: no retry, no override (spec §3 step 4). diff --git a/packages/extension/templates/solve_rydberg_cz.jl b/packages/extension/templates/solve_rydberg_cz.jl new file mode 100644 index 00000000..fa2eb603 --- /dev/null +++ b/packages/extension/templates/solve_rydberg_cz.jl @@ -0,0 +1,178 @@ +#!/usr/bin/env julia +# Amicode Rydberg CZ 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). +# +# Physics: two neutral atoms, 3 levels each (|0⟩ dark, laser couples |1⟩↔|r⟩, +# van-der-Waals blockade V·n⊗n on |rr⟩). CZ is the NATIVE entangling gate — +# defined on the {|0⟩,|1⟩}⊗2 computational subspace via EmbeddedOperator, and +# optimized with free_phase=true (CZ up to virtual single-atom Z rotations; +# fixed-phase fidelity systematically underreports entangling gates — both are +# recorded in result.toml, free-phase is the primary metric). +# Defaults = QuEra gate-zone (⁸⁷Rb, deep blockade, d = 2 μm). Units: μs, rad/μs. +using Piccolo +using CairoMakie # loads PiccoloMakieExt → gives LivePulsePlotCallback its impl +using JLD2 +using LinearAlgebra # Diagonal (free-phase goal construction) +using TOML +using Printf + +# ── FILL IN ────────────────────────────────────────────────────────────── +Ω_max = 4.6 * 2π # global Rabi bound (rad/μs) — QuEra gate zone +Δ_max = 20.0 * 2π # global detuning bound (rad/μs) +C6 = 28_800.0 * 2π # van der Waals coefficient (rad/μs·μm⁶) +distance = 2.0 # atom spacing (μm) → V = C6/d⁶ (deep blockade: V/Ω ≈ 98) +T = 0.5 # gate time (μs) — J&P speed limit ≈ 7.61/Ω_max ≈ 0.26 μs; fixed-phase needs extra slack +N = 51 # timesteps +max_iter = 400 +# ───────────────────────────────────────────────────────────────────────── + +V = C6 / distance^6 + +# Single-atom 3-level operators: basis |0⟩=1, |1⟩=2, |r⟩=3 (|0⟩ is dark). +const σx_1r = ComplexF64[0 0 0; 0 0 1; 0 1 0] # |1⟩⟨r| + h.c. +const σy_1r = ComplexF64[0 0 0; 0 0 -im; 0 im 0] # -i|1⟩⟨r| + i|r⟩⟨1| +const n_r = ComplexF64[0 0 0; 0 0 0; 0 0 1] # |r⟩⟨r| +const I3 = ComplexF64[1 0 0; 0 1 0; 0 0 1] + +# Two atoms, global drives (QuEra zoned architecture: no individual addressing). +Hx = kron(σx_1r, I3) + kron(I3, σx_1r) # u[1] = Ωx(t) +Hy = kron(σy_1r, I3) + kron(I3, σy_1r) # u[2] = Ωy(t) +Hn = kron(n_r, I3) + kron(I3, n_r) # u[3] = -Δ(t) (sign folded into H below) +H_drift = V * kron(n_r, n_r) # blockade shift on |rr⟩ + +H = (u, t) -> H_drift + u[1] * Hx + u[2] * Hy - u[3] * Hn +sys = QuantumSystem(H, [(-Ω_max, Ω_max), (-Ω_max, Ω_max), (-Δ_max, Δ_max)]) + +# CZ on the {|0⟩,|1⟩} subspace of each atom, embedded in the 9-dim space. +op = EmbeddedOperator(GATES[:CZ], [1, 2], [1:2, 1:2], [3, 3]) + +times = collect(range(0.0, T, length = N)) +initial = 0.1 * randn(sys.n_drives, N) +qtraj = UnitaryTrajectory(sys, ZeroOrderPulse(initial, times), op) +# NOTE: free_phase=true needs Piccolissimo's global-aware integrator — this +# template stays public-Piccolo-only (the lab project ships Piccolo). So the +# NLP targets FIXED-phase CZ (the detuning drive supplies the authority to null +# the single-atom phases), and the free-phase metric is recovered at report +# time by a virtual-Z scan over the rolled-out unitary (phases are software +# rotations; best-phase fidelity is the honest primary metric). +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 — same blessed idiom as the transmon template (AGENTS.md): +# LivePulsePlotCallback writes iter_.png; the Run Inspector reads the frames. +const PLOT_EVERY = 6 +live_plot = LivePulsePlotCallback(qtraj, prob.trajectory; every = PLOT_EVERY, save_dir = ".") + +# Pulse-data telemetry (#66): AMICODE_PULSE lines per iteration, riding the same +# solver-agnostic (primal, iter) hook as the live plot. Verbatim from the vetted +# transmon template — the contract is identical. +struct PulseEmitCallback <: AbstractIntermediateCallback + inner::Any + traj::Any +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 + 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 + 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 + end + return ok +end +pulse_emit = PulseEmitCallback(live_plot, prob.trajectory) + +let ls = "\"Ωx\",\"Ωy\",\"Δ\"", + bs = "$(-Ω_max):$(Ω_max),$(-Ω_max):$(Ω_max),$(-Δ_max):$(Δ_max)" + println("AMICODE_PULSE_META drives=$(sys.n_drives) knots=$N labels=$ls bounds=$bs") + flush(stdout) +end + +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 on the computational subspace from a fresh rollout (same rationale as +# the transmon template), reported BOTH ways: +# fixed-phase — raw CZ target; +# free-phase — CZ up to the optimized virtual Z's (φ_1, φ_2) — PRIMARY metric. +traj_final = get_trajectory(qcp) +Uroll = iso_vec_to_operator(unitary_rollout(traj_final, sys)[:, end]) +fid_fixed = unitary_fidelity(Uroll, op.operator; subspace = op.subspace) + +# Free-phase metric via post-hoc virtual-Z scan: CZ is equivalent up to +# single-atom Z rotations exp(i(φ₁n₁+φ₂n₂)); scan (φ₁,φ₂), then refine. The +# basis phase per computational state |b₁b₂⟩ is φ₁b₁+φ₂b₂ (the +# _make_free_phase_goal convention). +phase_fid(φ1, φ2) = unitary_fidelity(Uroll, + Diagonal(ComplexF64[1, exp(im * φ2), exp(im * φ1), exp(im * (φ1 + φ2))]) * GATES[:CZ]; + subspace = op.subspace) +best_f, best_φ1, best_φ2 = fid_fixed, 0.0, 0.0 +for φ1 in range(0, 2π; length = 73), φ2 in range(0, 2π; length = 73) + f = phase_fid(φ1, φ2) + if f > best_f + best_f, best_φ1, best_φ2 = f, φ1, φ2 + end +end +for δφ in (0.02, 0.002) # two local refinement passes around the grid optimum + for φ1 in range(best_φ1 - 5δφ, best_φ1 + 5δφ; length = 11), + φ2 in range(best_φ2 - 5δφ, best_φ2 + 5δφ; length = 11) + f = phase_fid(φ1, φ2) + if f > best_f + best_f, best_φ1, best_φ2 = f, φ1, φ2 + end + end +end +fid_free = best_f +phases = [best_φ1, best_φ2] +fid = max(fid_free, fid_fixed) + +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", traj_final) +open("result.toml.tmp", "w") do io + TOML.print(io, Dict( + "schema_version" => "1", + "fidelity" => fid, "iterations" => iters[], "wall_seconds" => wall, + "params" => Dict("platform" => "rydberg", "gate" => "CZ", + "Omega_max" => Ω_max, "Delta_max" => Δ_max, + "C6" => C6, "distance" => distance, + "V_blockade" => V, "T" => T, "N" => N, "max_iter" => max_iter, + "fidelity_fixed_phase" => fid_fixed, + "fidelity_free_phase" => fid_free, + "phi_1" => length(phases) == 2 ? phases[1] : 0.0, + "phi_2" => length(phases) == 2 ? phases[2] : 0.0), + )) +end +mv("result.toml.tmp", "result.toml"; force = true) +println("DONE fidelity=$(fid)"); flush(stdout) diff --git a/packages/extension/test/fetch_opencode.test.ts b/packages/extension/test/fetch_opencode.test.ts index 2ce32ba3..5d45f4ac 100644 --- a/packages/extension/test/fetch_opencode.test.ts +++ b/packages/extension/test/fetch_opencode.test.ts @@ -79,3 +79,22 @@ describe('fetchOpencode', () => { expect(existsSync(join(root, 'vendor', 'opencode', 'linux-x64', 'opencode'))).toBe(false) }) }) + +describe('releaseCoords — fork-mirror pinning', async () => { + const { releaseCoords, assetUrl } = await import('../scripts/fetch_opencode.mjs') + const platforms = { 'linux-x64': { asset: 'opencode-linux-x64.tar.gz', sha256: 'a'.repeat(64) } } + it('defaults to upstream at v, public', () => { + const m = { version: '1.17.3', platforms } + expect(releaseCoords(m)).toEqual({ repo: 'sst/opencode', tag: 'v1.17.3', private: false }) + expect(assetUrl(m, 'linux-x64')).toBe( + 'https://github.com/sst/opencode/releases/download/v1.17.3/opencode-linux-x64.tar.gz', + ) + }) + it('repo+tag repoint to the private mirror', () => { + const m = { version: '1.17.3', repo: 'harmoniqs/opencode', tag: 'v1.17.3-amicode.1', platforms } + expect(releaseCoords(m)).toEqual({ repo: 'harmoniqs/opencode', tag: 'v1.17.3-amicode.1', private: true }) + expect(assetUrl(m, 'linux-x64')).toBe( + 'https://github.com/harmoniqs/opencode/releases/download/v1.17.3-amicode.1/opencode-linux-x64.tar.gz', + ) + }) +}) From 42b64189d0b456c57db9e26f96c1f5addeb39952 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 3 Jul 2026 06:28:14 -0400 Subject: [PATCH 029/135] docs: root agent-forward setup guide (AGENTS.md + CLAUDE.md pointer) Point an agent at the repo and it can set up, verify, and test the branch: prerequisites with checks, ordered setup, five verification gates, dev facts (fixed port, fork boundary, plugin runtime, scores), sharp edges. --- AGENTS.md | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 5 ++++ 2 files changed, 80 insertions(+) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..8063ba10 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,75 @@ +# Amicode — agent setup & testing guide + +You are (probably) an agent asked to set up, develop, or test Amicode on this machine. +This file is the canonical path. The human-oriented test script is +`packages/extension/TESTING.md`; design authority lives in the `harmoniqs/amico` vault +(see `README.md`). **Do not confuse this file with `packages/extension/AGENTS.md`** — +that one is the product's runtime instruction set for the in-app agent, not for you. + +## What this is + +A VS Code extension (`packages/extension`, pnpm workspace) for conversational quantum +pulse design: chat (an opencode server we vendor, branded as Amicode) → guided interview +→ LLM-authored Julia solve (Piccolo) via `packages/amico-run` → live Run Inspector. +Interview flows are data (`packages/extension/scores/`). Schemas: `packages/schema`. + +## Prerequisites (verify each; do not assume) + +1. `node >= 20`, `corepack enable` (repo pins `pnpm@9.15.9` — never install pnpm globally) +2. Julia ≥ 1.12 via juliaup: `curl -fsSL https://install.julialang.org | sh` +3. `gh auth status` succeeds AND `gh repo view harmoniqs/opencode` succeeds + (private fork mirror — the vendored binary downloads from its release; if 404, stop + and tell the human to request access from Aaron) +4. An LLM provider for the chat: `opencode auth login` after the binary is vendored + (or `ANTHROPIC_API_KEY` in the environment). Without one, the free anonymous tier is + used — functional but flaky; do not judge interview-quality bugs on the free tier. + +## Setup (in order; each step has a check) + +```bash +git clone git@github.com:harmoniqs/amicode.git && cd amicode +git checkout aaron/night-l0-pulse-designer # the testing branch (PR #75) until merged +corepack enable && pnpm install # check: exits 0, lockfile untouched +pnpm -r build # check: packages/extension/dist/extension.js exists +pnpm --filter amicode-v2 run fetch:opencode # check: vendor/opencode//opencode exists + # (downloads via gh from harmoniqs/opencode release) +pnpm --filter amicode-v2 test # check: 200+ tests pass, 0 fail +bash packages/extension/scripts/install.sh # Julia project (~15 min first precompile) + VSIX + lab.toml +node packages/extension/scripts/healthcheck.mjs # check: 4/4 ✓ (julia, opencode, amico-run, creds) +``` + +macOS note: the vendored binary is unsigned — if Gatekeeper blocks it: +`xattr -d com.apple.quarantine packages/extension/vendor/opencode/darwin-arm64/opencode` + +## Verification gates (run before claiming anything works) + +| Gate | Command | Expect | +|---|---|---| +| Fast suite | `pnpm --filter amicode-v2 test` | all pass | +| Boot smoke | `pnpm --filter amicode-v2 run test:smoke` | `[smoke] PASS` | +| Julia solve E2E | `AMICO_TEST_JULIA_PROJECT=$HOME/.amico/julia pnpm --filter amicode-v2 run test:slow` | template vet passes, F > 0.999 | +| Live interview E2E | `cd packages/extension && AMICODE_E2E_LIVE=1 npx vitest run test/slow/interview_e2e.test.ts` | tiers A/B/C pass (C needs a provider; flaky on free tier) | +| Full chain (opt-in, ~3 min) | add `AMICODE_E2E_FULLCHAIN=1` | tier D: interview → real solve → F > 0.99 | + +## Development facts you need + +- **Dev host**: open this repo in VS Code, F5 ("Run Extension (amicode-v2)"). The opencode + server runs on **fixed port 43117** (`amicode.opencodePort`); Remote-SSH users forward it once. +- **The vendored binary is a build artifact** — never edit it; it comes from + `harmoniqs/opencode` (thin fork, patch stack in its `AMICODE-PATCHES.md`). Rebrand/UI work + happens THERE, product logic lives HERE in config/plugin/scores (Layer 0). +- `packages/extension/opencode-plugin/` executes inside opencode's Bun runtime — it is NOT + part of the extension bundle; keep it dependency-free; exactly one export. +- `packages/extension/scores/` — interview flows as data. New user path = new `SCORE.md` + (see `scores/README.md`); lint gate: `pnpm --filter amicode-v2 test -- repertoire_lint`. +- Run artifacts land in `~/.amico/runs/default//` (contract: `run.toml`, `AMICODE_ITER` + lines, `iter_*.png`, `result.toml`, `pulse.jld2`, `FINISHED`). Validate files with + `packages/schema/launcher/amico-validate `. +- Never commit to `main`; branch + PR. Testing feedback → PR #75 thread. + +## Known sharp edges + +- `test:slow` without `AMICO_TEST_JULIA_PROJECT` silently skips the Julia gates. +- The vendor `.sha256` stamp must match the lock manifest or `fetch:opencode` re-downloads. +- Free-tier live e2e tiers are non-deterministic; a single tier-C failure is sampling noise. +- Julia 1.12.x minor-version drift vs the pinned Manifest prints a warning and proceeds. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..427a8422 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,5 @@ +# CLAUDE.md + +Read `AGENTS.md` in this directory — it is the canonical agent-forward setup, development, +and testing guide for this repo. Follow it exactly (prerequisites → setup → verification +gates). Human test script: `packages/extension/TESTING.md`. From f6a0e6765719da01dbc9eb5edb08aac75f267731 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 3 Jul 2026 06:45:46 -0400 Subject: [PATCH 030/135] chore: vendor from the harmoniqs/opencode private release (v1.17.3-amicode.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lock repointed to the mirror (both platform shas from the release assets); clean-fetch verified end-to-end via the authenticated gh path — downloaded binary is byte-identical to the deployed build (8aa718ad…) and serves Amicode. Rydberg CZ template clearly marked EXPERIMENTAL (vetting stalled on pathological first-iteration NLP cost; parked) and TESTING.md de-overclaimed accordingly. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/extension/TESTING.md | 8 +++++--- packages/extension/opencode.lock.json | 6 ++++-- packages/extension/templates/solve_rydberg_cz.jl | 13 +++++++++++-- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/packages/extension/TESTING.md b/packages/extension/TESTING.md index 18afb583..5bb9c1b1 100644 --- a/packages/extension/TESTING.md +++ b/packages/extension/TESTING.md @@ -42,9 +42,11 @@ restarts reuse it. **Run Inspector** pops with the live pulse, expect **F ≥ 0.999** in ~1–2 min warm. 4. **Fast path** — new session, type "optimize an X gate on my transmon, defaults" — should skip the interview and launch directly. -5. **Rydberg** — pick "neutral-atom Rydberg" in the interview: CZ (the native gate) via - `templates/solve_rydberg_cz.jl` (QuEra gate-zone params; fixed-phase NLP + post-hoc virtual-Z - scan — both fidelities land in `result.toml`). +5. **Rydberg** — pick "neutral-atom Rydberg" in the interview: expect the *honest scope* + behavior (System recorded, formulation captured for follow-up — no dead reckoning). An + **experimental** CZ template exists (`templates/solve_rydberg_cz.jl`, QuEra gate-zone + params, public-Piccolo-only) but is NOT yet vetted — its first NLP iteration is + pathologically slow (under investigation); don't wire it into demos yet. 6. **"What can Amicode do?"** — should give the curated capability pitch (no web fetches). 7. **Thinking spinner** — the pulsing H glyph in the header/timeline while the model works. diff --git a/packages/extension/opencode.lock.json b/packages/extension/opencode.lock.json index aeb4282b..f6685932 100644 --- a/packages/extension/opencode.lock.json +++ b/packages/extension/opencode.lock.json @@ -1,7 +1,9 @@ { "version": "1.17.3", + "repo": "harmoniqs/opencode", + "tag": "v1.17.3-amicode.1", "platforms": { - "darwin-arm64": { "asset": "opencode-darwin-arm64.zip", "sha256": "b49948f96d8e92c577d54854e2f038389d03c3dfbbeacc44643b73123210fd13" }, - "linux-x64": { "asset": "opencode-linux-x64.tar.gz", "sha256": "d4bd238a2c1ff56aca1cd3397d21a0a317f5992234517a7f8e2afbbd72010a7d" } + "darwin-arm64": { "asset": "opencode-darwin-arm64.zip", "sha256": "f1d6291485246e03a3d33eefb40b4d264d68c8e5812b11b4ffdfec4444ab9455" }, + "linux-x64": { "asset": "opencode-linux-x64.tar.gz", "sha256": "268596e61475dd79d3076dd65d39bf97926d56c28477419250ab3c678bd55b10" } } } diff --git a/packages/extension/templates/solve_rydberg_cz.jl b/packages/extension/templates/solve_rydberg_cz.jl index fa2eb603..abc23e7b 100644 --- a/packages/extension/templates/solve_rydberg_cz.jl +++ b/packages/extension/templates/solve_rydberg_cz.jl @@ -1,4 +1,10 @@ #!/usr/bin/env julia +# ⚠️ EXPERIMENTAL — NOT YET VETTED. The NLP's first iteration is pathologically +# slow at this problem size in the current Piccolo path (both closure- and +# matrix-form systems tried; single iteration > 25 min where the transmon +# template solves in 72 s). Under investigation — do not wire into scores or +# demos until a vetting solve completes with F > 0.99. +# # Amicode Rydberg CZ 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). @@ -41,8 +47,11 @@ Hy = kron(σy_1r, I3) + kron(I3, σy_1r) # u[2] = Ωy(t) Hn = kron(n_r, I3) + kron(I3, n_r) # u[3] = -Δ(t) (sign folded into H below) H_drift = V * kron(n_r, n_r) # blockade shift on |rr⟩ -H = (u, t) -> H_drift + u[1] * Hx + u[2] * Hy - u[3] * Hn -sys = QuantumSystem(H, [(-Ω_max, Ω_max), (-Ω_max, Ω_max), (-Δ_max, Δ_max)]) +# Matrix form (NOT a closure): hands Piccolo the bilinear structure analytically — +# the function-Hamiltonian path forces AD through the closure per NLP evaluation +# and is orders of magnitude slower at 9-dim. Detuning sign folded into the drive. +sys = QuantumSystem(H_drift, [Hx, Hy, -Hn], + [(-Ω_max, Ω_max), (-Ω_max, Ω_max), (-Δ_max, Δ_max)]) # CZ on the {|0⟩,|1⟩} subspace of each atom, embedded in the 9-dim space. op = EmbeddedOperator(GATES[:CZ], [1, 2], [1:2, 1:2], [3, 3]) From d81e63f10c7e2cbb748b668c4238a5fc2fb08c4b Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 3 Jul 2026 17:07:40 -0400 Subject: [PATCH 031/135] test: align buildOpencodeConfigContent call sites with rebased runsRoot signature The rebase onto main folded in #79's runsRoot param (3rd position). These test call sites predated it and passed pluginPath/scoresRoot into the wrong slots or omitted runsRoot entirely. Insert runsRoot so scores + slow-e2e configs grant the right paths. Fast suite 207 pass; tsc --noEmit clean. Co-Authored-By: Claude Fable 5 --- .../extension/test/scores/prep_integration.test.ts | 12 ++++++++++-- packages/extension/test/slow/interview_e2e.test.ts | 2 +- packages/extension/test/slow/scores_e2e.test.ts | 2 +- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/extension/test/scores/prep_integration.test.ts b/packages/extension/test/scores/prep_integration.test.ts index fa092ce6..b5c0b8c0 100644 --- a/packages/extension/test/scores/prep_integration.test.ts +++ b/packages/extension/test/scores/prep_integration.test.ts @@ -78,12 +78,20 @@ describe("prepareOpencodeProject × scores (spec §6)", () => { 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")); + const cfg = JSON.parse( + buildOpencodeConfigContent("/abs/AGENTS.md", "/abs/templates/solve_template.jl", "/home/u/.amico/runs/default"), + ); 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"), + buildOpencodeConfigContent( + "/abs/AGENTS.md", + "/abs/templates/solve_template.jl", + "/home/u/.amico/runs/default", + "/p/plugin.ts", + "/custom/scores", + ), ); expect(cfg.permission.external_directory["/custom/scores/**"]).toBe("allow"); }); diff --git a/packages/extension/test/slow/interview_e2e.test.ts b/packages/extension/test/slow/interview_e2e.test.ts index 21cd54bf..b8f2b5af 100644 --- a/packages/extension/test/slow/interview_e2e.test.ts +++ b/packages/extension/test/slow/interview_e2e.test.ts @@ -43,7 +43,7 @@ function hasCreds(): boolean { * 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')) + return buildOpencodeConfigContent(agentsPath, join(EXT, 'templates', 'solve_template.jl'), join(homedir(), '.amico', 'runs', 'default')) } interface Server { child: ChildProcess; url: string; log: () => string } diff --git a/packages/extension/test/slow/scores_e2e.test.ts b/packages/extension/test/slow/scores_e2e.test.ts index f460403f..d3390f6c 100644 --- a/packages/extension/test/slow/scores_e2e.test.ts +++ b/packages/extension/test/slow/scores_e2e.test.ts @@ -50,7 +50,7 @@ async function serveWithScores(port: number) { 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')) + env.OPENCODE_CONFIG_CONTENT = buildOpencodeConfigContent(project.agentsPath, join(EXT, 'templates', 'solve_template.jl'), join(homedir(), '.amico', 'runs', 'default')) let buf = '' const child = spawn(OC_BIN, ['serve', '--port', String(port)], { env, stdio: ['ignore', 'pipe', 'pipe'] }) servers.push(child) From 0ff98139dba10dbba5f834d6f5fe62746816cdd2 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 3 Jul 2026 19:02:20 -0400 Subject: [PATCH 032/135] feat(spec-a): opened entity model, canonical json, slug, diff + sentinel truncation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SystemEntity.platform → open string; levels optional; notes field (hash-excluded) - FormulationEntity.solve sub-object (T/N/max_iter/integrator/parameterization/pinned_globals) → #64 formulation_hash input - RunStub gains tier/script_ref/env; ProblemMeta + RunRef types - problemToml/runRefsToml + JSON sidecars; canonicalJson, deriveSlug, entityDiff, truncateDiffForSentinel - validateSystem opened (non-empty platform, optional levels ≥2, no upper cap) Co-Authored-By: Claude Fable 5 --- .../extension/opencode-plugin/entities.ts | 249 ++++++++++++++++-- packages/extension/test/amicode_tools.test.ts | 93 ++++++- 2 files changed, 318 insertions(+), 24 deletions(-) diff --git a/packages/extension/opencode-plugin/entities.ts b/packages/extension/opencode-plugin/entities.ts index 4747467b..6078769c 100644 --- a/packages/extension/opencode-plugin/entities.ts +++ b/packages/extension/opencode-plugin/entities.ts @@ -24,10 +24,31 @@ // ============================================================================ export interface SystemEntity { - platform: "transmon" | "rydberg"; - levels: number; + /** Open platform string (spec A): any platform a Legato/Piccolo/Intonato user + * names. Known platforms (KNOWN_PLATFORMS) keep their affordances; unknown + * ones are recorded honestly. Validated non-empty. */ + platform: string; + /** Optional — platform-dependent. Only transmon defaults to 3; unknown + * platforms get no default. When given: integer >= MIN_LEVELS (no upper + * error — the old <=6 cap is now a tool-side warning, not a validation error). */ + levels?: number; /** Named physical parameters, e.g. omega/delta (GHz), drive_max. */ params: Record; + /** Free text for what params can't hold (e.g. topology prose). EXCLUDED from + * the canonical hash input (prose edits must not churn identity). */ + notes?: string; +} + +/** Structured solve parameters merged into the Formulation (spec A): they are the + * hash-relevant "duration/knots + integrator + parameterization + pinned globals" + * half of amicode#64's formulation_hash. Written by amicode_solve. */ +export interface SolveParams { + T?: number; + N?: number; + max_iter?: number; + integrator?: string; + parameterization?: string; + pinned_globals?: string[]; } export interface FormulationEntity { @@ -35,6 +56,8 @@ export interface FormulationEntity { target: string; objective: string; constraints: string[]; + /** Solve params (spec A) — present once amicode_solve has recorded them. */ + solve?: SolveParams; } export interface RunStub { @@ -42,10 +65,49 @@ export interface RunStub { system_ref?: string; /** Run directory, when the bash launch already happened and the agent knows it. */ run_dir?: string; + /** Authoring tier (spec C): vetted | composed | free. */ + tier?: "vetted" | "composed" | "free"; + /** Path to the authored script (spec C — workspace-owned solve.jl). */ + script_ref?: string; + /** Resolved env binding kind (spec C). */ + env?: string; /** Optional free-text note ("X gate, defaults"). */ note?: string; } +/** Problem workspace identity (spec A) — the `[problem]` table of problem.toml. */ +export interface ProblemScoreRef { + id: string; + version: number; +} + +export interface ProblemEnvBinding { + /** provisioned (~/.amico/julia) | project (a Julia project path) | sandbox + * (generated per-problem). NO cloud kind — executor routing is per-solve. */ + kind: "provisioned" | "project" | "sandbox"; + path?: string; +} + +export interface ProblemMeta { + name: string; + slug: string; + created: string; + /** Only these two persist; solving/solved are display-derived (spec A). */ + status: "designing" | "archived"; + recorded?: string; + score?: ProblemScoreRef; + env?: ProblemEnvBinding; +} + +/** A reference into ~/.amico/runs// (spec A) — the workspace stores + * refs only; RunsManager's runs/index is the run source of truth. */ +export interface RunRef { + run_id: string; + lab: string; + tier?: "vetted" | "composed" | "free"; + recorded: 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 @@ -66,20 +128,29 @@ export interface CalibrationStub { note?: string; } -export const PLATFORMS = ["transmon", "rydberg"] as const; +/** Platforms with built-in affordances (Hamiltonian LaTeX, defaults). NOT a + * closed validation set anymore (spec A opened `platform` to any string) — this + * is the hint list for tool descriptions. PLATFORMS kept as an alias for the + * existing amicode_tools.ts import. */ +export const KNOWN_PLATFORMS = ["transmon", "rydberg"] as const; +export const PLATFORMS = KNOWN_PLATFORMS; export const MIN_LEVELS = 2; +/** Soft cap: >MAX_LEVELS is a tool-side warning, no longer a validation error. */ export const MAX_LEVELS = 6; // --- validation -------------------------------------------------------------- -/** Problems with a SystemEntity; [] means valid. */ +/** Problems with a SystemEntity; [] means valid. Opened model (spec A): any + * non-empty platform string; levels optional and, when given, an integer + * >= MIN_LEVELS (no upper bound — the >6 case is a warning surfaced by the + * tool, not a validation error). */ 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 (typeof e.platform !== "string" || e.platform.trim() === "") { + problems.push(`platform must be a non-empty string`); } - 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}`); + if (e.levels !== undefined && (!Number.isInteger(e.levels) || e.levels < MIN_LEVELS)) { + problems.push(`levels, when given, must be an integer >= ${MIN_LEVELS}, got ${e.levels}`); } for (const [k, v] of Object.entries(e.params ?? {})) { if (typeof v !== "number" || !Number.isFinite(v)) { @@ -117,6 +188,7 @@ export function updateSystem(existing: SystemEntity, patch: SystemPatch): System levels: patch.levels ?? existing.levels, params: { ...existing.params, ...(patch.params ?? {}) }, }; + if (existing.notes !== undefined) merged.notes = existing.notes; const problems = validateSystem(merged); if (problems.length) throw new Error(`invalid system after merge: ${problems.join("; ")}`); return merged; @@ -163,15 +235,11 @@ function isoNow(now?: Date): string { 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)}`), - ]; + const lines = ["[system]", `platform = ${tomlEscape(e.platform)}`]; + if (e.levels !== undefined) lines.push(`levels = ${e.levels}`); + if (e.notes !== undefined) lines.push(`notes = ${tomlEscape(e.notes)}`); + lines.push(`recorded = ${tomlEscape(isoNow(now))}`, "", "[system.params]"); + lines.push(...Object.entries(e.params).map(([k, v]) => `${tomlKey(k)} = ${tomlNumber(v)}`)); return lines.join("\n") + "\n"; } @@ -187,6 +255,20 @@ export function formulationToml(e: FormulationEntity, now?: Date): string { `constraints = [${e.constraints.map(tomlEscape).join(", ")}]`, `recorded = ${tomlEscape(isoNow(now))}`, ]; + // [formulation.solve] sub-table (spec A) — MUST follow all [formulation] + // scalar keys (TOML: no keys added to a table after a sub-table opens). + if (e.solve) { + const s = e.solve; + lines.push("", "[formulation.solve]"); + if (s.T !== undefined) lines.push(`T = ${tomlNumber(s.T)}`); + if (s.N !== undefined) lines.push(`N = ${tomlNumber(s.N)}`); + if (s.max_iter !== undefined) lines.push(`max_iter = ${tomlNumber(s.max_iter)}`); + if (s.integrator !== undefined) lines.push(`integrator = ${tomlEscape(s.integrator)}`); + if (s.parameterization !== undefined) lines.push(`parameterization = ${tomlEscape(s.parameterization)}`); + if (s.pinned_globals !== undefined) { + lines.push(`pinned_globals = [${s.pinned_globals.map(tomlEscape).join(", ")}]`); + } + } return lines.join("\n") + "\n"; } @@ -199,12 +281,145 @@ export function runStubToml(stub: RunStub, now?: Date): string { 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)}`); + if (stub.tier !== undefined) lines.push(`tier = ${tomlEscape(stub.tier)}`); + if (stub.script_ref !== undefined) lines.push(`script_ref = ${tomlEscape(stub.script_ref)}`); + if (stub.env !== undefined) lines.push(`env = ${tomlEscape(stub.env)}`); 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"; } +// --- problem workspace serializers (spec A) ---------------------------------- + +/** Serialize ProblemMeta under [problem] (+ [problem.score]/[problem.env]). + * `recorded` defaults to now when absent. */ +export function problemToml(meta: ProblemMeta, now?: Date): string { + const lines = [ + "[problem]", + `name = ${tomlEscape(meta.name)}`, + `slug = ${tomlEscape(meta.slug)}`, + `created = ${tomlEscape(meta.created)}`, + `status = ${tomlEscape(meta.status)}`, + `recorded = ${tomlEscape(meta.recorded ?? isoNow(now))}`, + ]; + if (meta.score) { + lines.push("", "[problem.score]", `id = ${tomlEscape(meta.score.id)}`, `version = ${meta.score.version}`); + } + if (meta.env) { + lines.push("", "[problem.env]", `kind = ${tomlEscape(meta.env.kind)}`); + if (meta.env.path !== undefined) lines.push(`path = ${tomlEscape(meta.env.path)}`); + } + return lines.join("\n") + "\n"; +} + +/** Serialize an array of RunRefs as [[runs]] array-of-tables. */ +export function runRefsToml(refs: RunRef[]): string { + const blocks = refs.map((r) => { + const lines = ["[[runs]]", `run_id = ${tomlEscape(r.run_id)}`, `lab = ${tomlEscape(r.lab)}`]; + if (r.tier !== undefined) lines.push(`tier = ${tomlEscape(r.tier)}`); + lines.push(`recorded = ${tomlEscape(r.recorded)}`); + return lines.join("\n"); + }); + return blocks.length ? blocks.join("\n\n") + "\n" : ""; +} + +/** JSON sidecars — the machine-read source (the plugin is TOML-writer-only, so + * all reads/merges go through these). */ +export function problemJson(meta: ProblemMeta): string { + return JSON.stringify(meta, null, 2) + "\n"; +} + +export function runRefsJson(refs: RunRef[]): string { + return JSON.stringify({ runs: refs }, null, 2) + "\n"; +} + +// --- canonical serialization + diffs (spec A / amicode#64) ------------------- + +/** Keys excluded from the canonical hash input: `recorded` (clock ticks) and + * `notes` (prose) must not churn entity identity. The #64 coordination seam — + * number normalization is JSON.stringify's default for v1 (revisit with #64). */ +const HASH_EXCLUDED_KEYS = new Set(["recorded", "notes"]); + +function canonicalize(value: unknown): unknown { + if (value === null || typeof value !== "object") return value; + if (Array.isArray(value)) return value.map(canonicalize); + const out: Record = {}; + for (const key of Object.keys(value as Record).sort()) { + if (HASH_EXCLUDED_KEYS.has(key)) continue; + const v = (value as Record)[key]; + if (v === undefined) continue; + out[key] = canonicalize(v); + } + return out; +} + +/** Canonical JSON: recursively key-sorted, `recorded`/`notes` and undefined + * dropped at every level. The hash input for hashes.ts (spec A / #64). */ +export function canonicalJson(value: unknown): string { + return JSON.stringify(canonicalize(value)); +} + +/** Kebab-case slug from a problem name; empty result → "untitled". */ +export function deriveSlug(name: string): string { + const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); + return slug || "untitled"; +} + +/** Flatten one level of nested objects to dotted keys (`params.drive_max`), + * dropping `recorded`. Arrays are treated as scalar values. */ +function flattenForDiff(e: Record | undefined): Record { + const out: Record = {}; + if (!e) return out; + for (const [k, v] of Object.entries(e)) { + if (k === "recorded") continue; + if (v && typeof v === "object" && !Array.isArray(v)) { + for (const [sk, sv] of Object.entries(v as Record)) out[`${k}.${sk}`] = sv; + } else { + out[k] = v; + } + } + return out; +} + +/** Structured diff of two entity snapshots → { dottedKey: {from, to} } for + * changed keys only. `before === undefined` (create) → every `from` is null. */ +export function entityDiff( + before: Record | undefined, + after: Record | undefined, +): Record { + const b = flattenForDiff(before); + const a = flattenForDiff(after); + const diff: Record = {}; + for (const k of new Set([...Object.keys(b), ...Object.keys(a)])) { + const fromV = k in b ? b[k] : null; + const toV = k in a ? a[k] : null; + if (JSON.stringify(fromV) !== JSON.stringify(toV)) { + diff[k] = { from: before === undefined ? null : fromV, to: toV }; + } + } + return diff; +} + +/** Keep the AMICODE_DIFF sentinel line small: truncate long string values, then, + * if still over budget, drop trailing entries and mark with an "…" key. */ +export function truncateDiffForSentinel( + diff: Record, + maxBytes = 1024, +): Record { + const trunc = (v: unknown): unknown => + typeof v === "string" && v.length > 120 ? v.slice(0, 120) + "…" : v; + const out: Record = {}; + for (const [k, { from, to }] of Object.entries(diff)) out[k] = { from: trunc(from), to: trunc(to) }; + const keys = Object.keys(out); + const total = keys.length; + while (JSON.stringify(out).length > maxBytes && keys.length > 0) { + delete out[keys.pop()!]; + out["…"] = { from: null, to: `${total - keys.length} more fields` }; + } + return out; +} + /** 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() === "") { diff --git a/packages/extension/test/amicode_tools.test.ts b/packages/extension/test/amicode_tools.test.ts index d3c4c73b..10cba535 100644 --- a/packages/extension/test/amicode_tools.test.ts +++ b/packages/extension/test/amicode_tools.test.ts @@ -23,8 +23,15 @@ import { validateSystem, validateFormulation, updateSystem, + canonicalJson, + deriveSlug, + entityDiff, + truncateDiffForSentinel, + problemToml, + runRefsToml, type SystemEntity, type FormulationEntity, + type ProblemMeta, } from '../opencode-plugin/entities' const SYS: SystemEntity = { @@ -58,13 +65,14 @@ describe('systemToml', () => { 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('accepts an arbitrary platform, rejects an empty one (opened model, spec A)', () => { + expect(() => systemToml({ ...SYS, platform: 'gkp-cavity' })).not.toThrow() + expect(() => systemToml({ ...SYS, platform: '' })).toThrow(/platform/) }) - it('rejects levels < 2, > 6, and non-integers', () => { + it('rejects levels < 2 and non-integers, but allows levels > 6 (warning, not error)', () => { expect(() => systemToml({ ...SYS, levels: 1 })).toThrow(/levels/) - expect(() => systemToml({ ...SYS, levels: 7 })).toThrow(/levels/) expect(() => systemToml({ ...SYS, levels: 3.5 })).toThrow(/levels/) + expect(() => systemToml({ ...SYS, levels: 7 })).not.toThrow() }) it('rejects non-finite param values (NaN/Infinity have no TOML representation)', () => { expect(() => systemToml({ ...SYS, params: { omega: NaN } })).toThrow(/param/) @@ -106,8 +114,8 @@ describe('validateSystem / validateFormulation', () => { 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(validateSystem({ ...SYS, platform: '' as any }).join(' ')).toMatch(/platform/) + expect(validateSystem({ ...SYS, levels: 1 }).join(' ')).toMatch(/levels/) expect(validateFormulation({ ...FORM, target: '' }).join(' ')).toMatch(/target/) }) }) @@ -130,7 +138,7 @@ describe('updateSystem (the amicode_set_model merge)', () => { 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, { levels: 1 })).toThrow(/levels/) expect(() => updateSystem(SYS, { params: { omega: NaN } })).toThrow(/param/) }) }) @@ -212,3 +220,74 @@ describe('calibrationStubToml (guided follow-up stub — loop not wired in this expect(() => calibrationStubToml({ device_session_ref: '' })).toThrow(/device_session_ref/) }) }) + +describe('opened entity model (spec A)', () => { + it('accepts an unknown platform and optional levels', () => { + expect(validateSystem({ platform: 'gkp-cavity', params: { chi: 0.5 } } as SystemEntity)).toEqual([]) + expect(validateSystem({ platform: '', params: {} } as SystemEntity)).not.toEqual([]) + }) + it('warns but does not reject levels > 6', () => { + expect(validateSystem({ platform: 'transmon', levels: 7, params: {} } as SystemEntity)).toEqual([]) + }) + it('round-trips formulation.solve through TOML', () => { + const f: FormulationEntity = { + problem: 'min_time', target: 'CZ', objective: 'unitary infidelity', + constraints: ['amplitude bound'], solve: { T: 10, N: 50, max_iter: 60, integrator: 'MagnusGL4' }, + } + const parsed = parse(formulationToml(f)) as any + expect(parsed.formulation.solve.T).toBe(10) + expect(parsed.formulation.solve.integrator).toBe('MagnusGL4') + }) +}) + +describe('canonicalJson + hash input rules', () => { + it('sorts keys and excludes recorded/notes', () => { + expect(canonicalJson({ b: 1, a: 2, recorded: 'x', notes: 'y' })).toBe('{"a":2,"b":1}') + }) + it('is stable across key order', () => { + expect(canonicalJson({ x: { b: 1, a: [1, 2] } })).toBe(canonicalJson({ x: { a: [1, 2], b: 1 } })) + }) +}) + +describe('deriveSlug', () => { + it('kebab-cases and strips punctuation', () => { + expect(deriveSlug('X gate on Q1!')).toBe('x-gate-on-q1') + expect(deriveSlug('///')).toBe('untitled') + }) +}) + +describe('entityDiff + sentinel truncation', () => { + it('produces dotted keys for nested params and skips recorded', () => { + const d = entityDiff( + { levels: 3, params: { drive_max: 0.2 } }, + { levels: 4, params: { drive_max: 0.2 }, recorded: 'x' }, + ) + expect(d).toEqual({ levels: { from: 3, to: 4 } }) + }) + it('null from on create', () => { + expect(entityDiff(undefined, { platform: 'transmon' })).toEqual({ platform: { from: null, to: 'transmon' } }) + }) + it('keeps the sentinel line under 1 KB', () => { + const big = entityDiff(undefined, { notes2: 'z'.repeat(5000) }) + const line = JSON.stringify(truncateDiffForSentinel(big)) + expect(line.length).toBeLessThanOrEqual(1024) + expect(line).toContain('…') + }) +}) + +describe('problem + run-ref serializers', () => { + it('round-trips problem.toml', () => { + const meta: ProblemMeta = { + name: 'X gate on Q1', slug: 'x-gate-q1', created: '2026-07-03T00:00:00Z', + status: 'designing', score: { id: 'pulse-designer', version: 3 }, env: { kind: 'provisioned' }, + } + const parsed = parse(problemToml(meta)) as any + expect(parsed.problem.slug).toBe('x-gate-q1') + expect(parsed.problem.score.id).toBe('pulse-designer') + expect(parsed.problem.env.kind).toBe('provisioned') + }) + it('round-trips runs.toml appends', () => { + const t = runRefsToml([{ run_id: 'r1', lab: 'default', tier: 'vetted', recorded: 'x' }]) + expect((parse(t) as any).runs[0].tier).toBe('vetted') + }) +}) From 8bb9436009d044ee30ca17d171be05eb1f81d503 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 3 Jul 2026 19:06:39 -0400 Subject: [PATCH 033/135] =?UTF-8?q?feat(spec-a):=20hashes.ts=20=E2=80=94?= =?UTF-8?q?=20sha256=20over=20canonical=20entity=20json=20(#64=20seam)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- packages/extension/opencode-plugin/hashes.ts | 28 ++++++++++++++++++++ packages/extension/test/hashes.test.ts | 21 +++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 packages/extension/opencode-plugin/hashes.ts create mode 100644 packages/extension/test/hashes.test.ts diff --git a/packages/extension/opencode-plugin/hashes.ts b/packages/extension/opencode-plugin/hashes.ts new file mode 100644 index 00000000..bcd289e7 --- /dev/null +++ b/packages/extension/opencode-plugin/hashes.ts @@ -0,0 +1,28 @@ +// ============================================================================ +// SHA-256 hashing for the amicode_* tool pack (spec A / amicode#64). +// +// SIBLING-MODULE RULES (same as ./score_guard): imported by amicode_tools.ts +// inside opencode's Bun runtime via a relative `./hashes` import — node: builtins +// only, named exports fine (the single-export constraint is the plugin entry's, +// not this file's). Its logic is pure and unit-tested from test/hashes.test.ts. +// +// This file is DELIBERATELY separate from ./entities: entities.ts is +// dependency-free / dual-runtime (no node: builtins, so no `node:crypto`), so the +// hash lives here and consumes entities.ts's pure `canonicalJson`. `system_hash` +// / `formulation_hash` (amicode#64's System ÷ Formulation identity cut) are +// `entityHash(entity)` over the canonical serialization. +// ============================================================================ + +import { createHash } from "node:crypto"; +import { canonicalJson } from "./entities"; + +/** Hex SHA-256 of a UTF-8 string. */ +export function sha256Hex(s: string): string { + return createHash("sha256").update(s, "utf8").digest("hex"); +} + +/** Content hash of an entity over its canonical JSON (key-sorted, recorded/notes + * excluded). Prefixed `sha256:` for self-describing storage in events/run.toml. */ +export function entityHash(entity: unknown): string { + return "sha256:" + sha256Hex(canonicalJson(entity)); +} diff --git a/packages/extension/test/hashes.test.ts b/packages/extension/test/hashes.test.ts new file mode 100644 index 00000000..ad74dfa8 --- /dev/null +++ b/packages/extension/test/hashes.test.ts @@ -0,0 +1,21 @@ +// Tests for the amicode_* plugin's hashing sibling (opencode-plugin/hashes.ts). +// +// hashes.ts uses node:crypto — it is NOT importable into entities.ts (which is +// dependency-free / dual-runtime). It follows the score_guard.ts sibling rules: +// node: builtins allowed, named exports fine. Exercised here as plain functions. +import { describe, it, expect } from 'vitest' +import { sha256Hex, entityHash } from '../opencode-plugin/hashes' + +describe('sha256Hex', () => { + it('matches the known vector for "abc"', () => { + expect(sha256Hex('abc')).toBe('ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad') + }) +}) + +describe('entityHash', () => { + it('is prefixed with sha256: and stable across key order + excluded keys', () => { + const h = entityHash({ b: 1, a: 2, recorded: 'x' }) + expect(h.startsWith('sha256:')).toBe(true) + expect(entityHash({ a: 2, b: 1 })).toBe(h) + }) +}) From 2ccae6b2f102bc436e5b55c4b99fd13a0d39deb2 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 3 Jul 2026 19:10:21 -0400 Subject: [PATCH 034/135] =?UTF-8?q?feat(spec-a):=20problems.ts=20=E2=80=94?= =?UTF-8?q?=20workspace=20fs=20ops,=20active=20pointer,=20events,=20run=20?= =?UTF-8?q?refs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - create/open(fuzzy)/rename(untitled re-slug)/archive; ensureActiveProblem (auto-create on absent/dangling) - appendEvent (monotonic seq, problem-lifecycle + entity events, provenance:null) - appendRunRef (both runs.toml + runs.json); writeEntityFiles; listProblems - migrateLegacyEntities no-op stub (Task 6) Co-Authored-By: Claude Fable 5 --- .../extension/opencode-plugin/problems.ts | 249 ++++++++++++++++++ packages/extension/test/problems.test.ts | 180 +++++++++++++ 2 files changed, 429 insertions(+) create mode 100644 packages/extension/opencode-plugin/problems.ts create mode 100644 packages/extension/test/problems.test.ts diff --git a/packages/extension/opencode-plugin/problems.ts b/packages/extension/opencode-plugin/problems.ts new file mode 100644 index 00000000..21f2c0d2 --- /dev/null +++ b/packages/extension/opencode-plugin/problems.ts @@ -0,0 +1,249 @@ +// ============================================================================ +// Problem-workspace fs operations (spec A) for the amicode_* tool pack. +// +// SIBLING-MODULE RULES (same as ./score_guard): imported by amicode_tools.ts +// inside opencode's Bun runtime via a relative `./problems` import — node: +// builtins only, named exports fine (the single-export constraint is the plugin +// entry's). Pure-ish fs logic, unit-tested from test/problems.test.ts against a +// temp $AMICODE_PROBLEMS_DIR. +// +// A Problem workspace (~/.amico/problems//) is the durable unit of identity +// that replaces the global _entities singleton: it owns entities, an append-only +// events.jsonl (the provenance spine), run REFS (runs.toml/.json — never run +// data), and the authored solve.jl (spec C). The plugin is TOML-writer-only, so +// every read/merge goes through the `.json` sidecars. +// ============================================================================ + +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { + problemToml, + problemJson, + runRefsToml, + runRefsJson, + deriveSlug, + type ProblemMeta, + type RunRef, +} from "./entities"; + +const ACTIVE_FILE = "active"; + +/** Root of all problem workspaces. $AMICODE_PROBLEMS_DIR overrides (test + the + * extension-side grant point here identically). */ +export function problemsDir(): string { + const env = process.env.AMICODE_PROBLEMS_DIR; + if (env && env.trim() !== "") return env; + return path.join(os.homedir(), ".amico", "problems"); +} + +export function problemDir(slug: string): string { + return path.join(problemsDir(), slug); +} + +function atomicWrite(file: string, content: string): void { + fs.mkdirSync(path.dirname(file), { recursive: true }); + const tmp = file + ".tmp"; + fs.writeFileSync(tmp, content, "utf8"); + fs.renameSync(tmp, file); +} + +// --- active pointer ---------------------------------------------------------- + +/** The active problem slug, or undefined when absent OR dangling (the pointer + * names a dir that no longer exists — callers auto-create in that case). */ +export function readActiveSlug(): string | undefined { + const file = path.join(problemsDir(), ACTIVE_FILE); + if (!fs.existsSync(file)) return undefined; + const slug = fs.readFileSync(file, "utf8").trim(); + if (!slug) return undefined; + if (!fs.existsSync(problemDir(slug))) return undefined; // dangling + return slug; +} + +export function setActiveSlug(slug: string): void { + atomicWrite(path.join(problemsDir(), ACTIVE_FILE), slug + "\n"); +} + +// --- problem.json (the machine-read source) ---------------------------------- + +function readProblemMeta(slug: string): ProblemMeta | undefined { + const file = path.join(problemDir(slug), "problem.json"); + if (!fs.existsSync(file)) return undefined; + try { + return JSON.parse(fs.readFileSync(file, "utf8")) as ProblemMeta; + } catch { + return undefined; + } +} + +/** Write both problem.toml and its .json sidecar, stamping `recorded` = now. */ +function writeProblemMeta(meta: ProblemMeta): void { + const stamped: ProblemMeta = { ...meta, recorded: new Date().toISOString() }; + atomicWrite(path.join(problemDir(meta.slug), "problem.toml"), problemToml(stamped)); + atomicWrite(path.join(problemDir(meta.slug), "problem.json"), problemJson(stamped)); +} + +/** First non-colliding slug: `base`, then `base-2`, `base-3`, … */ +function uniqueSlug(base: string): string { + if (!fs.existsSync(problemDir(base))) return base; + for (let i = 2; ; i++) { + const candidate = `${base}-${i}`; + if (!fs.existsSync(problemDir(candidate))) return candidate; + } +} + +// --- lifecycle --------------------------------------------------------------- + +export function listProblems(): ProblemMeta[] { + const root = problemsDir(); + if (!fs.existsSync(root)) return []; + const out: ProblemMeta[] = []; + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const meta = readProblemMeta(entry.name); + if (meta) out.push(meta); + } + return out; +} + +export function createProblem(name: string): ProblemMeta { + const slug = uniqueSlug(deriveSlug(name)); + const meta: ProblemMeta = { name, slug, created: new Date().toISOString(), status: "designing" }; + fs.mkdirSync(path.join(problemDir(slug), "entities"), { recursive: true }); + writeProblemMeta(meta); + setActiveSlug(slug); + appendEvent(slug, { entity: "problem", action: "created" }); + return meta; +} + +/** Open by exact slug (works for archived too), else fuzzy by name + * (case-insensitive substring, archived excluded). Sets active on a hit. */ +export function openProblem(query: string): ProblemMeta | undefined { + const exact = readProblemMeta(query); + if (exact) { + setActiveSlug(query); + return exact; + } + const q = query.toLowerCase().trim(); + const matches = listProblems().filter( + (m) => m.status !== "archived" && m.name.toLowerCase().includes(q), + ); + if (matches.length === 0) return undefined; + matches.sort((a, b) => (b.recorded ?? "").localeCompare(a.recorded ?? "")); + setActiveSlug(matches[0].slug); + return matches[0]; +} + +/** Rename a problem. Name always updates. The slug (= dir) changes ONLY for an + * auto-generated `untitled-*` slug (then: dir rename + active update); an + * established slug is immutable so external refs stay valid. */ +export function renameProblem(slug: string, newName: string): ProblemMeta { + const meta = readProblemMeta(slug); + if (!meta) throw new Error(`no such problem: ${slug}`); + if (slug.startsWith("untitled")) { + const wasActive = readActiveSlug() === slug; + const newSlug = uniqueSlug(deriveSlug(newName)); + fs.renameSync(problemDir(slug), problemDir(newSlug)); + const updated: ProblemMeta = { ...meta, name: newName, slug: newSlug }; + writeProblemMeta(updated); + if (wasActive) setActiveSlug(newSlug); + appendEvent(newSlug, { + entity: "problem", + action: "renamed", + diff: { name: { from: meta.name, to: newName }, slug: { from: slug, to: newSlug } }, + }); + return updated; + } + const updated: ProblemMeta = { ...meta, name: newName }; + writeProblemMeta(updated); + appendEvent(slug, { entity: "problem", action: "renamed", diff: { name: { from: meta.name, to: newName } } }); + return updated; +} + +export function archiveProblem(slug: string): ProblemMeta { + const meta = readProblemMeta(slug); + if (!meta) throw new Error(`no such problem: ${slug}`); + const updated: ProblemMeta = { ...meta, status: "archived" }; + writeProblemMeta(updated); + appendEvent(slug, { entity: "problem", action: "archived", diff: { status: { from: meta.status, to: "archived" } } }); + return updated; +} + +/** The active problem, auto-creating an `Untitled ` one when the pointer + * is absent or dangling (fast-path sessions must never stall on bookkeeping). */ +export function ensureActiveProblem(): ProblemMeta { + const active = readActiveSlug(); + if (active) { + const meta = readProblemMeta(active); + if (meta) return meta; + } + return createProblem(`Untitled ${new Date().toISOString().slice(0, 10)}`); +} + +// --- event log --------------------------------------------------------------- + +export interface EventInput { + entity: string; + action: string; + diff?: Record; + hash?: string; + source?: { tool?: string; stage?: string; session?: string }; +} + +/** Append one event to the problem's events.jsonl; returns its monotonic seq + * (= existing non-empty line count + 1). `ts` + `provenance:null` are stamped. */ +export function appendEvent(slug: string, input: EventInput): number { + const file = path.join(problemDir(slug), "events.jsonl"); + let seq = 1; + if (fs.existsSync(file)) { + seq = fs.readFileSync(file, "utf8").split("\n").filter((l) => l.trim() !== "").length + 1; + } + const record = { + seq, + ts: new Date().toISOString(), + entity: input.entity, + action: input.action, + ...(input.diff !== undefined ? { diff: input.diff } : {}), + ...(input.hash !== undefined ? { hash: input.hash } : {}), + ...(input.source !== undefined ? { source: input.source } : {}), + provenance: null, + }; + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.appendFileSync(file, JSON.stringify(record) + "\n"); + return seq; +} + +// --- run refs + entity files ------------------------------------------------- + +/** Append a run ref to BOTH runs.json (read source) and runs.toml (human). */ +export function appendRunRef(slug: string, ref: RunRef): void { + const jsonFile = path.join(problemDir(slug), "runs.json"); + let refs: RunRef[] = []; + if (fs.existsSync(jsonFile)) { + try { + const parsed = JSON.parse(fs.readFileSync(jsonFile, "utf8")) as { runs?: RunRef[] }; + if (Array.isArray(parsed.runs)) refs = parsed.runs; + } catch { + refs = []; + } + } + refs.push(ref); + atomicWrite(jsonFile, runRefsJson(refs)); + atomicWrite(path.join(problemDir(slug), "runs.toml"), runRefsToml(refs)); +} + +/** Write an entity's TOML + JSON sidecar under /entities/. */ +export function writeEntityFiles(slug: string, kind: string, toml: string, json: string): void { + const dir = path.join(problemDir(slug), "entities"); + atomicWrite(path.join(dir, `${kind}.toml`), toml); + atomicWrite(path.join(dir, `${kind}.json`), json); +} + +// --- migration (Task 6 fills this in) ---------------------------------------- + +/** One-shot legacy `_entities/` → problem-workspace migration. No-op stub for now + * so amicode_tools.ts's module-load call resolves; implemented in Task 6. */ +export function migrateLegacyEntities(_legacySrc?: string, _problemsRoot?: string): void { + // intentionally empty — see Task 6 +} diff --git a/packages/extension/test/problems.test.ts b/packages/extension/test/problems.test.ts new file mode 100644 index 00000000..43bdc217 --- /dev/null +++ b/packages/extension/test/problems.test.ts @@ -0,0 +1,180 @@ +// Tests for the Problem-workspace fs module (opencode-plugin/problems.ts). +// +// problems.ts uses node: builtins (fs/path/os) — sibling-module rules, not the +// dependency-free entities.ts. Every test points AMICODE_PROBLEMS_DIR at a fresh +// temp dir so nothing touches the real ~/.amico. Reads go through .json sidecars. +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import { parse } from 'smol-toml' +import { + problemsDir, + problemDir, + readActiveSlug, + setActiveSlug, + listProblems, + createProblem, + openProblem, + renameProblem, + archiveProblem, + ensureActiveProblem, + appendEvent, + appendRunRef, + writeEntityFiles, +} from '../opencode-plugin/problems' + +let tmp: string +let prevEnv: string | undefined + +beforeEach(() => { + prevEnv = process.env.AMICODE_PROBLEMS_DIR + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'amicode-problems-')) + process.env.AMICODE_PROBLEMS_DIR = tmp +}) + +afterEach(() => { + if (prevEnv === undefined) delete process.env.AMICODE_PROBLEMS_DIR + else process.env.AMICODE_PROBLEMS_DIR = prevEnv + fs.rmSync(tmp, { recursive: true, force: true }) +}) + +describe('problemsDir / problemDir', () => { + it('honors AMICODE_PROBLEMS_DIR', () => { + expect(problemsDir()).toBe(tmp) + expect(problemDir('x-gate')).toBe(path.join(tmp, 'x-gate')) + }) +}) + +describe('createProblem', () => { + it('writes problem.toml + .json + entities/ and sets active', () => { + const meta = createProblem('X gate on Q1') + expect(meta.slug).toBe('x-gate-on-q1') + expect(meta.status).toBe('designing') + const dir = problemDir('x-gate-on-q1') + expect(fs.existsSync(path.join(dir, 'problem.toml'))).toBe(true) + expect(fs.existsSync(path.join(dir, 'problem.json'))).toBe(true) + expect(fs.existsSync(path.join(dir, 'entities'))).toBe(true) + expect(readActiveSlug()).toBe('x-gate-on-q1') + const doc = parse(fs.readFileSync(path.join(dir, 'problem.toml'), 'utf8')) as any + expect(doc.problem.name).toBe('X gate on Q1') + }) + it('auto-suffixes a colliding slug', () => { + createProblem('X gate') + const second = createProblem('X gate') + expect(second.slug).toBe('x-gate-2') + }) + it('records a problem/created lifecycle event', () => { + const meta = createProblem('X gate') + const lines = fs.readFileSync(path.join(problemDir(meta.slug), 'events.jsonl'), 'utf8').trim().split('\n') + const evt = JSON.parse(lines[0]) + expect(evt).toMatchObject({ seq: 1, entity: 'problem', action: 'created' }) + expect(Number.isNaN(Date.parse(evt.ts))).toBe(false) + }) +}) + +describe('openProblem', () => { + it('opens by exact slug and by fuzzy name, sets active', () => { + createProblem('X gate on Q1') + createProblem('Y gate on Q2') + expect(openProblem('x-gate-on-q1')?.slug).toBe('x-gate-on-q1') + expect(readActiveSlug()).toBe('x-gate-on-q1') + expect(openProblem('gate on q2')?.slug).toBe('y-gate-on-q2') + expect(openProblem('nonexistent')).toBeUndefined() + }) + it('excludes archived from fuzzy match but still opens by exact slug', () => { + createProblem('X gate on Q1') + archiveProblem('x-gate-on-q1') + expect(openProblem('gate on q1')).toBeUndefined() + expect(openProblem('x-gate-on-q1')?.slug).toBe('x-gate-on-q1') + }) +}) + +describe('renameProblem', () => { + it('renames name only for an established (non-untitled) slug', () => { + createProblem('X gate') + const meta = renameProblem('x-gate', 'X gate on transmon Q1') + expect(meta.slug).toBe('x-gate') // slug immutable + expect(meta.name).toBe('X gate on transmon Q1') + expect(fs.existsSync(problemDir('x-gate'))).toBe(true) + }) + it('re-slugs and renames the dir for an untitled slug, updating active', () => { + const u = ensureActiveProblem() // untitled-* + expect(u.slug.startsWith('untitled')).toBe(true) + const meta = renameProblem(u.slug, 'X gate on Q1') + expect(meta.slug).toBe('x-gate-on-q1') + expect(fs.existsSync(problemDir('x-gate-on-q1'))).toBe(true) + expect(fs.existsSync(problemDir(u.slug))).toBe(false) + expect(readActiveSlug()).toBe('x-gate-on-q1') + }) +}) + +describe('ensureActiveProblem', () => { + it('auto-creates an untitled problem when no active pointer exists', () => { + expect(readActiveSlug()).toBeUndefined() + const meta = ensureActiveProblem() + expect(meta.slug.startsWith('untitled')).toBe(true) + expect(readActiveSlug()).toBe(meta.slug) + }) + it('auto-creates when the active pointer is dangling', () => { + setActiveSlug('deleted-slug') // points at a dir that never existed + const meta = ensureActiveProblem() + expect(meta.slug).not.toBe('deleted-slug') + expect(fs.existsSync(problemDir(meta.slug))).toBe(true) + }) + it('returns the existing active problem when present', () => { + const created = createProblem('X gate') + const active = ensureActiveProblem() + expect(active.slug).toBe(created.slug) + }) +}) + +describe('appendEvent', () => { + it('returns a monotonic seq and writes valid JSONL', () => { + const meta = createProblem('X gate') // seq 1 = created + const s2 = appendEvent(meta.slug, { entity: 'system', action: 'created', diff: { platform: { from: null, to: 'transmon' } }, hash: 'sha256:abc', source: { tool: 'amicode_pick_system', stage: 'platform' } }) + const s3 = appendEvent(meta.slug, { entity: 'system', action: 'updated', diff: { levels: { from: 3, to: 4 } } }) + expect(s2).toBe(2) + expect(s3).toBe(3) + const lines = fs.readFileSync(path.join(problemDir(meta.slug), 'events.jsonl'), 'utf8').trim().split('\n') + expect(lines).toHaveLength(3) + const e2 = JSON.parse(lines[1]) + expect(e2).toMatchObject({ seq: 2, entity: 'system', action: 'created', hash: 'sha256:abc', provenance: null }) + expect(e2.source.tool).toBe('amicode_pick_system') + }) +}) + +describe('appendRunRef', () => { + it('appends to both runs.toml and runs.json', () => { + const meta = createProblem('X gate') + appendRunRef(meta.slug, { run_id: '20260703-190412-abcd', lab: 'default', tier: 'vetted', recorded: 't1' }) + appendRunRef(meta.slug, { run_id: '20260703-191500-efgh', lab: 'default', tier: 'free', recorded: 't2' }) + const toml = parse(fs.readFileSync(path.join(problemDir(meta.slug), 'runs.toml'), 'utf8')) as any + expect(toml.runs).toHaveLength(2) + expect(toml.runs[1].tier).toBe('free') + const json = JSON.parse(fs.readFileSync(path.join(problemDir(meta.slug), 'runs.json'), 'utf8')) + expect(json.runs).toHaveLength(2) + expect(json.runs[0].run_id).toBe('20260703-190412-abcd') + }) +}) + +describe('writeEntityFiles', () => { + it('writes entities/.toml + .json', () => { + const meta = createProblem('X gate') + writeEntityFiles(meta.slug, 'system', '[system]\nplatform = "transmon"\n', '{"platform":"transmon"}\n') + const dir = path.join(problemDir(meta.slug), 'entities') + expect(fs.readFileSync(path.join(dir, 'system.toml'), 'utf8')).toContain('transmon') + expect(JSON.parse(fs.readFileSync(path.join(dir, 'system.json'), 'utf8')).platform).toBe('transmon') + }) +}) + +describe('listProblems', () => { + it('lists all problems with status', () => { + createProblem('X gate') + createProblem('Y gate') + archiveProblem('y-gate') + const all = listProblems() + expect(all.map((p) => p.slug).sort()).toEqual(['x-gate', 'y-gate']) + expect(all.find((p) => p.slug === 'y-gate')?.status).toBe('archived') + }) +}) From b4eff4bbdc5dc50d69b88e458f82a5e0cd5a402e Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 3 Jul 2026 19:14:02 -0400 Subject: [PATCH 035/135] feat(spec-a): score_guard two-dir split + cross-score fresh-state rule guardAndRecordStage(manifestDir, stateDir, stage): manifest from problems root, state/usage per-problem workspace; reset state when score_id/version mismatch (stderr-only log) Co-Authored-By: Claude Fable 5 --- .../extension/opencode-plugin/score_guard.ts | 40 +++++++++---- packages/extension/test/scores/guard.test.ts | 58 ++++++++++++++++++- 2 files changed, 85 insertions(+), 13 deletions(-) diff --git a/packages/extension/opencode-plugin/score_guard.ts b/packages/extension/opencode-plugin/score_guard.ts index ada29f5e..396f248b 100644 --- a/packages/extension/opencode-plugin/score_guard.ts +++ b/packages/extension/opencode-plugin/score_guard.ts @@ -7,13 +7,18 @@ // 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 +// STATE CONTRACT (spec A — TWO dirs, split from the old single entitiesDir): +// manifestDir (problems ROOT, session-scoped): +// score_manifest.json written by prepareOpencodeProject (extension side) +// stateDir (the active problem's WORKSPACE, per-problem): +// 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). +// side must not import from src/ (see amicode_tools.ts header). When the state's +// score_id/version disagree with the manifest (an old problem reopened under a +// different score), the state is reset to fresh for the manifest's score so +// completed-stage checks never cross scores. // // SEMANTICS: the guard protects ENTITY DEPENDENCIES, not conversation order. // Blockers for entering a stage = prior non-optional stages that EMIT entities @@ -129,14 +134,25 @@ export function appendUsage(dir: string, event: Record): void { /** 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); + * and on success records stage entry + usage events. No manifest → no gating. + * Reads the manifest from `manifestDir` (problems root) and state/usage from + * `stateDir` (the active problem workspace); resets state on a score mismatch. */ +export function guardAndRecordStage(manifestDir: string, stateDir: string, stageId: string): string | undefined { + const manifest = loadManifest(manifestDir); if (!manifest) return undefined; // fallback mode: pure bookkeeping, as before - let state = loadScoreState(dir); + let state = loadScoreState(stateDir); + if (state && (state.score_id !== manifest.id || state.score_version !== manifest.version)) { + // Old problem reopened under a different score — reset to fresh (stderr only: + // stdout is parsed by `opencode debug config`; see amicode_tools.ts header). + console.error( + `[amicode-tools] score changed (${state.score_id} v${state.score_version} → ` + + `${manifest.id} v${manifest.version}); resetting interview state`, + ); + state = undefined; + } 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 }); + appendUsage(stateDir, { 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) { @@ -148,10 +164,10 @@ export function guardAndRecordStage(dir: string, stageId: string): string | unde ); } if (!state.completed_stages.includes(stageId)) { - appendUsage(dir, { kind: "stage_entered", ts: new Date().toISOString(), stage: stageId }); + appendUsage(stateDir, { kind: "stage_entered", ts: new Date().toISOString(), stage: stageId }); } state.stage_cursor = stageId; - saveScoreState(dir, state); + saveScoreState(stateDir, state); return undefined; } diff --git a/packages/extension/test/scores/guard.test.ts b/packages/extension/test/scores/guard.test.ts index 091d3647..757d884c 100644 --- a/packages/extension/test/scores/guard.test.ts +++ b/packages/extension/test/scores/guard.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; @@ -8,6 +8,8 @@ import { loadScoreState, saveScoreState, freshScoreState, + guardAndRecordStage, + completeStage, type StageLite, } from "../../opencode-plugin/score_guard"; @@ -90,3 +92,57 @@ describe("manifest + state IO (entitiesDir contract)", () => { expect(loaded.completed_stages).toEqual(["platform"]); }); }); + +describe("guardAndRecordStage — two-dir (manifest vs state) split (spec A)", () => { + function dirs() { + const manifestDir = fs.mkdtempSync(path.join(os.tmpdir(), "guard-m-")); + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "guard-s-")); + fs.writeFileSync( + path.join(manifestDir, "score_manifest.json"), + JSON.stringify({ manifest: { id: "pulse-designer", version: 2, stages: STAGES } }), + ); + return { manifestDir, stateDir }; + } + + it("reads manifest from manifestDir, writes state + usage to stateDir", () => { + const { manifestDir, stateDir } = dirs(); + expect(guardAndRecordStage(manifestDir, stateDir, "platform")).toBeUndefined(); + expect(fs.existsSync(path.join(stateDir, "interview_state.json"))).toBe(true); + expect(fs.existsSync(path.join(stateDir, "usage.jsonl"))).toBe(true); + expect(fs.existsSync(path.join(manifestDir, "interview_state.json"))).toBe(false); + }); + + it("blocks via the manifest read from manifestDir", () => { + const { manifestDir, stateDir } = dirs(); + const blocked = guardAndRecordStage(manifestDir, stateDir, "formulate"); + expect(blocked).toMatch(/model/); // model (emits system) not completed + }); + + it("resets state whose score_id/version mismatches the manifest", () => { + const { manifestDir, stateDir } = dirs(); + const stale = freshScoreState("other-score", 1); + stale.completed_stages = ["platform", "model"]; + saveScoreState(stateDir, stale); + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + guardAndRecordStage(manifestDir, stateDir, "platform"); + spy.mockRestore(); + const reloaded = loadScoreState(stateDir)!; + expect(reloaded.score_id).toBe("pulse-designer"); + expect(reloaded.score_version).toBe(2); + expect(reloaded.completed_stages).not.toContain("model"); + }); + + it("completeStage records against the state dir", () => { + const { manifestDir, stateDir } = dirs(); + guardAndRecordStage(manifestDir, stateDir, "platform"); + completeStage(stateDir, "platform"); + expect(loadScoreState(stateDir)!.completed_stages).toContain("platform"); + }); + + it("no manifest → undefined (fallback), no state written", () => { + const emptyManifestDir = fs.mkdtempSync(path.join(os.tmpdir(), "guard-m-")); + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "guard-s-")); + expect(guardAndRecordStage(emptyManifestDir, stateDir, "platform")).toBeUndefined(); + expect(fs.existsSync(path.join(stateDir, "interview_state.json"))).toBe(false); + }); +}); From 9dd41a56a7e799087b6b1802669118c81dc3e234 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 3 Jul 2026 19:19:20 -0400 Subject: [PATCH 036/135] =?UTF-8?q?feat(spec-a):=20tools=20=E2=86=92=20act?= =?UTF-8?q?ive=20problem=20workspace,=20AMICODE=5FDIFF=20sentinel,=20amico?= =?UTF-8?q?de=5Fproblem,=20solve-params=20merge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - every entity tool: ensureActiveProblem → writeEntityFiles + appendEvent(hash) + AMICODE_DIFF sentinel (last line) - amicode_pick_system: platform free-form (known-platform affordances), levels default only for known platforms, notes arg - amicode_set_model: >6 levels warning (no longer an error) - amicode_formulate: preserves existing solve sub-object - amicode_solve: T/N/max_iter/integrator/tier args → merge into Formulation.solve (#64 hash input), parse lab/run_id → appendRunRef - amicode_problem (create/open/rename/archive); module-load legacy migration (env-guarded, stderr-only) - problems.ts: lastEventSeq for honest sentinel seqs - verified: bun build resolves 5 modules; real-binary opencode-config plugin-load test green; 240/240 non-slow tests pass Co-Authored-By: Claude Fable 5 --- .../opencode-plugin/amicode_tools.ts | 442 +++++++++++++----- .../extension/opencode-plugin/problems.ts | 9 + packages/extension/test/problems.test.ts | 10 + 3 files changed, 340 insertions(+), 121 deletions(-) diff --git a/packages/extension/opencode-plugin/amicode_tools.ts b/packages/extension/opencode-plugin/amicode_tools.ts index b889a1f5..bb1f2549 100644 --- a/packages/extension/opencode-plugin/amicode_tools.ts +++ b/packages/extension/opencode-plugin/amicode_tools.ts @@ -1,56 +1,32 @@ // ============================================================================ -// amicode_* tool pack v0 — an opencode PLUGIN, not extension-bundle code. +// amicode_* tool pack v1 — 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). +// relative `./entities` / `./problems` / `./hashes` / `./score_guard` sibling +// imports below resolve; nothing else does. 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 + ./problems.ts + ./hashes.ts, which ARE unit-tested. // -// 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. +// v1 (spec A — Problem workspaces): entities live under a durable, named Problem +// workspace (~/.amico/problems//), NOT the old global _entities singleton. +// Every entity write appends a structured-diff event to the workspace's +// events.jsonl AND returns an `AMICODE_DIFF {json}` sentinel as its LAST line +// (the UI parses it into a diff receipt — same idiom as the run-dir contract's +// AMICODE_ITER/AMICODE_PULSE lines). The active problem is auto-created if none +// exists, so fast-path sessions never stall on bookkeeping. // -// 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. +// ARGS-SCHEMA DECISION (unchanged): plain JSON-Schema property objects validated +// inside execute(); 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. // ============================================================================ import * as fs from "node:fs"; -import * as os from "node:os"; import * as path from "node:path"; import { systemToml, @@ -61,13 +37,32 @@ import { updateSystem, validateSystem, validateFormulation, - PLATFORMS, + entityDiff, + truncateDiffForSentinel, + KNOWN_PLATFORMS, + MAX_LEVELS, type SystemEntity, type FormulationEntity, type RunStub, type DeviceSessionStub, type CalibrationStub, } from "./entities"; +import { entityHash } from "./hashes"; +import { + ensureActiveProblem, + problemsDir, + problemDir, + writeEntityFiles, + appendEvent, + appendRunRef, + createProblem, + openProblem, + renameProblem, + archiveProblem, + listProblems, + lastEventSeq, + migrateLegacyEntities, +} from "./problems"; import { guardAndRecordStage, completeStage } from "./score_guard"; // Load line goes to STDERR, not stdout: `opencode debug config` imports plugin @@ -75,20 +70,16 @@ import { guardAndRecordStage, completeStage } from "./score_guard"; // 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"); -} +console.error("[amicode-tools] loaded — amicode_* tool pack v1 (problems → " + problemsDir() + ")"); -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; +// One-shot legacy _entities → problem-workspace migration. Skipped when either +// env override is set (test harnesses point them at temp dirs). stderr-only. +if (!process.env.AMICODE_ENTITIES_DIR && !process.env.AMICODE_PROBLEMS_DIR) { + try { + migrateLegacyEntities(); + } catch (e) { + console.error(`[amicode-tools] legacy migration skipped: ${e instanceof Error ? e.message : String(e)}`); + } } /** null/undefined → absent (the schema forces the LLM to pass every key, so @@ -97,26 +88,52 @@ 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"); +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(", "); +} + +/** Read an entity's JSON sidecar from the active problem's workspace (the plugin + * is TOML-writer-only; all reads go through .json). */ +function readEntityJson(slug: string, kind: string): T | undefined { + const file = path.join(problemDir(slug), "entities", `${kind}.json`); if (!fs.existsSync(file)) return undefined; try { - return JSON.parse(fs.readFileSync(file, "utf8")) as SystemEntity; + return JSON.parse(fs.readFileSync(file, "utf8")) as T; } 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; +/** The AMICODE_DIFF sentinel line (LAST line of a tool return) — the UI parses + * it into a diff receipt; the prose above it is for the model. */ +function sentinelLine( + problem: string, + entity: string, + action: string, + seq: number, + diff: Record, +): string { + return "AMICODE_DIFF " + JSON.stringify({ problem, entity, action, seq, diff: truncateDiffForSentinel(diff) }); } -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(", "); +/** Persist an entity to the active problem workspace: write TOML+JSON sidecar, + * append a structured-diff event (with content hash), and return the sentinel + * line. `action` is derived from whether a prior snapshot exists. */ +function recordEntity( + slug: string, + kind: string, + entity: Record, + toml: string, + source: { tool: string; stage?: string }, +): string { + const before = readEntityJson>(slug, kind); + const action: "created" | "updated" = before ? "updated" : "created"; + writeEntityFiles(slug, kind, toml, JSON.stringify(entity, null, 2) + "\n"); + const diff = entityDiff(before, entity); + const seq = appendEvent(slug, { entity: kind, action, diff, hash: entityHash(entity), source }); + return sentinelLine(slug, kind, action, seq, diff); } // LaTeX shown at the PLATFORM stage — kept verbatim in sync with AGENTS.md's @@ -173,47 +190,151 @@ export const AmicodeTools = async (_input: unknown) => ({ ); }, }, + + amicode_problem: { + description: + "Open or create the Problem workspace the design state belongs to (spec A). " + + "Call this at the start of a design session (fold the name into the first " + + "confirmation — never a separate 'workspace' question), and to rename the " + + "auto-created untitled problem once the target is known. Bookkeeping only.", + args: { + action: { + type: "string", + enum: ["open", "create", "rename", "archive"], + description: "open (by name/slug) | create | rename the active/target problem | archive.", + }, + name: { + type: "string", + description: "For create/open: the problem name (or slug) to create/find. For rename/archive: the target slug.", + }, + new_name: { + type: ["string", "null"], + description: "For rename: the new name. Null otherwise.", + }, + }, + async execute(a: { action: string; name: string; new_name?: string | null }) { + if (!a.name || a.name.trim() === "") return "Cannot: empty name."; + if (a.action === "create") { + const meta = createProblem(a.name); + return ( + `Problem created: "${meta.name}" (${meta.slug}).\n\n` + + sentinelLine(meta.slug, "problem", "created", lastEventSeq(meta.slug), { + slug: { from: null, to: meta.slug }, + name: { from: null, to: meta.name }, + }) + ); + } + if (a.action === "open") { + const meta = openProblem(a.name); + if (!meta) { + const near = listProblems() + .filter((p) => p.status !== "archived") + .map((p) => `${p.name} (${p.slug})`) + .slice(0, 8); + return near.length + ? `No problem matches "${a.name}". Open problems: ${near.join("; ")}.` + : `No problem matches "${a.name}", and none exist yet — create one first.`; + } + return `Opened problem: "${meta.name}" (${meta.slug}).`; + } + if (a.action === "rename") { + if (!given(a.new_name) || a.new_name.trim() === "") return "Cannot rename: new_name is required."; + let meta; + try { + meta = renameProblem(a.name, a.new_name); + } catch (err) { + return `Cannot rename: ${err instanceof Error ? err.message : String(err)}`; + } + return ( + `Problem renamed to "${meta.name}" (${meta.slug}).\n\n` + + sentinelLine(meta.slug, "problem", "renamed", lastEventSeq(meta.slug), { + name: { from: null, to: meta.name }, + }) + ); + } + if (a.action === "archive") { + let meta; + try { + meta = archiveProblem(a.name); + } catch (err) { + return `Cannot archive: ${err instanceof Error ? err.message : String(err)}`; + } + return ( + `Problem archived: "${meta.name}" (${meta.slug}).\n\n` + + sentinelLine(meta.slug, "problem", "archived", lastEventSeq(meta.slug), { + status: { from: null, to: "archived" }, + }) + ); + } + return `Unknown action "${a.action}".`; + }, + }, + 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.", + "Platform is free-form — known platforms (" + + KNOWN_PLATFORMS.join(", ") + + ") get built-in affordances; others are recorded honestly. Bookkeeping only.", args: { platform: { type: "string", - enum: [...PLATFORMS], - description: "Device platform the user named.", + description: `Device platform the user named (e.g. ${KNOWN_PLATFORMS.join(", ")}, or anything else).`, }, omega: { type: ["number", "null"], - description: "Transmon frequency ω in GHz; pass null if not yet known.", + description: "Transmon frequency ω in GHz; pass null if not applicable/known.", }, delta: { type: ["number", "null"], - description: "Anharmonicity δ in GHz; pass null if not yet known.", + description: "Anharmonicity δ in GHz; pass null if not applicable/known.", + }, + notes: { + type: ["string", "null"], + description: "Free-text notes for what params can't hold (e.g. topology); null for none.", }, }, - async execute(a: { platform: string; omega?: number | null; delta?: number | null }) { - const blocked = guardAndRecordStage(entitiesDir(), "platform"); + async execute(a: { platform: string; omega?: number | null; delta?: number | null; notes?: string | null }) { + const meta = ensureActiveProblem(); + const dir = problemDir(meta.slug); + const blocked = guardAndRecordStage(problemsDir(), dir, "platform"); if (blocked) return blocked; + if (!a.platform || a.platform.trim() === "") return "Cannot record system: platform must be non-empty."; 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 }; + // Known platforms default to a sensible model size; unknown ones get no + // levels default (recorded honestly — spec A). + const known = (KNOWN_PLATFORMS as readonly string[]).includes(a.platform); + const entity: SystemEntity = { platform: a.platform, params }; + if (known) entity.levels = 3; + if (given(a.notes)) entity.notes = a.notes; const problems = validateSystem(entity); if (problems.length) return `Cannot record system: ${problems.join("; ")}`; - const file = persistSystem(entity); - completeStage(entitiesDir(), "platform"); - if (entity.platform === "transmon") { + const sentinel = recordEntity(meta.slug, "system", entity as any, systemToml(entity), { + tool: "amicode_pick_system", + stage: "platform", + }); + completeStage(dir, "platform"); + const levelsDesc = entity.levels !== undefined ? `${entity.levels} levels` : "levels TBD"; + if (a.platform === "transmon") { return ( - `System recorded (transmon, ${entity.levels} levels, ${paramsSummary(params)}) → ${file}\n\n` + + `System recorded (transmon, ${levelsDesc}, ${paramsSummary(params)}) in problem "${meta.slug}".\n\n` + `Model Hamiltonian:\n${TRANSMON_LATEX}\n\n` + - `Show this to the user and confirm it matches their device.` + `Show this to the user and confirm it matches their device.\n\n${sentinel}` + ); + } + if (a.platform === "rydberg") { + return ( + `System recorded (rydberg, ${levelsDesc}, ${paramsSummary(params)}) in problem "${meta.slug}".\n\n` + + `Model: ${RYDBERG_DESC}\n\n${RYDBERG_SCOPE_NOTE}\n\n${sentinel}` ); } return ( - `System recorded (rydberg, ${entity.levels} levels, ${paramsSummary(params)}) → ${file}\n\n` + - `Model: ${RYDBERG_DESC}\n\n${RYDBERG_SCOPE_NOTE}` + `System recorded (${a.platform}, ${levelsDesc}, ${paramsSummary(params)}) in problem "${meta.slug}".\n\n` + + `This platform has no built-in template in this build — record the formulation for ` + + `follow-up; don't improvise an unvetted script.\n\n${sentinel}` ); }, }, @@ -226,7 +347,7 @@ export const AmicodeTools = async (_input: unknown) => ({ args: { levels: { type: ["integer", "null"], - description: "Number of transmon levels to model (2–6, default 3); null to leave unchanged.", + description: "Number of levels to model (>=2, default 3); null to leave unchanged.", }, drive_max: { type: ["number", "null"], @@ -239,9 +360,11 @@ export const AmicodeTools = async (_input: unknown) => ({ }, }, async execute(a: { levels?: number | null; drive_max?: number | null; params?: Record | null }) { - const blocked = guardAndRecordStage(entitiesDir(), "model"); + const meta = ensureActiveProblem(); + const dir = problemDir(meta.slug); + const blocked = guardAndRecordStage(problemsDir(), dir, "model"); if (blocked) return blocked; - const existing = readSystemState(); + const existing = readEntityJson(meta.slug, "system"); 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; @@ -250,9 +373,16 @@ export const AmicodeTools = async (_input: unknown) => ({ 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}`; + const sentinel = recordEntity(meta.slug, "system", merged as any, systemToml(merged), { + tool: "amicode_set_model", + stage: "model", + }); + completeStage(dir, "model"); + const warn = + merged.levels !== undefined && merged.levels > MAX_LEVELS + ? ` ⚠️ ${merged.levels} levels worsens conditioning/leakage and solve cost — convergence may degrade.` + : ""; + return `System updated (${merged.platform}, ${merged.levels ?? "levels TBD"}, ${paramsSummary(merged.params)}).${warn}\n\n${sentinel}`; } catch (err) { return `Cannot update model: ${err instanceof Error ? err.message : String(err)}`; } @@ -266,7 +396,7 @@ export const AmicodeTools = async (_input: unknown) => ({ args: { problem: { type: "string", - description: "Problem kind: \"gate_synthesis\" or \"state_prep\".", + description: "Problem kind, e.g. \"gate_synthesis\", \"state_prep\", \"min_time\".", }, target: { type: "string", @@ -283,63 +413,126 @@ export const AmicodeTools = async (_input: unknown) => ({ }, }, async execute(a: { problem: string; target: string; objective?: string | null; constraints?: string[] | null }) { - const blocked = guardAndRecordStage(entitiesDir(), "formulate"); + const meta = ensureActiveProblem(); + const dir = problemDir(meta.slug); + const blocked = guardAndRecordStage(problemsDir(), dir, "formulate"); if (blocked) return blocked; + // Preserve any solve sub-object already recorded (stage 6 writes it via + // amicode_solve; re-running formulate must not wipe it). + const existing = readEntityJson(meta.slug, "formulation"); 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)"], }; + if (existing?.solve) entity.solve = existing.solve; const problems = validateFormulation(entity); if (problems.length) return `Cannot record formulation: ${problems.join("; ")}`; - const file = writeEntity("formulation.toml", formulationToml(entity)); - completeStage(entitiesDir(), "formulate"); + const sentinel = recordEntity(meta.slug, "formulation", entity as any, formulationToml(entity), { + tool: "amicode_formulate", + stage: "formulate", + }); + completeStage(dir, "formulate"); return ( - `Formulation recorded → ${file}\n` + - `problem: ${entity.problem}; target: ${entity.target}; objective: ${entity.objective}; ` + - `constraints: ${entity.constraints.join(" · ")}` + `Formulation recorded in "${meta.slug}": problem: ${entity.problem}; target: ${entity.target}; ` + + `objective: ${entity.objective}; constraints: ${entity.constraints.join(" · ")}\n\n${sentinel}` ); }, }, amicode_solve: { description: - "Record the Run entity stub (interview stage 6: SOLVE PARAMS). This tool NEVER " + + "Record the Run entity stub (interview stage 6: SOLVE PARAMS), merging solve " + + "parameters (T/N/max_iter/integrator) into the Formulation. 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.", + "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.", }, + T: { type: ["number", "null"], description: "Gate time T in ns; null if not applicable." }, + N: { type: ["integer", "null"], description: "Number of timesteps N; null if not applicable." }, + max_iter: { type: ["integer", "null"], description: "Solver max iterations; null for the default." }, + integrator: { type: ["string", "null"], description: "Integrator name (e.g. \"MagnusGL4\"); null for the default." }, + tier: { + type: ["string", "null"], + description: "Authoring tier: \"vetted\" | \"composed\" | \"free\" (spec C); null if unknown.", + }, 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"); + async execute(a: { + run_dir?: string | null; + T?: number | null; + N?: number | null; + max_iter?: number | null; + integrator?: string | null; + tier?: string | null; + note?: string | null; + }) { + const meta = ensureActiveProblem(); + const dir = problemDir(meta.slug); + const blocked = guardAndRecordStage(problemsDir(), dir, "solve"); if (blocked) return blocked; + + // Merge solve params into the Formulation (they are the hash-relevant + // half of #64's formulation_hash). One event, no sentinel (the Run + // sentinel below is this call's receipt). + if (given(a.T) || given(a.N) || given(a.max_iter) || given(a.integrator)) { + const form = readEntityJson(meta.slug, "formulation"); + if (form) { + const solve = { ...(form.solve ?? {}) }; + if (given(a.T)) solve.T = a.T; + if (given(a.N)) solve.N = a.N; + if (given(a.max_iter)) solve.max_iter = a.max_iter; + if (given(a.integrator)) solve.integrator = a.integrator; + const merged: FormulationEntity = { ...form, solve }; + recordEntity(meta.slug, "formulation", merged as any, formulationToml(merged), { + tool: "amicode_solve", + stage: "solve", + }); + } + } + + // Run stub — refs point at the workspace entity files. const stub: RunStub = {}; - const sysPath = path.join(dir, "system.toml"); - const formPath = path.join(dir, "formulation.toml"); + const sysPath = path.join(dir, "entities", "system.toml"); + const formPath = path.join(dir, "entities", "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.tier)) stub.tier = a.tier as RunStub["tier"]; if (given(a.note)) stub.note = a.note; - const file = writeEntity("run.toml", runStubToml(stub)); + const sentinel = recordEntity(meta.slug, "run", stub as any, runStubToml(stub), { + tool: "amicode_solve", + stage: "solve", + }); + + // Append a run REF (lab/run_id parsed from run_dir's last two segments). + if (given(a.run_dir)) { + const parts = a.run_dir.replace(/\/+$/, "").split("/"); + const run_id = parts[parts.length - 1]; + const lab = parts[parts.length - 2] ?? "default"; + appendRunRef(meta.slug, { + run_id, + lab, + tier: given(a.tier) ? (a.tier as RunStub["tier"]) : undefined, + recorded: new Date().toISOString(), + }); + } + 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 ")}.` : ""; + const runWarn = given(a.run_dir) ? "" : " No run_dir yet — launch via the workflow's amico-run bash command."; completeStage(dir, "solve"); - return ( - `Run entity recorded → ${file} — launch via the workflow's amico-run bash command ` + - `if not already launched.${warn}` - ); + return `Run entity recorded in "${meta.slug}".${warn}${runWarn}\n\n${sentinel}`; }, }, @@ -363,15 +556,20 @@ export const AmicodeTools = async (_input: unknown) => ({ }, }, async execute(a: { pulse_ref?: string | null; run_dir?: string | null; note?: string | null }) { - const blocked = guardAndRecordStage(entitiesDir(), "hardware"); + const meta = ensureActiveProblem(); + const dir = problemDir(meta.slug); + const blocked = guardAndRecordStage(problemsDir(), dir, "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; + let sentinel: string; try { - file = writeEntity("device_session.toml", deviceSessionStubToml(stub)); + sentinel = recordEntity(meta.slug, "device_session", stub as any, deviceSessionStubToml(stub), { + tool: "amicode_to_hardware", + stage: "hardware", + }); } catch (err) { return `Cannot record device session: ${err instanceof Error ? err.message : String(err)}`; } @@ -379,12 +577,11 @@ export const AmicodeTools = async (_input: unknown) => ({ ? "" : " Note: no pulse/run referenced yet — re-record after the solve finishes."; return ( - `Device session recorded → ${file} (gate: pending-human-signoff).${warn}\n\n` + + `Device session recorded in "${meta.slug}" (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.` + `THIS BUILD PERFORMS NO DEVICE I/O — intent recorded only.\n\n${sentinel}` ); }, }, @@ -406,21 +603,24 @@ export const AmicodeTools = async (_input: unknown) => ({ }, }, async execute(a: { device_session_ref?: string | null; note?: string | null }) { - const blocked = guardAndRecordStage(entitiesDir(), "hardware"); + const meta = ensureActiveProblem(); + const dir = problemDir(meta.slug); + const blocked = guardAndRecordStage(problemsDir(), dir, "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"); + const dsPath = path.join(dir, "entities", "device_session.toml"); if (fs.existsSync(dsPath)) stub.device_session_ref = dsPath; } if (given(a.note)) stub.note = a.note; - let file: string; + let sentinel: string; try { - file = writeEntity("calibration.toml", calibrationStubToml(stub)); + sentinel = recordEntity(meta.slug, "calibration", stub as any, calibrationStubToml(stub), { + tool: "amicode_calibrate", + stage: "hardware", + }); } catch (err) { return `Cannot record calibration: ${err instanceof Error ? err.message : String(err)}`; } @@ -428,11 +628,11 @@ export const AmicodeTools = async (_input: unknown) => ({ ? "" : " 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` + + `Calibration follow-up recorded in "${meta.slug}" (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.` + `that loop is a recorded follow-up only — nothing is executed tonight.\n\n${sentinel}` ); }, }, diff --git a/packages/extension/opencode-plugin/problems.ts b/packages/extension/opencode-plugin/problems.ts index 21f2c0d2..2ee7a92c 100644 --- a/packages/extension/opencode-plugin/problems.ts +++ b/packages/extension/opencode-plugin/problems.ts @@ -191,6 +191,15 @@ export interface EventInput { source?: { tool?: string; stage?: string; session?: string }; } +/** The last (highest) event seq recorded for a problem, or 0 if none. Used by + * the tools to emit an honest `seq` in the AMICODE_DIFF sentinel for lifecycle + * events that problems.ts appends internally (create/rename/archive). */ +export function lastEventSeq(slug: string): number { + const file = path.join(problemDir(slug), "events.jsonl"); + if (!fs.existsSync(file)) return 0; + return fs.readFileSync(file, "utf8").split("\n").filter((l) => l.trim() !== "").length; +} + /** Append one event to the problem's events.jsonl; returns its monotonic seq * (= existing non-empty line count + 1). `ts` + `provenance:null` are stamped. */ export function appendEvent(slug: string, input: EventInput): number { diff --git a/packages/extension/test/problems.test.ts b/packages/extension/test/problems.test.ts index 43bdc217..1bcac85b 100644 --- a/packages/extension/test/problems.test.ts +++ b/packages/extension/test/problems.test.ts @@ -22,6 +22,7 @@ import { appendEvent, appendRunRef, writeEntityFiles, + lastEventSeq, } from '../opencode-plugin/problems' let tmp: string @@ -144,6 +145,15 @@ describe('appendEvent', () => { }) }) +describe('lastEventSeq', () => { + it('returns 0 before any event and the highest seq after', () => { + const meta = createProblem('X gate') // created event = seq 1 + expect(lastEventSeq(meta.slug)).toBe(1) + appendEvent(meta.slug, { entity: 'system', action: 'created' }) + expect(lastEventSeq(meta.slug)).toBe(2) + }) +}) + describe('appendRunRef', () => { it('appends to both runs.toml and runs.json', () => { const meta = createProblem('X gate') From 3bea35719e867dbdc2e39a028eb3f1e72ef786b4 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 3 Jul 2026 19:22:43 -0400 Subject: [PATCH 037/135] feat(spec-a): one-shot legacy _entities migration (injectable roots) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reshapes flat legacy dir → legacy-/ (entities under entities/, score-state at root, synthesized archived problem.toml/.json, active set if sole problem); no-op if legacySrc absent or problemsRoot exists; env-skip guard is at the module-load call site Co-Authored-By: Claude Fable 5 --- .../extension/opencode-plugin/problems.ts | 45 ++++++++++++++-- packages/extension/test/problems.test.ts | 51 +++++++++++++++++++ 2 files changed, 91 insertions(+), 5 deletions(-) diff --git a/packages/extension/opencode-plugin/problems.ts b/packages/extension/opencode-plugin/problems.ts index 2ee7a92c..d3026648 100644 --- a/packages/extension/opencode-plugin/problems.ts +++ b/packages/extension/opencode-plugin/problems.ts @@ -249,10 +249,45 @@ export function writeEntityFiles(slug: string, kind: string, toml: string, json: atomicWrite(path.join(dir, `${kind}.json`), json); } -// --- migration (Task 6 fills this in) ---------------------------------------- +// --- migration --------------------------------------------------------------- -/** One-shot legacy `_entities/` → problem-workspace migration. No-op stub for now - * so amicode_tools.ts's module-load call resolves; implemented in Task 6. */ -export function migrateLegacyEntities(_legacySrc?: string, _problemsRoot?: string): void { - // intentionally empty — see Task 6 +const LEGACY_ENTITY_KINDS = new Set(["system", "formulation", "run", "device_session", "calibration"]); + +/** One-shot legacy `_entities/` → problem-workspace migration. Reshapes the flat + * legacy dir into `/legacy-/`: entity files under entities/, + * score-state files (score_manifest/interview_state/usage) at the workspace root, + * a synthesized archived problem.toml/.json, and `active` set only when no other + * problem exists. Roots are injectable for testing; the env-skip guard lives at + * the module-load CALL SITE (amicode_tools.ts), NOT here. No-op when the legacy + * source is absent or the problems root already exists. */ +export function migrateLegacyEntities( + legacySrc: string = path.join(os.homedir(), ".amico", "runs", "default", "_entities"), + problemsRoot: string = problemsDir(), +): void { + if (!fs.existsSync(legacySrc)) return; // nothing to migrate + if (fs.existsSync(problemsRoot)) return; // already migrated / problems exist + const slug = `legacy-${new Date().toISOString().slice(0, 10)}`; + const ws = path.join(problemsRoot, slug); + const wsEntities = path.join(ws, "entities"); + fs.mkdirSync(wsEntities, { recursive: true }); + for (const file of fs.readdirSync(legacySrc)) { + const src = path.join(legacySrc, file); + if (!fs.statSync(src).isFile()) continue; + const m = file.match(/^(.+)\.(toml|json)$/); + const isEntity = m !== null && LEGACY_ENTITY_KINDS.has(m[1]); + fs.copyFileSync(src, path.join(isEntity ? wsEntities : ws, file)); + } + const meta: ProblemMeta = { + name: "Legacy entities", + slug, + created: new Date().toISOString(), + status: "archived", + recorded: new Date().toISOString(), + }; + fs.writeFileSync(path.join(ws, "problem.toml"), problemToml(meta)); + fs.writeFileSync(path.join(ws, "problem.json"), problemJson(meta)); + const others = fs + .readdirSync(problemsRoot, { withFileTypes: true }) + .filter((e) => e.isDirectory() && e.name !== slug); + if (others.length === 0) fs.writeFileSync(path.join(problemsRoot, "active"), slug + "\n"); } diff --git a/packages/extension/test/problems.test.ts b/packages/extension/test/problems.test.ts index 1bcac85b..326e4170 100644 --- a/packages/extension/test/problems.test.ts +++ b/packages/extension/test/problems.test.ts @@ -23,6 +23,7 @@ import { appendRunRef, writeEntityFiles, lastEventSeq, + migrateLegacyEntities, } from '../opencode-plugin/problems' let tmp: string @@ -188,3 +189,53 @@ describe('listProblems', () => { expect(all.find((p) => p.slug === 'y-gate')?.status).toBe('archived') }) }) + +describe('migrateLegacyEntities (injectable roots — env-skip lives at the call site)', () => { + function legacyFixture(): string { + const legacy = fs.mkdtempSync(path.join(os.tmpdir(), 'amicode-legacy-')) + fs.writeFileSync(path.join(legacy, 'system.toml'), '[system]\nplatform = "transmon"\n') + fs.writeFileSync(path.join(legacy, 'system.json'), '{"platform":"transmon"}') + fs.writeFileSync(path.join(legacy, 'formulation.toml'), '[formulation]\nproblem = "gate_synthesis"\n') + fs.writeFileSync(path.join(legacy, 'score_manifest.json'), '{"manifest":{}}') + fs.writeFileSync(path.join(legacy, 'interview_state.json'), '{}') + fs.writeFileSync(path.join(legacy, 'usage.jsonl'), '{}\n') + return legacy + } + + it('reshapes a flat legacy dir into an archived problem workspace + sets active', () => { + const legacy = legacyFixture() + const root = path.join(tmp, 'fresh-problems') // does not exist yet + migrateLegacyEntities(legacy, root) + const dirs = fs.readdirSync(root).filter((d) => d.startsWith('legacy-')) + expect(dirs).toHaveLength(1) + const ws = path.join(root, dirs[0]) + // entity files reshaped under entities/ + expect(fs.existsSync(path.join(ws, 'entities', 'system.toml'))).toBe(true) + expect(fs.existsSync(path.join(ws, 'entities', 'system.json'))).toBe(true) + expect(fs.existsSync(path.join(ws, 'entities', 'formulation.toml'))).toBe(true) + // score-state files at the workspace root + expect(fs.existsSync(path.join(ws, 'score_manifest.json'))).toBe(true) + expect(fs.existsSync(path.join(ws, 'interview_state.json'))).toBe(true) + expect(fs.existsSync(path.join(ws, 'usage.jsonl'))).toBe(true) + // synthesized archived meta + active set (no other problem) + const meta = JSON.parse(fs.readFileSync(path.join(ws, 'problem.json'), 'utf8')) + expect(meta.status).toBe('archived') + expect(fs.readFileSync(path.join(root, 'active'), 'utf8').trim()).toBe(dirs[0]) + fs.rmSync(legacy, { recursive: true, force: true }) + }) + + it('no-ops when problemsRoot already exists', () => { + const legacy = legacyFixture() + const root = path.join(tmp, 'existing-problems') + fs.mkdirSync(root, { recursive: true }) + migrateLegacyEntities(legacy, root) + expect(fs.readdirSync(root).filter((d) => d.startsWith('legacy-'))).toHaveLength(0) + fs.rmSync(legacy, { recursive: true, force: true }) + }) + + it('no-ops when legacySrc is absent', () => { + const root = path.join(tmp, 'root-no-legacy') + migrateLegacyEntities(path.join(tmp, 'does-not-exist'), root) + expect(fs.existsSync(root)).toBe(false) + }) +}) From 1901974ec98cb9806b70d6bdba8b36b53934b78e Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 3 Jul 2026 19:36:13 -0400 Subject: [PATCH 038/135] =?UTF-8?q?feat(spec-a):=20extension=20grant=20+?= =?UTF-8?q?=20score-manifest=20=E2=86=92=20problems=20root;=20AMICODE=5FPR?= =?UTF-8?q?OBLEMS=5FDIR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - opencode_config.ts: entitiesDir() → problemsRoot() ($AMICODE_PROBLEMS_DIR, ~/.amico/problems); grant + score_manifest.json transport point at the problems root (guard's manifestDir) - opencode_config.test + prep_integration.test: updated grant/manifest expectations - slow e2e (scores_e2e, interview_e2e): resolve per-problem state dir via the active pointer; env var renamed (hand-updated — slow-excluded, verified via tsc) - packaging.test: pin all 5 opencode-plugin files (closes pre-existing no-plugin-pin gap); verified against a fresh vsix (5 files) under AMICODE_REQUIRE_VSIX=1 Co-Authored-By: Claude Fable 5 --- packages/extension/src/opencode_config.ts | 35 +++++++++-------- .../extension/test/opencode_config.test.ts | 14 +++---- packages/extension/test/packaging.test.ts | 7 ++++ .../test/scores/prep_integration.test.ts | 20 +++++----- .../extension/test/slow/interview_e2e.test.ts | 10 +++-- .../extension/test/slow/scores_e2e.test.ts | 38 +++++++++++++------ 6 files changed, 77 insertions(+), 47 deletions(-) diff --git a/packages/extension/src/opencode_config.ts b/packages/extension/src/opencode_config.ts index e01bfee3..7ad19b3a 100644 --- a/packages/extension/src/opencode_config.ts +++ b/packages/extension/src/opencode_config.ts @@ -87,19 +87,21 @@ export function resolveJuliaProject(configValue: string): string { * - `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. */ + * - an `external_directory` grant for the Problem-workspaces root, so the + * AGENT's file tools can read back system/formulation/run/event TOML the + * plugin wrote (the plugin's own fs writes are host-process calls and need + * no grant). Must stay derivation-identical to problemsDir() in + * opencode-plugin/problems.ts. */ const SCRATCH_DIR = "/tmp/amicode-work"; // matches AGENTS.md step 2/3 -/** 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; +/** Root of the amicode_* Problem workspaces — MUST match problemsDir() in + * opencode-plugin/problems.ts ($AMICODE_PROBLEMS_DIR override included, so the + * permission grant AND the score-manifest transport follow the plugin wherever + * it is pointed). */ +function problemsRoot(): string { + const env = process.env.AMICODE_PROBLEMS_DIR; if (env && env.trim() !== "") return env; - return path.join(os.homedir(), ".amico", "runs", "default", "_entities"); + return path.join(os.homedir(), ".amico", "problems"); } /** Default location of the amicode_* opencode plugin: a sibling directory of @@ -141,7 +143,7 @@ export function buildOpencodeConfigContent( [`${SCRATCH_DIR}/**`]: "allow", // solve.jl + solve.log it writes [`/private${SCRATCH_DIR}/**`]: "allow", // macOS: /tmp → /private/tmp [`${runsRoot}/**`]: "allow", // run read-backs: FINISHED/result.toml/run.log - [`${entitiesDir()}/**`]: "allow", // amicode_* entities the agent may read back + [`${problemsRoot()}/**`]: "allow", // amicode_* problem workspaces the agent reads back [`${scoresRoot}/**`]: "allow", // score templates + memory hooks ([Why?]) the agent reads }, }, @@ -200,14 +202,15 @@ export function prepareOpencodeProject(opts: OpencodeConfigOptions): OpencodePro 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. + // reads score_manifest.json from the problems ROOT — that is the guard's + // session-scoped manifestDir (per-problem interview state lives in each + // workspace; 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); + fs.mkdirSync(problemsRoot(), { recursive: true }); + fs.writeFileSync(path.join(problemsRoot(), "score_manifest.json"), manifestJson); } } catch (e) { console.warn(`amicode: score compilation failed, using built-in interview fallback: ${e}`); diff --git a/packages/extension/test/opencode_config.test.ts b/packages/extension/test/opencode_config.test.ts index 5ae0b5e2..d89ba9dd 100644 --- a/packages/extension/test/opencode_config.test.ts +++ b/packages/extension/test/opencode_config.test.ts @@ -73,18 +73,18 @@ describe('buildOpencodeConfigContent', () => { 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') + '/**' + it('grants external_directory on the problems root (default + $AMICODE_PROBLEMS_DIR override)', () => { + const defGrant = join(homedir(), '.amico', 'problems') + '/**' const cfg = JSON.parse(buildOpencodeConfigContent('/abs/AGENTS.md', TPL, '/home/u/.amico/runs/default')) expect(cfg.permission.external_directory[defGrant]).toBe('allow') - const prev = process.env.AMICODE_ENTITIES_DIR - process.env.AMICODE_ENTITIES_DIR = '/custom/entities' + const prev = process.env.AMICODE_PROBLEMS_DIR + process.env.AMICODE_PROBLEMS_DIR = '/custom/problems' try { const cfg2 = JSON.parse(buildOpencodeConfigContent('/abs/AGENTS.md', TPL, '/home/u/.amico/runs/default')) - expect(cfg2.permission.external_directory['/custom/entities/**']).toBe('allow') // grant follows the plugin + expect(cfg2.permission.external_directory['/custom/problems/**']).toBe('allow') // grant follows the plugin } finally { - if (prev === undefined) delete process.env.AMICODE_ENTITIES_DIR - else process.env.AMICODE_ENTITIES_DIR = prev + if (prev === undefined) delete process.env.AMICODE_PROBLEMS_DIR + else process.env.AMICODE_PROBLEMS_DIR = prev } }) it('never embeds a credential in the config content (D11 no-store/no-inject regression guard)', () => { diff --git a/packages/extension/test/packaging.test.ts b/packages/extension/test/packaging.test.ts index 6b8cc103..69708dbd 100644 --- a/packages/extension/test/packaging.test.ts +++ b/packages/extension/test/packaging.test.ts @@ -20,6 +20,13 @@ const REQUIRED = [ '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 + // amicode_* plugin (Bun-transpiled .ts, loaded by absolute path) — every sibling + // is load-bearing: a dropped file silently reverts the session to vanilla opencode. + 'extension/opencode-plugin/amicode_tools.ts', + 'extension/opencode-plugin/entities.ts', + 'extension/opencode-plugin/problems.ts', + 'extension/opencode-plugin/hashes.ts', + 'extension/opencode-plugin/score_guard.ts', ] // Guards against a silently-dropped runtime asset (the β.2 .gitignore-fallback diff --git a/packages/extension/test/scores/prep_integration.test.ts b/packages/extension/test/scores/prep_integration.test.ts index b5c0b8c0..6cbc2293 100644 --- a/packages/extension/test/scores/prep_integration.test.ts +++ b/packages/extension/test/scores/prep_integration.test.ts @@ -5,16 +5,16 @@ 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; +// the problems root, which defaults into $HOME — point it at a tmp dir for the run. +const PROBLEMS_TMP = fs.mkdtempSync(path.join(os.tmpdir(), "prep-problems-")); +let prevProblemsDir: string | undefined; beforeAll(() => { - prevEntitiesDir = process.env.AMICODE_ENTITIES_DIR; - process.env.AMICODE_ENTITIES_DIR = ENTITIES_TMP; + prevProblemsDir = process.env.AMICODE_PROBLEMS_DIR; + process.env.AMICODE_PROBLEMS_DIR = PROBLEMS_TMP; }); afterAll(() => { - if (prevEntitiesDir === undefined) delete process.env.AMICODE_ENTITIES_DIR; - else process.env.AMICODE_ENTITIES_DIR = prevEntitiesDir; + if (prevProblemsDir === undefined) delete process.env.AMICODE_PROBLEMS_DIR; + else process.env.AMICODE_PROBLEMS_DIR = prevProblemsDir; }); const AGENTS_SRC = path.resolve(__dirname, "..", "..", "AGENTS.md"); @@ -44,7 +44,7 @@ describe("prepareOpencodeProject × scores (spec §6)", () => { expect(agents).not.toMatch(/\{\{[A-Z_]+\}\}/); // substitution complete, incl. compiled content }); - it("writes the score_manifest.json plugin transport (projectDir record + entitiesDir copy)", () => { + it("writes the score_manifest.json plugin transport (projectDir record + problems-root 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"); @@ -54,8 +54,8 @@ describe("prepareOpencodeProject × scores (spec §6)", () => { expect(agentsForVersion).toContain(`Compiled from score \`pulse-designer\` v${manifest.manifest.version}`); 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")); + // the copy the Bun-side guard actually reads as its manifestDir (problems root) + const guardCopy = JSON.parse(fs.readFileSync(path.join(PROBLEMS_TMP, "score_manifest.json"), "utf8")); expect(guardCopy.manifest.id).toBe("pulse-designer"); }); diff --git a/packages/extension/test/slow/interview_e2e.test.ts b/packages/extension/test/slow/interview_e2e.test.ts index b8f2b5af..62298ea0 100644 --- a/packages/extension/test/slow/interview_e2e.test.ts +++ b/packages/extension/test/slow/interview_e2e.test.ts @@ -212,9 +212,13 @@ describe.skipIf(!existsSync(OC_BIN) || !hasCreds())('live interview turns (creds 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'))) { + // a prompt-strength finding, not a chain failure). Entities live in the + // active problem workspace now (spec A), not the old global _entities dir. + const problemsRoot = join(homedir(), '.amico', 'problems') + const activeFile = join(problemsRoot, 'active') + const activeSlug = existsSync(activeFile) ? readFileSync(activeFile, 'utf8').trim() : '' + const sysToml = activeSlug ? join(problemsRoot, activeSlug, 'entities', 'system.toml') : '' + if (!sysToml || !existsSync(sysToml)) { 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 index d3390f6c..4bf4bc4a 100644 --- a/packages/extension/test/slow/scores_e2e.test.ts +++ b/packages/extension/test/slow/scores_e2e.test.ts @@ -14,14 +14,26 @@ import { readUsage, reconstructTraversal } from '../../src/scores/usage' // 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. +// transport; AMICODE_PROBLEMS_DIR is pinned to a fresh tmp dir so the Bun-side +// guard state (per-problem 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') +// Spec A: the manifest lives at the problems ROOT (the guard's manifestDir), but +// interview_state.json / usage.jsonl live in the ACTIVE problem's workspace. The +// plugin auto-creates an untitled problem on the first tool call; resolve it via +// the `active` pointer. +function activeStateDir(problemsRoot: string): string | undefined { + const activeFile = join(problemsRoot, 'active') + if (!existsSync(activeFile)) return undefined + const slug = readFileSync(activeFile, 'utf8').trim() + if (!slug) return undefined + return join(problemsRoot, slug) +} + const AUTH_JSON = join(homedir(), '.local', 'share', 'opencode', 'auth.json') function hasCreds(): boolean { if (process.env.AMICODE_E2E_LIVE === '1') return true @@ -33,23 +45,23 @@ function hasCreds(): boolean { } } -const ENTITIES = mkdtempSync(join(tmpdir(), 'scores-e2e-entities-')) +const PROBLEMS = mkdtempSync(join(tmpdir(), 'scores-e2e-problems-')) 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 + + // problems root 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 + process.env.AMICODE_PROBLEMS_DIR = PROBLEMS 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 } + const env = { ...process.env, AMICODE_PROBLEMS_DIR: PROBLEMS } env.OPENCODE_CONFIG_CONTENT = buildOpencodeConfigContent(project.agentsPath, join(EXT, 'templates', 'solve_template.jl'), join(homedir(), '.amico', 'runs', 'default')) let buf = '' const child = spawn(OC_BIN, ['serve', '--port', String(port)], { env, stdio: ['ignore', 'pipe', 'pipe'] }) @@ -114,20 +126,24 @@ describe.skipIf(!existsSync(OC_BIN) || !hasCreds())('scores runtime live e2e (cr // 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)) { + // the hard assertion (rerun-once policy covers residual sampling noise). State + // now lives in the ACTIVE problem workspace, not the problems root. + const stateDir0 = activeStateDir(PROBLEMS) + if (!stateDir0 || !loadState(stateDir0)) { 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) + const stateDir = activeStateDir(PROBLEMS) + expect(stateDir, 'active problem workspace exists').toBeDefined() + const state = loadState(stateDir!) 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)) + const traversal = reconstructTraversal(readUsage(stateDir!)) expect(traversal.score_id).toBe('pulse-designer') expect(traversal.funnel.map((f) => f.stage)).toContain('platform') }) From ad6fc4b599c554be97b5452bb3a9be455a2ef65d Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 3 Jul 2026 19:37:55 -0400 Subject: [PATCH 039/135] docs(spec-a): interview opens a Problem; solve params recorded via amicode_solve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Workflow section (survives score compilation): amicode_* tools record under a named Problem workspace via amicode_problem; auto-created otherwise - interview: Problem-workspace paragraph (open/create/rename, fold name into first confirmation, fast-path rename); stage 6 passes T/N/max_iter to amicode_solve → recorded on the Formulation Co-Authored-By: Claude Fable 5 --- packages/extension/AGENTS.md | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/extension/AGENTS.md b/packages/extension/AGENTS.md index 5dbcc3f8..9d2c63b8 100644 --- a/packages/extension/AGENTS.md +++ b/packages/extension/AGENTS.md @@ -41,8 +41,9 @@ and the Run Inspector renders the live solve. `FINISHED` + `result.toml` under `~/.amico/runs///`. 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. +`amicode_*` tools below (when present) record design state under a named **Problem +workspace** (open/create/rename it with `amicode_problem`; one is auto-created if +you don't) — they never replace the bash launch. `amico-run --help` prints usage. ## Answering "What can Amicode do?" @@ -90,6 +91,14 @@ advance. After each answer, record the stage's state: call the matching one line and continue (the tools record entities — System, Formulation, Run — they are bookkeeping, not gates). +**Problem workspace.** All design state lives in a named **Problem** (recorded by +`amicode_problem`). Open or create one at the start of a design session — fold +the name into your first confirmation (e.g. right after the platform answer), +never a separate "workspace" question. If you don't, the recording tools +auto-create an "untitled" problem; **rename it once the target is known** +(`amicode_problem` with action `rename`, e.g. to "x-gate-q1"). Fast-path asks +("X gate, 10 ns, defaults") do the same rename after launch. + **Asking choice questions:** when a stage's answer is a small option set (PLATFORM; simulate-vs-solve; gate synthesis vs state prep; which gate), ask it via the native **`question` tool** — ONE question per call; the default option @@ -129,10 +138,10 @@ Stages, in order: 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). + below); pass them to `amicode_solve` (it records them on the Formulation and + writes the Run entity), then author `solve.jl` from the vetted template + ({{TEMPLATE_PATH}}) and launch it detached per the workflow above (`amico-run` + via bash — 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 From 5c4e3e5397848e115298a592fcb93cc2bb47d1a0 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 3 Jul 2026 19:40:42 -0400 Subject: [PATCH 040/135] =?UTF-8?q?test(spec-a):=20plugin=5Fexercise=20?= =?UTF-8?q?=E2=80=94=20direct=20end-to-end=20tool=20drive=20on=20temp=20wo?= =?UTF-8?q?rkspace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit standalone bun script: create→pick_system→set_model→formulate→solve via real execute(); asserts workspace layout, 6 monotonic events (incl. solve-params Formulation merge), hashed system events, sentinel parse, runs.json ref+tier. Verification: build+typecheck clean; 325 tests (schema 34 + amico-run 47 + extension 244); opencode-config plugin-load guard green; boot smoke PASS Co-Authored-By: Claude Fable 5 --- packages/extension/scripts/plugin_exercise.ts | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 packages/extension/scripts/plugin_exercise.ts diff --git a/packages/extension/scripts/plugin_exercise.ts b/packages/extension/scripts/plugin_exercise.ts new file mode 100644 index 00000000..eaafa7b1 --- /dev/null +++ b/packages/extension/scripts/plugin_exercise.ts @@ -0,0 +1,74 @@ +// Direct end-to-end exercise of the amicode_* plugin (spec A verification). +// +// The "never import the plugin in tests" rule is a vitest idiom (the module has +// load-time side effects + a single-export constraint that vitest's graph would +// trip on). Run standalone under bun it's fine: this drives each tool's real +// execute() against a temp AMICODE_PROBLEMS_DIR and asserts the workspace, +// event log, sentinels, and run ref. Exit 0 = pass. +// +// PATH="$HOME/.bun/bin:$PATH" bun packages/extension/scripts/plugin_exercise.ts + +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "amicode-exercise-")); +process.env.AMICODE_PROBLEMS_DIR = tmp; // set BEFORE the dynamic import's module load + +function assert(cond: boolean, msg: string): void { + if (!cond) { + console.error("FAIL: " + msg); + process.exit(1); + } +} + +function lastSentinel(ret: string): any { + const lines = ret.trim().split("\n"); + const last = lines[lines.length - 1]; + assert(last.startsWith("AMICODE_DIFF "), `last line is a sentinel — got: ${last.slice(0, 80)}`); + return JSON.parse(last.slice("AMICODE_DIFF ".length)); +} + +const { AmicodeTools } = await import("../opencode-plugin/amicode_tools"); +const pack: any = await AmicodeTools({}); +const tools = pack.tool; + +// create → pick_system → set_model → formulate → solve +const s0 = lastSentinel(await tools.amicode_problem.execute({ action: "create", name: "X gate on Q1", new_name: null })); +assert(s0.entity === "problem" && s0.action === "created", "problem/created sentinel"); +const slug: string = s0.problem; + +lastSentinel(await tools.amicode_pick_system.execute({ platform: "transmon", omega: 4.8, delta: -0.2, notes: null })); +lastSentinel(await tools.amicode_set_model.execute({ levels: 4, drive_max: 0.2, params: null })); +lastSentinel(await tools.amicode_formulate.execute({ problem: "gate_synthesis", target: "X", objective: null, constraints: null })); +const s4 = lastSentinel( + await tools.amicode_solve.execute({ + run_dir: "/home/u/.amico/runs/default/20260703-190412-abcd", + T: 10, N: 50, max_iter: 60, integrator: "MagnusGL4", tier: "vetted", note: "X gate", + }), +); +assert(s4.entity === "run", "solve emits a run sentinel"); + +// Workspace layout +const ws = path.join(tmp, slug); +for (const f of ["entities/system.toml", "entities/system.json", "entities/formulation.toml", "entities/run.toml", "problem.json"]) { + assert(fs.existsSync(path.join(ws, f)), `workspace file ${f}`); +} + +// Event log: >=5 events, monotonic seq, incl. the solve-params Formulation merge +const events = fs.readFileSync(path.join(ws, "events.jsonl"), "utf8").trim().split("\n").map((l) => JSON.parse(l)); +assert(events.length >= 5, `>=5 events (got ${events.length})`); +events.forEach((e: any, i: number) => assert(e.seq === i + 1, `monotonic seq at index ${i} (got ${e.seq})`)); +const formEvents = events.filter((e: any) => e.entity === "formulation"); +assert(formEvents.length >= 2, `formulation created + solve-merge update (got ${formEvents.length})`); +const sysEvents = events.filter((e: any) => e.entity === "system"); +assert(sysEvents.some((e: any) => e.hash?.startsWith("sha256:")), "system events carry a content hash"); + +// Run ref parsed from run_dir's last two segments +const runs = JSON.parse(fs.readFileSync(path.join(ws, "runs.json"), "utf8")); +assert(runs.runs.length === 1 && runs.runs[0].run_id === "20260703-190412-abcd" && runs.runs[0].lab === "default", "runs.json ref"); +assert(runs.runs[0].tier === "vetted", "run ref carries tier"); + +console.error(`OK — ${events.length} events, ${formEvents.length} formulation events, workspace "${slug}"`); +fs.rmSync(tmp, { recursive: true, force: true }); +process.exit(0); From d2e2536a5c9ec22cb88f1e201707009f43fbd591 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 3 Jul 2026 21:16:18 -0400 Subject: [PATCH 041/135] docs(agents): announce the problem rail once when the first entity records --- packages/extension/AGENTS.md | 5 ++++- packages/extension/scores/pulse-designer/SCORE.md | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/extension/AGENTS.md b/packages/extension/AGENTS.md index 9d2c63b8..97948287 100644 --- a/packages/extension/AGENTS.md +++ b/packages/extension/AGENTS.md @@ -97,7 +97,10 @@ the name into your first confirmation (e.g. right after the platform answer), never a separate "workspace" question. If you don't, the recording tools auto-create an "untitled" problem; **rename it once the target is known** (`amicode_problem` with action `rename`, e.g. to "x-gate-q1"). Fast-path asks -("X gate, 10 ns, defaults") do the same rename after launch. +("X gate, 10 ns, defaults") do the same rename after launch. When the FIRST +entity of a session records, mention once: "I'll track our progress in the +strip up top — click any part of it to inspect." Never repeat it in the same +session. **Asking choice questions:** when a stage's answer is a small option set (PLATFORM; simulate-vs-solve; gate synthesis vs state prep; which gate), ask it diff --git a/packages/extension/scores/pulse-designer/SCORE.md b/packages/extension/scores/pulse-designer/SCORE.md index 2bd12a69..4ff37297 100644 --- a/packages/extension/scores/pulse-designer/SCORE.md +++ b/packages/extension/scores/pulse-designer/SCORE.md @@ -104,7 +104,9 @@ FIRST, then continue. Per-stage notes: 1. **platform** — on answer, show the model Hamiltonian and confirm it matches - their device. Record via `amicode_pick_system`. + their device. Record via `amicode_pick_system`. When this FIRST entity + records, mention once: "I'll track our progress in the strip up top — click + any part of it to inspect." Never repeat it in the same session. - 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, From fe7e458325041d2e8dfd565bcb50fb9aa71a2c0a Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 3 Jul 2026 22:13:36 -0400 Subject: [PATCH 042/135] =?UTF-8?q?feat(schema):=20SolveSpec=20+=20run.tom?= =?UTF-8?q?l=20v2=20=E2=80=94=20executor/tier/env/source/hashes;=20per-kin?= =?UTF-8?q?d=20supported-version=20sets=20(spec=20C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/schema/schemas/run.schema.json | 14 ++++- packages/schema/schemas/solvespec.schema.json | 35 +++++++++++-- packages/schema/src/index.ts | 18 +++++-- packages/schema/test/validate.test.ts | 51 ++++++++++++++++--- 4 files changed, 104 insertions(+), 14 deletions(-) diff --git a/packages/schema/schemas/run.schema.json b/packages/schema/schemas/run.schema.json index cb515c3d..a0cc0763 100644 --- a/packages/schema/schemas/run.schema.json +++ b/packages/schema/schemas/run.schema.json @@ -7,7 +7,19 @@ "additionalProperties": false, "required": ["schema_version", "run_id", "script_path", "lab", "lab_id", "created_at", "orchestrator_version", "julia"], "properties": { - "schema_version": { "enum": ["1"], "description": "run-dir contract version (enum = the supported-version SET; grows by one entry per bump)" }, + "schema_version": { "enum": ["1", "2"], "description": "run-dir contract version (enum = the supported-version SET; grows by one entry per bump). v2 (spec C) adds tier + [hashes] for --spec launches" }, + "tier": { "enum": ["vetted", "composed", "free"], "description": "trust tier stamped by amico-run when launched via --spec (v2)" }, + "hashes": { + "type": "object", + "additionalProperties": false, + "properties": { + "system_hash": { "type": "string" }, + "formulation_hash": { "type": "string" }, + "warm_start_hash": { "type": "string" }, + "spec_hash": { "type": "string" } + }, + "description": "amicode#64 provenance hashes; spec_hash is gate-computed over the canonical solvespec.json (v2)" + }, "run_id": { "type": "string", "minLength": 1 }, "script_path": { "type": "string", "minLength": 1 }, "lab": { "type": "string", "minLength": 1 }, diff --git a/packages/schema/schemas/solvespec.schema.json b/packages/schema/schemas/solvespec.schema.json index 7e10d41d..0f70eee5 100644 --- a/packages/schema/schemas/solvespec.schema.json +++ b/packages/schema/schemas/solvespec.schema.json @@ -1,17 +1,46 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://amico.harmoniqs.co/schema/solvespec/v1", + "$id": "https://amico.harmoniqs.co/schema/solvespec/v2", "title": "amico SolveSpec", - "description": "FORWARD-LOOKING. The resolved solve specification a future amico-run would assemble and validate before dispatch. amico-run is argv-only today and emits no SolveSpec, so this schema is authored from the PRD shape and exercised via a committed fixture + `--schema solvespec` only (no emitter to round-trip yet). Its assembler/validate() call is a later slice, NOT 0.1a.", + "description": "The resolved solve specification the agent assembles and `amico-run --spec` validates + gates before dispatch (spec C). v2 adds executor/tier/env/source/hashes; v1 specs remain valid (tier-less specs skip tier gating).", "type": "object", "additionalProperties": false, "required": ["schema_version", "script_path", "lab_id"], "properties": { - "schema_version": { "enum": ["1"] }, + "schema_version": { "enum": ["1", "2"] }, "script_path": { "type": "string", "minLength": 1, "description": "the Julia script to run" }, "lab_id": { "type": "string", "minLength": 1, "description": "lab pointer (id or path) — physics params live in the lab.toml/script, not here" }, "gate": { "type": "string", "description": "target gate label (e.g. X, H), if known at assembly" }, "params": { "type": "object", "additionalProperties": true, "description": "lenient solve-knob block (T, N, max_iter, …)" }, + "executor": { "enum": ["local"], "description": "whose machine runs it — per-solve and explicit (Δ10); only local exists today" }, + "tier": { "enum": ["vetted", "composed", "free"], "description": "trust tier of the authored script (spec C resolver)" }, + "env": { + "type": "object", + "additionalProperties": false, + "required": ["kind"], + "properties": { + "kind": { "enum": ["provisioned", "project", "sandbox"], "description": "which Julia environment (NOT which machine — that is executor)" }, + "project": { "type": "string", "description": "Julia project path for kind=project|sandbox" } + } + }, + "source": { + "type": "object", + "additionalProperties": false, + "properties": { + "template_id": { "type": "string", "description": "tier-1 registry entry id" }, + "exemplar_id": { "type": "string", "description": "tier-2 exemplars-index entry id (required by the gate when tier=composed)" } + } + }, + "hashes": { + "type": "object", + "additionalProperties": false, + "properties": { + "system_hash": { "type": "string" }, + "formulation_hash": { "type": "string" }, + "warm_start_hash": { "type": "string" } + }, + "description": "amicode#64 entity hashes from the problem workspace's events; spec_hash is computed by the gate, never supplied here" + }, "julia": { "type": "object", "additionalProperties": false, diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index 8fc5e8be..414fa44a 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -36,9 +36,17 @@ const SCHEMAS = { export type SchemaKind = keyof typeof SCHEMAS; export const SCHEMA_KINDS = Object.keys(SCHEMAS) as SchemaKind[]; -/** Versions the validators accept (Q87: tolerate known-prior within range, reject - * unknown/absent). Only v1 exists today; grows when the first bump lands. */ -export const SUPPORTED_SCHEMA_VERSIONS = ["1"] as const; +/** Versions the validators accept, PER KIND (Q87: tolerate known-prior within + * range, reject unknown/absent). Derived from the schema files' enums — the + * schemas are the single source of truth; this export just surfaces them. + * run + solvespec are at v2 (spec C: executor/tier/env/source/hashes); the + * rest remain v1 and bump independently. */ +export const SUPPORTED_VERSIONS_BY_KIND: Record, string[]> = Object.fromEntries( + (["run", "result", "lab", "solvespec", "catalog-entry"] as const).map((kind) => [ + kind, + (SCHEMAS[kind] as { properties: { schema_version: { enum: string[] } } }).properties.schema_version.enum, + ]), +) as Record, string[]>; export interface Validation { ok: boolean; errors: string[] } @@ -105,7 +113,9 @@ function formatError(e: ErrorObject): string { // #17 AC3). An ABSENT version fails as `required` on the parent (handled below); // an UNRECOGNIZED version fails `enum` here. if (e.instancePath === "/schema_version" && e.keyword === "enum") { - return `/schema_version: unrecognized version (supported: ${SUPPORTED_SCHEMA_VERSIONS.join(", ")})`; + // Per-kind version sets: the enum error's own allowedValues IS the kind's set. + const allowed = (e.params as { allowedValues?: unknown[] }).allowedValues ?? []; + return `/schema_version: unrecognized version (supported: ${allowed.join(", ")})`; } switch (e.keyword) { case "required": diff --git a/packages/schema/test/validate.test.ts b/packages/schema/test/validate.test.ts index 661787a0..b0504db0 100644 --- a/packages/schema/test/validate.test.ts +++ b/packages/schema/test/validate.test.ts @@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import { parse as parseToml } from "smol-toml"; import { - validate, validateFile, SCHEMA_KINDS, SUPPORTED_SCHEMA_VERSIONS, type SchemaKind, + validate, validateFile, SCHEMA_KINDS, SUPPORTED_VERSIONS_BY_KIND, type SchemaKind, } from "../src/index.js"; const here = dirname(fileURLToPath(import.meta.url)); @@ -31,8 +31,14 @@ describe("schema set + exports", () => { new Set(["run", "result", "lab", "solvespec", "catalog-entry", "finished"]), ); }); - it("SUPPORTED_SCHEMA_VERSIONS is the v1 instantiation of a version SET", () => { - expect([...SUPPORTED_SCHEMA_VERSIONS]).toEqual(["1"]); + it("supported versions are PER-KIND: run + solvespec bumped to v2 (spec C), the rest v1", () => { + expect(SUPPORTED_VERSIONS_BY_KIND).toEqual({ + run: ["1", "2"], + solvespec: ["1", "2"], + result: ["1"], + lab: ["1"], + "catalog-entry": ["1"], + }); }); it("an unknown kind is a clean error, not a throw", () => { const r = validate({}, "nope" as SchemaKind); @@ -59,11 +65,13 @@ describe("schema_version policy", () => { expect(hasErr(r.errors, "/schema_version: unrecognized version")).toBe(true); } }); - it("every versioned schema's enum is in sync with SUPPORTED_SCHEMA_VERSIONS (no drift seam)", () => { + it("every versioned schema's enum is in sync with its per-kind version set (no drift seam)", () => { const schemasDir = join(here, "..", "schemas"); - for (const kind of ["run", "result", "lab", "solvespec", "catalog-entry"]) { + for (const kind of ["run", "result", "lab", "solvespec", "catalog-entry"] as const) { const schema = JSON.parse(readFileSync(join(schemasDir, `${kind}.schema.json`), "utf8")); - expect(schema.properties.schema_version.enum, `${kind} enum drift`).toEqual([...SUPPORTED_SCHEMA_VERSIONS]); + expect(schema.properties.schema_version.enum, `${kind} enum drift`).toEqual( + SUPPORTED_VERSIONS_BY_KIND[kind], + ); } }); it("FINISHED is a sub-shape — it carries NO schema_version and adding one is rejected", () => { @@ -174,3 +182,34 @@ describe("bundled demo run dir conforms", () => { expect(validateFile(join(demoDir, "result.toml"), "result").errors).toEqual([]); }); }); + +// ── v2 (spec C): SolveSpec executor/tier/env/source/hashes + run.toml tier/hashes ── +describe("v2 (spec C)", () => { + const specV2 = { + schema_version: "2", script_path: "/w/solve.jl", lab_id: "default", + executor: "local", tier: "free", + env: { kind: "sandbox", project: "/w/env" }, + source: {}, hashes: { system_hash: "sha256:ab", formulation_hash: "sha256:cd" }, + }; + it("accepts a full v2 solvespec and still accepts v1", () => { + expect(validate(specV2, "solvespec").errors).toEqual([]); + expect(validate({ schema_version: "1", script_path: "/s.jl", lab_id: "default" }, "solvespec").ok).toBe(true); + }); + it("rejects bad tier / executor / env.kind field-precisely", () => { + expect(validate({ ...specV2, tier: "trusted" }, "solvespec").errors.join()).toMatch(/tier/); + expect(validate({ ...specV2, executor: "cloud" }, "solvespec").errors.join()).toMatch(/executor/); + expect(validate({ ...specV2, env: { kind: "docker" } }, "solvespec").errors.join()).toMatch(/kind/); + }); + it("run v2: tier + [hashes] (all four keys) accepted; v1 manifests still valid", () => { + const run1 = { + schema_version: "1", run_id: "r", script_path: "/s.jl", lab: "default", lab_id: "default", + created_at: "2026-07-03T00:00:00Z", orchestrator_version: "0.1.0", julia: { binary: "julia" }, + }; + expect(validate(run1, "run").ok).toBe(true); + expect(validate({ + ...run1, schema_version: "2", tier: "free", + hashes: { system_hash: "sha256:ab", formulation_hash: "sha256:cd", warm_start_hash: "sha256:ef", spec_hash: "sha256:01" }, + }, "run").errors).toEqual([]); + expect(validate({ ...run1, schema_version: "2", tier: "nope" }, "run").errors.join()).toMatch(/tier/); + }); +}); From 93ecc876c0435bcc667ca0ddb6d42c792d3c2ee7 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 3 Jul 2026 22:14:59 -0400 Subject: [PATCH 043/135] feat(amico-run): authoring config + Julia import scan (spec C gate inputs) --- packages/amico-run/src/authoring.ts | 62 +++++++++++++++++++++ packages/amico-run/src/import_scan.ts | 56 +++++++++++++++++++ packages/amico-run/test/authoring.test.ts | 61 ++++++++++++++++++++ packages/amico-run/test/import_scan.test.ts | 37 ++++++++++++ 4 files changed, 216 insertions(+) create mode 100644 packages/amico-run/src/authoring.ts create mode 100644 packages/amico-run/src/import_scan.ts create mode 100644 packages/amico-run/test/authoring.test.ts create mode 100644 packages/amico-run/test/import_scan.test.ts diff --git a/packages/amico-run/src/authoring.ts b/packages/amico-run/src/authoring.ts new file mode 100644 index 00000000..c458e60d --- /dev/null +++ b/packages/amico-run/src/authoring.ts @@ -0,0 +1,62 @@ +// Authoring config (spec C) — the extension→amico-run seam. Session prep +// writes ~/.amico/authoring/authoring.json (allowlist resolved from +// entitlements + absolute paths to the bundled registry/exemplars/harness +// assets); the gate reads it here. Absent file → conservative built-in +// defaults (public base ∪ support set) so a bare-but-spec'd dev invocation +// still gates sanely. $AMICO_AUTHORING_FILE overrides the path (tests). +import { existsSync, readFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' + +export interface AuthoringConfig { + allowlist: string[] // entitlement-resolved Harmoniqs packages + support_set: string[] // fixed support packages the run-dir contract itself needs + registry?: string // abs path to templates/registry.toml + exemplars?: string // abs path to exemplars/index.json + verify_harness?: string // abs path to julia/verify_rollout.jl + verify_tolerance: number // tier-3 re-rollout agreement (absolute) +} + +export const DEFAULT_ALLOWLIST = ['Piccolo', 'Legato', 'Intonato', 'NamedTrajectories', 'DirectTrajOpt'] +export const DEFAULT_SUPPORT = ['JLD2', 'CairoMakie', 'Makie', 'TOML', 'Printf'] +const DEFAULT_TOLERANCE = 0.01 + +function defaults(): AuthoringConfig { + return { + allowlist: [...DEFAULT_ALLOWLIST], + support_set: [...DEFAULT_SUPPORT], + verify_tolerance: DEFAULT_TOLERANCE, + } +} + +export function authoringFile(): string { + const env = process.env.AMICO_AUTHORING_FILE + if (env && env.trim() !== '') return env + return join(homedir(), '.amico', 'authoring', 'authoring.json') +} + +export function readAuthoring(): { config: AuthoringConfig; warning?: string } { + const file = authoringFile() + if (!existsSync(file)) return { config: defaults() } + let raw: unknown + try { + raw = JSON.parse(readFileSync(file, 'utf8')) + } catch { + return { config: defaults(), warning: `malformed authoring.json at ${file} — using built-in defaults` } + } + if (typeof raw !== 'object' || raw === null) + return { config: defaults(), warning: `authoring.json at ${file} is not an object — using built-in defaults` } + const data = raw as Record + const strings = (v: unknown): string[] | undefined => + Array.isArray(v) ? v.filter((s): s is string => typeof s === 'string') : undefined + return { + config: { + allowlist: strings(data.allowlist) ?? [...DEFAULT_ALLOWLIST], + support_set: strings(data.support_set) ?? [...DEFAULT_SUPPORT], + registry: typeof data.registry === 'string' ? data.registry : undefined, + exemplars: typeof data.exemplars === 'string' ? data.exemplars : undefined, + verify_harness: typeof data.verify_harness === 'string' ? data.verify_harness : undefined, + verify_tolerance: typeof data.verify_tolerance === 'number' ? data.verify_tolerance : DEFAULT_TOLERANCE, + }, + } +} diff --git a/packages/amico-run/src/import_scan.ts b/packages/amico-run/src/import_scan.ts new file mode 100644 index 00000000..a7cc5a3a --- /dev/null +++ b/packages/amico-run/src/import_scan.ts @@ -0,0 +1,56 @@ +// Julia import scan (spec C gate step 2) — extract root package names from a +// script's `using`/`import` lines and check them against the entitlement +// allowlist ∪ the fixed support set ∪ the Julia stdlibs. Conservative stance: +// anything else blocks the launch with a one-line reason. Fails CLOSED on +// multi-line continuations (`using A,\n B`) — a per-line scanner would +// silently pass the continuation, and that is the one adversarial bypass; +// the templates/skeletons all use one statement per line. + +export const JULIA_STDLIBS = new Set([ + 'LinearAlgebra', 'Random', 'Statistics', 'SparseArrays', 'Printf', 'TOML', 'Dates', + 'Test', 'Pkg', 'Serialization', 'SHA', 'Logging', 'Markdown', 'UUIDs', + 'Distributed', 'InteractiveUtils', 'Base64', 'Unicode', 'REPL', +]) + +export type ScanResult = { ok: true; roots: string[] } | { ok: false; reason: string } +export type CheckResult = { ok: true } | { ok: false; reason: string } + +const IMPORT_LINE = /^\s*(using|import)\s+(.+)$/ + +/** Strip a trailing comment (naive: templates never put `#` inside strings on import lines). */ +function stripComment(line: string): string { + const hash = line.indexOf('#') + return hash === -1 ? line : line.slice(0, hash) +} + +export function scanImports(script: string): ScanResult { + const roots: string[] = [] + for (const rawLine of script.split('\n')) { + const line = stripComment(rawLine) + const match = IMPORT_LINE.exec(line) + if (!match) continue + const payload = match[2].trim() + if (payload.endsWith(',')) + return { ok: false, reason: 'multi-line using/import not supported — one statement per line' } + for (const item of payload.split(',')) { + const trimmed = item.trim() + if (!trimmed) continue + const root = trimmed.split(/[.:\s]/, 1)[0] + if (root && !roots.includes(root)) roots.push(root) + } + } + return { ok: true, roots } +} + +export function checkImports( + roots: string[], + allow: { allowlist: string[]; support_set: string[] }, +): CheckResult { + const permitted = new Set([...allow.allowlist, ...allow.support_set, ...JULIA_STDLIBS]) + const blocked = roots.filter((root) => !permitted.has(root)) + if (blocked.length === 0) return { ok: true } + return { + ok: false, + reason: `${blocked.join(', ')}: not in the allowed package set (entitlement allowlist ∪ support set ∪ stdlibs)`, + } +} diff --git a/packages/amico-run/test/authoring.test.ts b/packages/amico-run/test/authoring.test.ts new file mode 100644 index 00000000..5fd226d7 --- /dev/null +++ b/packages/amico-run/test/authoring.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, afterEach } from "vitest" +import { mkdtempSync, writeFileSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { readAuthoring, DEFAULT_ALLOWLIST, DEFAULT_SUPPORT } from "../src/authoring.js" + +let dir: string | undefined +afterEach(() => { + delete process.env.AMICO_AUTHORING_FILE + if (dir) rmSync(dir, { recursive: true, force: true }) + dir = undefined +}) + +describe("readAuthoring", () => { + it("reads the file named by $AMICO_AUTHORING_FILE, fields round-trip", () => { + dir = mkdtempSync(join(tmpdir(), "amico-authoring-")) + const file = join(dir, "authoring.json") + writeFileSync( + file, + JSON.stringify({ + schema_version: 1, + allowlist: ["Piccolo", "Piccolissimo"], + support_set: ["JLD2"], + registry: "/abs/registry.toml", + exemplars: "/abs/index.json", + verify_harness: "/abs/verify_rollout.jl", + verify_tolerance: 0.02, + }), + ) + process.env.AMICO_AUTHORING_FILE = file + const { config, warning } = readAuthoring() + expect(warning).toBeUndefined() + expect(config.allowlist).toEqual(["Piccolo", "Piccolissimo"]) + expect(config.support_set).toEqual(["JLD2"]) + expect(config.registry).toBe("/abs/registry.toml") + expect(config.exemplars).toBe("/abs/index.json") + expect(config.verify_harness).toBe("/abs/verify_rollout.jl") + expect(config.verify_tolerance).toBe(0.02) + }) + + it("missing file → conservative built-in defaults, no warning", () => { + process.env.AMICO_AUTHORING_FILE = "/nonexistent/authoring.json" + const { config, warning } = readAuthoring() + expect(warning).toBeUndefined() + expect(config.allowlist).toEqual(DEFAULT_ALLOWLIST) + expect(config.allowlist).toEqual(["Piccolo", "Legato", "Intonato", "NamedTrajectories", "DirectTrajOpt"]) + expect(config.support_set).toEqual(DEFAULT_SUPPORT) + expect(config.support_set).toEqual(expect.arrayContaining(["JLD2", "CairoMakie", "TOML"])) + expect(config.verify_tolerance).toBe(0.01) + }) + + it("malformed JSON → defaults + a warning naming the file", () => { + dir = mkdtempSync(join(tmpdir(), "amico-authoring-")) + const file = join(dir, "authoring.json") + writeFileSync(file, "{nope") + process.env.AMICO_AUTHORING_FILE = file + const { config, warning } = readAuthoring() + expect(config.allowlist).toEqual(DEFAULT_ALLOWLIST) + expect(warning).toContain("authoring.json") + }) +}) diff --git a/packages/amico-run/test/import_scan.test.ts b/packages/amico-run/test/import_scan.test.ts new file mode 100644 index 00000000..cbd472d3 --- /dev/null +++ b/packages/amico-run/test/import_scan.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from "vitest" +import { scanImports, checkImports } from "../src/import_scan.js" + +const ALLOW = { allowlist: ["Piccolo", "Legato"], support_set: ["JLD2", "CairoMakie", "TOML", "Printf"] } + +describe("scanImports", () => { + it("extracts roots from every using/import form", () => { + expect( + scanImports( + `using Piccolo\nusing JLD2, TOML\nimport LinearAlgebra as LA\nusing Piccolo.NamedTrajectories\nusing CairoMakie: heatmap\n# using Zygote (comment)`, + ), + ).toEqual({ ok: true, roots: ["Piccolo", "JLD2", "TOML", "LinearAlgebra", "CairoMakie"] }) + }) + it("fails CLOSED on a trailing-comma continuation line (multi-line using)", () => { + const scanned = scanImports(`using Piccolo,\n Zygote\n`) + expect(scanned.ok).toBe(false) + if (!scanned.ok) expect(scanned.reason).toMatch(/one statement per line/) + }) +}) + +describe("checkImports", () => { + it("allows allowlist ∪ support ∪ stdlib", () => { + expect(checkImports(["Piccolo", "JLD2", "LinearAlgebra", "Printf"], ALLOW)).toEqual({ ok: true }) + }) + it("blocks others with a one-line reason naming every blocked package", () => { + const bad = checkImports(["Piccolo", "Zygote", "Flux"], ALLOW) + expect(bad.ok).toBe(false) + if (!bad.ok) { + expect(bad.reason).toMatch(/Zygote/) + expect(bad.reason).toMatch(/Flux/) + expect(bad.reason).toMatch(/not in the allowed package set/) + } + }) + it("issimo package blocked without entitlement", () => { + expect(checkImports(["Piccolissimo"], ALLOW).ok).toBe(false) + }) +}) From 1b224498f2a176fd1b73555b6e3db839b4bfacf0 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 3 Jul 2026 22:17:02 -0400 Subject: [PATCH 044/135] feat(amico-run): masked baseline + template/exemplar catalog + shape resolver core (smol-toml promoted to runtime dep) --- packages/amico-run/package.json | 12 +- packages/amico-run/src/baseline.ts | 37 +++++ packages/amico-run/src/catalog.ts | 169 +++++++++++++++++++++++ packages/amico-run/test/baseline.test.ts | 36 +++++ packages/amico-run/test/catalog.test.ts | 129 +++++++++++++++++ 5 files changed, 379 insertions(+), 4 deletions(-) create mode 100644 packages/amico-run/src/baseline.ts create mode 100644 packages/amico-run/src/catalog.ts create mode 100644 packages/amico-run/test/baseline.test.ts create mode 100644 packages/amico-run/test/catalog.test.ts diff --git a/packages/amico-run/package.json b/packages/amico-run/package.json index 7c269437..e2a197bc 100644 --- a/packages/amico-run/package.json +++ b/packages/amico-run/package.json @@ -5,8 +5,12 @@ "type": "module", "main": "./src/index.ts", "types": "./src/index.ts", - "bin": { "amico-run": "./launcher/amico-run" }, - "engines": { "node": ">=20" }, + "bin": { + "amico-run": "./launcher/amico-run" + }, + "engines": { + "node": ">=20" + }, "scripts": { "build": "node esbuild.config.mjs", "typecheck": "tsc --noEmit", @@ -14,12 +18,12 @@ "test:slow": "vitest run test/slow" }, "dependencies": { - "@amicode/schema": "workspace:*" + "@amicode/schema": "workspace:*", + "smol-toml": "^1.3.0" }, "devDependencies": { "@types/node": "^22.0.0", "esbuild": "^0.24.0", - "smol-toml": "^1.3.0", "typescript": "^5.6.0", "vitest": "^2.1.0" } diff --git a/packages/amico-run/src/baseline.ts b/packages/amico-run/src/baseline.ts new file mode 100644 index 00000000..2bc10f8e --- /dev/null +++ b/packages/amico-run/src/baseline.ts @@ -0,0 +1,37 @@ +// Masked baseline (spec C tier-2 demotion detection) — sha256 of a script +// with every line strictly BETWEEN the fill-point markers replaced by +// "#MASKED" (the marker lines themselves are kept). Fill-point edits are +// invariant; any edit outside them changes the hash, which the gate treats +// as "no longer the exemplar's physics". Default markers are the template +// convention's `# ── FILL IN` / `# ─────` pair; an index entry may override +// with fill_begin/fill_end regex sources. Unterminated blocks mask to EOF +// (conservative: an attacker deleting the end marker can't unmask anything). +import { createHash } from 'node:crypto' + +const DEFAULT_BEGIN = '^# ── FILL IN' +const DEFAULT_END = '^# ─────' + +export function maskFillPoints(text: string, beginSource?: string, endSource?: string): string { + const begin = new RegExp(beginSource ?? DEFAULT_BEGIN) + const end = new RegExp(endSource ?? DEFAULT_END) + const out: string[] = [] + let inside = false + for (const line of text.split('\n')) { + if (!inside && begin.test(line)) { + inside = true + out.push(line) + continue + } + if (inside && end.test(line)) { + inside = false + out.push(line) + continue + } + out.push(inside ? '#MASKED' : line) + } + return out.join('\n') +} + +export function maskedHash(text: string, beginSource?: string, endSource?: string): string { + return 'sha256:' + createHash('sha256').update(maskFillPoints(text, beginSource, endSource)).digest('hex') +} diff --git a/packages/amico-run/src/catalog.ts b/packages/amico-run/src/catalog.ts new file mode 100644 index 00000000..1c47fbeb --- /dev/null +++ b/packages/amico-run/src/catalog.ts @@ -0,0 +1,169 @@ +// Template registry + exemplars index loaders and the shape resolver core +// (spec C tiers). The registry (templates/registry.toml, an extension asset) +// carries tier-1 templates — ONLY status="vetted" entries are tier-1 +// eligible — plus the support package set and the sandbox uuid map. The +// exemplars index (exemplars/index.json, built by build_exemplars.mjs) is +// tier 2, with build-time masked baseline_hash per entry. Loaders never +// throw: a missing/corrupt catalog degrades to tier 3, not a crash. +import { existsSync, readFileSync } from 'node:fs' +import { parse as parseToml } from 'smol-toml' +import { JULIA_STDLIBS } from './import_scan.js' + +export interface TemplateEntry { + id: string + platform: string + kind: string + size: number + path: string + packages: string[] + status: string // "vetted" | "experimental" | … + entitlement?: string // required entitlement id, when gated + fill_begin?: string + fill_end?: string +} + +export interface ExemplarEntry { + id: string + platform: string + kind: string + size: number + path: string + packages: string[] + baseline_hash: string + notes?: string + fill_begin?: string + fill_end?: string +} + +export interface Registry { + templates: TemplateEntry[] + support: string[] + uuids: Record + verifyTolerance: number +} + +export interface ExemplarsIndex { + exemplars: ExemplarEntry[] +} + +export interface Shape { + platform: string + kind: string + size: number +} + +export interface ShapeMatch { + tier: 'vetted' | 'composed' | 'free' + template?: TemplateEntry + exemplar?: ExemplarEntry + blockedHigher?: { tier: 'vetted' | 'composed'; requires: string } +} + +const EMPTY_REGISTRY: Registry = { templates: [], support: [], uuids: {}, verifyTolerance: 0.01 } + +function strings(v: unknown): string[] { + return Array.isArray(v) ? v.filter((s): s is string => typeof s === 'string') : [] +} + +export function loadRegistry(file: string): Registry { + if (!existsSync(file)) return EMPTY_REGISTRY + let parsed: Record + try { + parsed = parseToml(readFileSync(file, 'utf8')) as Record + } catch { + return EMPTY_REGISTRY + } + const templates = (Array.isArray(parsed.template) ? parsed.template : []) + .filter((t): t is Record => typeof t === 'object' && t !== null) + .filter((t) => typeof t.id === 'string' && typeof t.platform === 'string' && typeof t.kind === 'string') + .map( + (t): TemplateEntry => ({ + id: t.id as string, + platform: t.platform as string, + kind: t.kind as string, + size: typeof t.size === 'number' ? t.size : 1, + path: typeof t.path === 'string' ? t.path : '', + packages: strings(t.packages), + status: typeof t.status === 'string' ? t.status : 'experimental', + entitlement: typeof t.entitlement === 'string' ? t.entitlement : undefined, + fill_begin: typeof t.fill_begin === 'string' ? t.fill_begin : undefined, + fill_end: typeof t.fill_end === 'string' ? t.fill_end : undefined, + }), + ) + const support = strings((parsed.support as Record | undefined)?.packages) + const uuids: Record = {} + if (typeof parsed.uuids === 'object' && parsed.uuids !== null) + for (const [name, uuid] of Object.entries(parsed.uuids as Record)) + if (typeof uuid === 'string') uuids[name] = uuid + return { + templates, + support, + uuids, + verifyTolerance: typeof parsed.verify_tolerance === 'number' ? parsed.verify_tolerance : 0.01, + } +} + +export function loadExemplarsIndex(file: string): ExemplarsIndex { + if (!existsSync(file)) return { exemplars: [] } + let parsed: unknown + try { + parsed = JSON.parse(readFileSync(file, 'utf8')) + } catch { + return { exemplars: [] } + } + const raw = (parsed as Record)?.exemplars + const exemplars = (Array.isArray(raw) ? raw : []) + .filter((e): e is Record => typeof e === 'object' && e !== null) + .filter((e) => typeof e.id === 'string' && typeof e.baseline_hash === 'string') + .map( + (e): ExemplarEntry => ({ + id: e.id as string, + platform: typeof e.platform === 'string' ? e.platform : '', + kind: typeof e.kind === 'string' ? e.kind : '', + size: typeof e.size === 'number' ? e.size : 1, + path: typeof e.path === 'string' ? e.path : '', + packages: strings(e.packages), + baseline_hash: e.baseline_hash as string, + notes: typeof e.notes === 'string' ? e.notes : undefined, + fill_begin: typeof e.fill_begin === 'string' ? e.fill_begin : undefined, + fill_end: typeof e.fill_end === 'string' ? e.fill_end : undefined, + }), + ) + return { exemplars } +} + +/** Tier resolution (spec C, locked decision 5): exact vetted template match → + * tier 1; else exemplar match on platform+kind (size may differ) → tier 2; + * else tier 3. Entitlement- or allowlist-blocked higher matches are excluded + * from selection but reported via blockedHigher so the agent can run the + * explicit-confirmation flow (never a silent downgrade). */ +export function matchShape( + shape: Shape, + registry: Registry, + exemplars: ExemplarsIndex, + allowlist: string[], +): ShapeMatch { + const allowed = new Set([...allowlist, ...registry.support, ...JULIA_STDLIBS]) + const packagesOk = (packages: string[]) => packages.every((p) => allowed.has(p)) + let blockedHigher: ShapeMatch['blockedHigher'] + + const templateMatches = registry.templates.filter( + (t) => t.status === 'vetted' && t.platform === shape.platform && t.kind === shape.kind && t.size === shape.size, + ) + for (const template of templateMatches) { + if (packagesOk(template.packages)) return { tier: 'vetted', template } + blockedHigher ??= { tier: 'vetted', requires: template.entitlement ?? 'unknown' } + } + + const exemplarMatches = exemplars.exemplars.filter( + (e) => e.platform === shape.platform && e.kind === shape.kind, + ) + // prefer exact-size, then any + exemplarMatches.sort((a, b) => Number(b.size === shape.size) - Number(a.size === shape.size)) + for (const exemplar of exemplarMatches) { + if (packagesOk(exemplar.packages)) return { tier: 'composed', exemplar, blockedHigher } + blockedHigher ??= { tier: 'composed', requires: 'unknown' } + } + + return { tier: 'free', blockedHigher } +} diff --git a/packages/amico-run/test/baseline.test.ts b/packages/amico-run/test/baseline.test.ts new file mode 100644 index 00000000..b49e6f8e --- /dev/null +++ b/packages/amico-run/test/baseline.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from "vitest" +import { maskFillPoints, maskedHash } from "../src/baseline.js" + +const SCRIPT = `using Piccolo\n# ── FILL IN ──────\nT = 10.0\nN = 50\n# ─────────────────\nsolve()\n` + +describe("maskedHash", () => { + it("is edit-invariant inside fill points, sensitive outside", () => { + const edited = SCRIPT.replace("T = 10.0", "T = 25.0") + expect(maskedHash(SCRIPT)).toBe(maskedHash(edited)) + const physics = SCRIPT.replace("solve()", "solve!(hacked)") + expect(maskedHash(SCRIPT)).not.toBe(maskedHash(physics)) + }) + it("custom markers override the defaults", () => { + const custom = `a\n# BEGIN-KNOBS\nx = 1\n# END-KNOBS\nb\n` + const edited = custom.replace("x = 1", "x = 999") + expect(maskedHash(custom, "^# BEGIN-KNOBS", "^# END-KNOBS")).toBe( + maskedHash(edited, "^# BEGIN-KNOBS", "^# END-KNOBS"), + ) + // default markers don't match this file → edits are visible + expect(maskedHash(custom)).not.toBe(maskedHash(edited)) + }) + it("an unterminated block masks to EOF", () => { + const open = `head\n# ── FILL IN ──\nx = 1\ny = 2\n` + const edited = open.replace("y = 2", "y = 3") + expect(maskedHash(open)).toBe(maskedHash(edited)) + // but the head is still sensitive + expect(maskedHash(open)).not.toBe(maskedHash(open.replace("head", "HEAD"))) + }) + it("the masked text keeps the marker lines and replaces interior lines", () => { + const masked = maskFillPoints(SCRIPT) + expect(masked).toContain("# ── FILL IN") + expect(masked).toContain("# ─────") + expect(masked).not.toContain("T = 10.0") + expect(masked).toContain("#MASKED") + }) +}) diff --git a/packages/amico-run/test/catalog.test.ts b/packages/amico-run/test/catalog.test.ts new file mode 100644 index 00000000..98ab8331 --- /dev/null +++ b/packages/amico-run/test/catalog.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest" +import { mkdtempSync, writeFileSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { loadRegistry, loadExemplarsIndex, matchShape } from "../src/catalog.js" + +let dir: string +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "amico-catalog-")) +}) +afterEach(() => rmSync(dir, { recursive: true, force: true })) + +const REGISTRY = ` +verify_tolerance = 0.01 + +[[template]] +id = "transmon-gate-1q" +platform = "transmon" +kind = "gate_synthesis" +size = 1 +path = "solve_template.jl" +status = "vetted" +packages = ["Piccolo", "CairoMakie", "JLD2", "TOML", "Printf"] + +[[template]] +id = "rydberg-cz-2q" +platform = "rydberg" +kind = "gate_synthesis" +size = 2 +path = "solve_rydberg_cz.jl" +status = "experimental" +packages = ["Piccolo", "CairoMakie", "JLD2", "LinearAlgebra", "TOML", "Printf"] + +[[template]] +id = "issimo-special-1q" +platform = "transmon" +kind = "state_prep" +size = 1 +path = "solve_issimo.jl" +status = "vetted" +entitlement = "issimo" +packages = ["Piccolissimo", "JLD2", "TOML"] + +[support] +packages = ["JLD2", "CairoMakie", "TOML", "Printf"] + +[uuids] +Piccolo = "c4671d76-df94-11ed-2057-43d4fd632fad" +JLD2 = "033835bb-8acc-5ee8-8aae-3f567f8a3819" +` + +const INDEX = JSON.stringify({ + schema_version: 1, + exemplars: [ + { + id: "rydberg-cz", + platform: "rydberg", + kind: "gate_synthesis", + size: 2, + path: "rydberg-cz/script.jl", + packages: ["Piccolo", "CairoMakie", "JLD2", "LinearAlgebra", "TOML", "Printf"], + baseline_hash: "sha256:deadbeef", + }, + ], +}) + +function seed() { + writeFileSync(join(dir, "registry.toml"), REGISTRY) + writeFileSync(join(dir, "index.json"), INDEX) + return { + registry: loadRegistry(join(dir, "registry.toml")), + exemplars: loadExemplarsIndex(join(dir, "index.json")), + } +} + +const PUBLIC_ALLOW = ["Piccolo", "Legato", "Intonato", "NamedTrajectories", "DirectTrajOpt"] + +describe("loaders", () => { + it("registry parses templates, support set, uuids, tolerance", () => { + const { registry } = seed() + expect(registry.templates).toHaveLength(3) + expect(registry.support).toEqual(["JLD2", "CairoMakie", "TOML", "Printf"]) + expect(registry.uuids.Piccolo).toBe("c4671d76-df94-11ed-2057-43d4fd632fad") + expect(registry.verifyTolerance).toBe(0.01) + }) + it("missing files → empty catalog, never throws", () => { + expect(loadRegistry(join(dir, "nope.toml")).templates).toEqual([]) + expect(loadExemplarsIndex(join(dir, "nope.json")).exemplars).toEqual([]) + }) +}) + +describe("matchShape", () => { + it("exact vetted template match → tier 1", () => { + const { registry, exemplars } = seed() + const match = matchShape({ platform: "transmon", kind: "gate_synthesis", size: 1 }, registry, exemplars, PUBLIC_ALLOW) + expect(match.tier).toBe("vetted") + expect(match.template?.id).toBe("transmon-gate-1q") + }) + it("experimental templates are NEVER tier 1 — falls through to the exemplar", () => { + const { registry, exemplars } = seed() + const match = matchShape({ platform: "rydberg", kind: "gate_synthesis", size: 2 }, registry, exemplars, PUBLIC_ALLOW) + expect(match.tier).toBe("composed") + expect(match.exemplar?.id).toBe("rydberg-cz") + }) + it("no template and no exemplar → tier 3 (free)", () => { + const { registry, exemplars } = seed() + expect(matchShape({ platform: "ions", kind: "gate_synthesis", size: 1 }, registry, exemplars, PUBLIC_ALLOW).tier).toBe("free") + }) + it("entitlement-blocked vetted match is excluded AND reported as blocked_higher", () => { + const { registry, exemplars } = seed() + const match = matchShape({ platform: "transmon", kind: "state_prep", size: 1 }, registry, exemplars, PUBLIC_ALLOW) + expect(match.tier).toBe("free") + expect(match.blockedHigher).toEqual({ tier: "vetted", requires: "issimo" }) + // with the issimo packages allowed, the same shape resolves tier 1 + const withIssimo = matchShape( + { platform: "transmon", kind: "state_prep", size: 1 }, + registry, + exemplars, + [...PUBLIC_ALLOW, "Piccolissimo", "Strettissimo", "Intonatissimo"], + ) + expect(withIssimo.tier).toBe("vetted") + expect(withIssimo.template?.id).toBe("issimo-special-1q") + }) + it("exemplar match on platform+kind tolerates a size mismatch (near match)", () => { + const { registry, exemplars } = seed() + const match = matchShape({ platform: "rydberg", kind: "gate_synthesis", size: 3 }, registry, exemplars, PUBLIC_ALLOW) + expect(match.tier).toBe("composed") + }) +}) From 3788a8f80b4d80ff8201ef57ac7793a05480dccf Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 3 Jul 2026 22:20:40 -0400 Subject: [PATCH 045/135] =?UTF-8?q?feat(amico-run):=20the=20--spec=20launc?= =?UTF-8?q?h=20gate=20=E2=80=94=20schema,=20import=20scan,=20tier/env=20+?= =?UTF-8?q?=20Manifest=20staleness,=20masked=20baseline,=20spec=5Fhash?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/amico-run/src/gate.ts | 112 +++++++++++++++++++ packages/amico-run/test/gate.test.ts | 140 ++++++++++++++++++++++++ packages/amico-run/test/schemas.test.ts | 6 +- 3 files changed, 256 insertions(+), 2 deletions(-) create mode 100644 packages/amico-run/src/gate.ts create mode 100644 packages/amico-run/test/gate.test.ts diff --git a/packages/amico-run/src/gate.ts b/packages/amico-run/src/gate.ts new file mode 100644 index 00000000..6e7a799d --- /dev/null +++ b/packages/amico-run/src/gate.ts @@ -0,0 +1,112 @@ +// The --spec launch gate (spec C) — the ONE enforcement point that is not an +// agent honor system, and it holds under both today's bash launch and the +// future Scheduler. Steps, in spec order: (1) schema validation; (2) import +// scan against the entitlement allowlist; (3) tier/env consistency incl. the +// per-binding Manifest staleness check (#74 extension — a project/sandbox +// env is validated against its OWN Manifest, not the extension-pinned one); +// (4) tier-2 masked-baseline check; (5) stamp assembly (canonical spec + +// gate-computed spec_hash). Any failure → no Julia process, one clear line. +import { createHash } from 'node:crypto' +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { parse as parseToml } from 'smol-toml' +import { validate } from '@amicode/schema' +import type { AuthoringConfig } from './authoring.js' +import { checkImports, scanImports } from './import_scan.js' +import { maskedHash } from './baseline.js' +import { loadExemplarsIndex } from './catalog.js' + +export interface GateStamp { + tier?: string + hashes: Record // spec hashes + gate-computed spec_hash + specCanonical: string // stable-key-order JSON, what gets persisted +} + +export type GateResult = + | { ok: true; stamp: GateStamp } + | { ok: false; reason: string; demote_to?: 'free' } + +/** Stable key order at every level so spec_hash is insensitive to author key order. */ +function canonicalize(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonicalize) + if (typeof value === 'object' && value !== null) { + const out: Record = {} + for (const key of Object.keys(value as Record).sort()) + out[key] = canonicalize((value as Record)[key]) + return out + } + return value +} + +/** Julia Manifest v2 keys deps as [[deps.]] — the parsed `deps` object's + * keys ARE the package names. Every Project [deps] name must appear. */ +function staleEnvCheck(projectDir: string): string | undefined { + const projectFile = join(projectDir, 'Project.toml') + const manifestFile = join(projectDir, 'Manifest.toml') + if (!existsSync(projectFile)) return `env has no Project.toml at ${projectDir}` + if (!existsSync(manifestFile)) + return `env at ${projectDir} has no Manifest.toml — instantiate it first (JULIA_PKG_USE_CLI_GIT=true julia --project=${projectDir} -e 'using Pkg; Pkg.instantiate()')` + try { + const project = parseToml(readFileSync(projectFile, 'utf8')) as Record + const manifest = parseToml(readFileSync(manifestFile, 'utf8')) as Record + const wanted = Object.keys((project.deps as Record) ?? {}) + const present = new Set(Object.keys((manifest.deps as Record) ?? {})) + const missing = wanted.filter((name) => !present.has(name)) + if (missing.length > 0) + return `stale env: ${missing.join(', ')} in Project.toml but not its Manifest — re-instantiate` + } catch (e) { + return `env at ${projectDir} has an unparseable Project/Manifest: ${(e as Error).message}` + } + return undefined +} + +export function runGate(specRaw: unknown, scriptText: string, authoring: AuthoringConfig): GateResult { + // ── step 1: schema ── + const validation = validate(specRaw, 'solvespec') + if (!validation.ok) return { ok: false, reason: `solvespec schema: ${validation.errors[0]}` } + const spec = specRaw as Record + const tier = typeof spec.tier === 'string' ? spec.tier : undefined + const env = (typeof spec.env === 'object' && spec.env !== null ? spec.env : undefined) as + | { kind?: string; project?: string } + | undefined + + // ── step 2: import scan ── + const scanned = scanImports(scriptText) + if (!scanned.ok) return { ok: false, reason: scanned.reason } + const checked = checkImports(scanned.roots, authoring) + if (!checked.ok) return { ok: false, reason: checked.reason } + + // ── step 3: tier/env consistency ── + if (tier === 'free' && env?.kind !== 'sandbox') + return { ok: false, reason: 'free tier requires a sandbox env (env.kind = "sandbox")' } + if ((env?.kind === 'project' || env?.kind === 'sandbox') && env.project) { + const stale = staleEnvCheck(env.project) + if (stale) return { ok: false, reason: stale } + } + + // ── step 4: composed → masked baseline vs the exemplar's build-time hash ── + if (tier === 'composed') { + const exemplarId = (spec.source as Record | undefined)?.exemplar_id + if (typeof exemplarId !== 'string') + return { ok: false, reason: 'tier "composed" requires source.exemplar_id' } + const index = loadExemplarsIndex(authoring.exemplars ?? '') + const entry = index.exemplars.find((e) => e.id === exemplarId) + if (!entry) return { ok: false, reason: `unknown exemplar_id "${exemplarId}" (index: ${authoring.exemplars ?? 'absent'})` } + if (maskedHash(scriptText, entry.fill_begin, entry.fill_end) !== entry.baseline_hash) + return { + ok: false, + reason: `script is no longer the exemplar's physics (edits outside the fill points of "${exemplarId}") — re-assemble as tier "free"`, + demote_to: 'free', + } + } + + // ── step 5: stamp — canonical spec + gate-computed spec_hash ── + const specCanonical = JSON.stringify(canonicalize(spec), null, 2) + const specHash = 'sha256:' + createHash('sha256').update(specCanonical).digest('hex') + const hashes: Record = {} + if (typeof spec.hashes === 'object' && spec.hashes !== null) + for (const [key, value] of Object.entries(spec.hashes as Record)) + if (typeof value === 'string') hashes[key] = value + hashes.spec_hash = specHash + return { ok: true, stamp: { tier, hashes, specCanonical } } +} diff --git a/packages/amico-run/test/gate.test.ts b/packages/amico-run/test/gate.test.ts new file mode 100644 index 00000000..27476e39 --- /dev/null +++ b/packages/amico-run/test/gate.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest" +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { runGate } from "../src/gate.js" +import { maskedHash } from "../src/baseline.js" +import type { AuthoringConfig } from "../src/authoring.js" + +let dir: string +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "amico-gate-")) +}) +afterEach(() => rmSync(dir, { recursive: true, force: true })) + +const EXEMPLAR_SCRIPT = `using Piccolo\nusing JLD2, TOML\n# ── FILL IN ──────\nT = 10.0\n# ─────────────────\nsolve()\n` + +function authoring(overrides?: Partial): AuthoringConfig { + // exemplars index on disk with the fixture exemplar's build-time baseline + const index = join(dir, "index.json") + writeFileSync( + index, + JSON.stringify({ + schema_version: 1, + exemplars: [ + { + id: "ex-1", + platform: "rydberg", + kind: "gate_synthesis", + size: 2, + path: "ex-1/script.jl", + packages: ["Piccolo", "JLD2", "TOML"], + baseline_hash: maskedHash(EXEMPLAR_SCRIPT), + }, + ], + }), + ) + return { + allowlist: ["Piccolo", "Legato"], + support_set: ["JLD2", "CairoMakie", "TOML", "Printf"], + exemplars: index, + verify_tolerance: 0.01, + ...overrides, + } +} + +function spec(overrides: Record = {}): Record { + return { + schema_version: "2", + script_path: join(dir, "solve.jl"), + lab_id: "default", + executor: "local", + tier: "vetted", + env: { kind: "provisioned" }, + ...overrides, + } +} + +describe("runGate", () => { + it("step 1: schema-invalid spec → one-line schema reason", () => { + const result = runGate({ nope: true }, "using Piccolo\n", authoring()) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.reason).toMatch(/schema/) + }) + it("step 2: blocked import → reason names the package", () => { + const result = runGate(spec(), "using Piccolo\nusing Zygote\n", authoring()) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.reason).toMatch(/Zygote/) + }) + it("step 3: free tier requires a sandbox env", () => { + const result = runGate(spec({ tier: "free", env: { kind: "provisioned" } }), "using Piccolo\n", authoring()) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.reason).toMatch(/free tier requires a sandbox env/) + }) + it("step 3: project env without a Manifest.toml → instantiate message", () => { + const env = join(dir, "env") + mkdirSync(env) + writeFileSync(join(env, "Project.toml"), `[deps]\nPiccolo = "c4671d76-df94-11ed-2057-43d4fd632fad"\n`) + const result = runGate(spec({ env: { kind: "project", project: env } }), "using Piccolo\n", authoring()) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.reason).toMatch(/instantiate/) + }) + it("step 3b: stale env — Project dep missing from its OWN Manifest → named + re-instantiate", () => { + const env = join(dir, "env") + mkdirSync(env) + writeFileSync( + join(env, "Project.toml"), + `[deps]\nPiccolo = "c4671d76-df94-11ed-2057-43d4fd632fad"\nJLD2 = "033835bb-8acc-5ee8-8aae-3f567f8a3819"\n`, + ) + writeFileSync(join(env, "Manifest.toml"), `julia_version = "1.11.0"\n\n[[deps.Piccolo]]\nversion = "1.19.0"\n`) + const result = runGate(spec({ env: { kind: "project", project: env } }), "using Piccolo\n", authoring()) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.reason).toMatch(/stale env.*JLD2.*re-instantiate/) + // consistent pair passes + writeFileSync( + join(env, "Manifest.toml"), + `julia_version = "1.11.0"\n\n[[deps.Piccolo]]\nversion = "1.19.0"\n\n[[deps.JLD2]]\nversion = "0.5.0"\n`, + ) + expect(runGate(spec({ env: { kind: "project", project: env } }), "using Piccolo\n", authoring()).ok).toBe(true) + }) + it("step 3: non-local executor rejected at schema level", () => { + const result = runGate(spec({ executor: "cloud" }), "using Piccolo\n", authoring()) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.reason).toMatch(/executor/) + }) + it("step 4: composed — inside-fill-point edits pass; outside edits reject with demote_to", () => { + const sandboxSpec = spec({ tier: "composed", source: { exemplar_id: "ex-1" } }) + const filled = EXEMPLAR_SCRIPT.replace("T = 10.0", "T = 25.0") + expect(runGate(sandboxSpec, filled, authoring()).ok).toBe(true) + const hacked = EXEMPLAR_SCRIPT.replace("solve()", "solve!(other_physics)") + const result = runGate(sandboxSpec, hacked, authoring()) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.reason).toMatch(/no longer the exemplar/) + expect(result.demote_to).toBe("free") + } + }) + it("step 4: composed without exemplar_id → clear reason", () => { + const result = runGate(spec({ tier: "composed" }), EXEMPLAR_SCRIPT, authoring()) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.reason).toMatch(/exemplar_id/) + }) + it("step 5: pass returns the stamp; spec_hash is gate-computed and spec-sensitive", () => { + const specA = spec({ hashes: { system_hash: "sha256:ab" } }) + const resultA = runGate(specA, "using Piccolo\n", authoring()) + expect(resultA.ok).toBe(true) + if (resultA.ok) { + expect(resultA.stamp.tier).toBe("vetted") + expect(resultA.stamp.hashes.system_hash).toBe("sha256:ab") + expect(resultA.stamp.hashes.spec_hash).toMatch(/^sha256:/) + expect(JSON.parse(resultA.stamp.specCanonical)).toMatchObject({ tier: "vetted" }) + const resultB = runGate(spec({ hashes: { system_hash: "sha256:cd" } }), "using Piccolo\n", authoring()) + if (resultB.ok) expect(resultB.stamp.hashes.spec_hash).not.toBe(resultA.stamp.hashes.spec_hash) + } + }) + it("v1 specs (no tier) pass through with import scan only", () => { + const v1 = { schema_version: "1", script_path: "/s.jl", lab_id: "default" } + expect(runGate(v1, "using Piccolo\n", authoring()).ok).toBe(true) + expect(runGate(v1, "using Zygote\n", authoring()).ok).toBe(false) + }) +}) diff --git a/packages/amico-run/test/schemas.test.ts b/packages/amico-run/test/schemas.test.ts index 04296653..879978af 100644 --- a/packages/amico-run/test/schemas.test.ts +++ b/packages/amico-run/test/schemas.test.ts @@ -20,8 +20,10 @@ describe('validateManifest', () => { expect(r.errors.join(' ')).toContain('run_id') // wrong-typed top-level field expect(r.errors.join(' ')).toContain('binary') // /julia missing required "binary" }) - it('rejects unknown schema_version', () => - expect(validateManifest({ ...goodManifest, schema_version: '2' }).ok).toBe(false)) + it('rejects unknown schema_version (v2 is now valid — spec C bump)', () => { + expect(validateManifest({ ...goodManifest, schema_version: '99' }).ok).toBe(false) + expect(validateManifest({ ...goodManifest, schema_version: '2' }).ok).toBe(true) + }) }) describe('validateFinished', () => { From b79c5119abf3032b1bc6a91583e2c3d0fe506977 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 3 Jul 2026 22:39:40 -0400 Subject: [PATCH 046/135] =?UTF-8?q?feat(amico-run):=20--spec=20gated=20lau?= =?UTF-8?q?nch=20=E2=80=94=20solvespec.json=20persisted,=20run.toml=20v2?= =?UTF-8?q?=20tier+hashes;=20S31=20SolveSpec=20ban=20lifted=20(amico-run?= =?UTF-8?q?=20IS=20the=20gate=20now)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/amico-run/src/cli.ts | 34 +++++++++++++-- packages/amico-run/src/local_executor.ts | 7 +++- packages/amico-run/src/run_dir.ts | 10 ++++- packages/amico-run/src/types.ts | 8 ++++ packages/amico-run/test/cli.test.ts | 53 +++++++++++++++++++++++- packages/amico-run/test/run_dir.test.ts | 24 +++++++++++ packages/amico-run/test/s31.test.ts | 8 +++- 7 files changed, 135 insertions(+), 9 deletions(-) diff --git a/packages/amico-run/src/cli.ts b/packages/amico-run/src/cli.ts index ccbe1f64..7c0a64fc 100644 --- a/packages/amico-run/src/cli.ts +++ b/packages/amico-run/src/cli.ts @@ -1,15 +1,20 @@ -import { existsSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' import { LocalExecutor } from './local_executor.js' import { ConfigError, type Finished, type SubmitOpts } from './types.js' +import { readAuthoring } from './authoring.js' +import { runGate } from './gate.js' const USAGE = `usage: amico-run [--executor local] [--lab ] - [--runs-root ] [--julia ] [--project ] [--sysimage ]` + [--runs-root ] [--julia ] [--project ] [--sysimage ] + [--spec ] (spec C: validate + gate before launch)` export async function main(argv: string[]): Promise { let script: string | undefined let executor = 'local' + let specPath: string | undefined const opts: SubmitOpts = { julia: {} } + let projectExplicit = false for (let i = 0; i < argv.length; i++) { const a = argv[i] @@ -25,8 +30,9 @@ export async function main(argv: string[]): Promise { case '--lab': opts.lab = next(); break case '--runs-root': opts.runsRoot = next(); break case '--julia': opts.julia!.julia = next(); break - case '--project': opts.julia!.project = next(); break + case '--project': opts.julia!.project = next(); projectExplicit = true; break case '--sysimage': opts.julia!.sysimage = next(); break + case '--spec': specPath = next(); break default: if (a.startsWith('-')) { console.error(`amico-run: unknown flag ${a}\n${USAGE}`); return 64 } if (script) { console.error(`amico-run: multiple scripts given`); return 64 } @@ -40,6 +46,28 @@ export async function main(argv: string[]): Promise { if (!script) { console.error(`amico-run: no script given\n${USAGE}`); return 64 } if (executor !== 'local') { console.error(`amico-run: only --executor local is supported in β`); return 64 } + // ── spec C: the launch gate. Failures leave NO run dir and exit 64. ── + if (specPath) { + let specRaw: unknown + try { specRaw = JSON.parse(readFileSync(specPath, 'utf8')) } + catch (e) { console.error(`amico-run: cannot read --spec ${specPath}: ${(e as Error).message}`); return 64 } + let scriptText: string + try { scriptText = readFileSync(script, 'utf8') } + catch (e) { console.error(`amico-run: cannot read script ${script}: ${(e as Error).message}`); return 64 } + const { config: authoring, warning } = readAuthoring() + if (warning) console.error(`amico-run: ${warning}`) + const gate = runGate(specRaw, scriptText, authoring) + if (!gate.ok) { console.error(`amico-run: gate: ${gate.reason}`); return 64 } + // env resolution: spec env.project feeds --project unless the flag was explicit + const env = (specRaw as { env?: { kind?: string; project?: string } }).env + if (env?.project && (env.kind === 'project' || env.kind === 'sandbox')) { + if (projectExplicit && opts.julia!.project !== env.project) + console.error(`amico-run: --project ${opts.julia!.project} overrides the spec's env.project ${env.project}`) + else opts.julia!.project = env.project + } + opts.spec = { canonical: gate.stamp.specCanonical, tier: gate.stamp.tier, hashes: gate.stamp.hashes } + } + // NOTE: `--sysimage ` is honored (passed through to the Julia process and // recorded in the manifest) but amicode does NOT build one — the local // PackageCompiler build (~25-50 min, CairoMakie-dominated) wasn't worth it. The diff --git a/packages/amico-run/src/local_executor.ts b/packages/amico-run/src/local_executor.ts index 0ccba0fd..da7f84d4 100644 --- a/packages/amico-run/src/local_executor.ts +++ b/packages/amico-run/src/local_executor.ts @@ -6,7 +6,7 @@ import * as readline from 'node:readline' import { EventQueue } from './event_queue.js' import { classifyLine } from './telemetry.js' import { - appendIndex, defaultRunsRoot, deriveLabId, generateRunId, + appendIndex, atomicWriteFile, defaultRunsRoot, deriveLabId, generateRunId, updateLatest, writeFinished, writeManifest, } from './run_dir.js' import { @@ -52,11 +52,14 @@ export class LocalExecutor implements Executor { mkdirSync(runDir) const createdAt = new Date().toISOString() writeManifest(runDir, { - schema_version: '1', run_id: runId, script_path: script, + // spec C: --spec launches stamp tier + hashes and bump to v2; bare runs stay v1 + schema_version: opts.spec ? '2' : '1', run_id: runId, script_path: script, lab, lab_id: labId, created_at: createdAt, orchestrator_version: ORCHESTRATOR_VERSION, julia: { binary: juliaBin, project: opts.julia?.project, sysimage: opts.julia?.sysimage }, + tier: opts.spec?.tier, hashes: opts.spec?.hashes, }) + if (opts.spec) atomicWriteFile(runDir, 'solvespec.json', opts.spec.canonical + '\n') appendIndex(runsRoot, runId, createdAt, script) updateLatest(runsRoot, runId) diff --git a/packages/amico-run/src/run_dir.ts b/packages/amico-run/src/run_dir.ts index e7899629..e799e25b 100644 --- a/packages/amico-run/src/run_dir.ts +++ b/packages/amico-run/src/run_dir.ts @@ -42,7 +42,7 @@ export function atomicWriteFile(dir: string, name: string, content: string): voi const ts = (s: string) => JSON.stringify(s) // JSON escaping is valid TOML basic-string export interface Manifest { - schema_version: '1' + schema_version: '1' | '2' run_id: string script_path: string lab: string @@ -50,11 +50,16 @@ export interface Manifest { created_at: string orchestrator_version: string julia: { binary: string; project?: string; sysimage?: string } + // v2 (spec C, --spec launches only) — bare runs stay byte-identical v1 + tier?: string + hashes?: Record } export function writeManifest(runDir: string, m: Manifest): void { + const hashEntries = Object.entries(m.hashes ?? {}) const lines = [ `schema_version = ${ts(m.schema_version)}`, + ...(m.tier ? [`tier = ${ts(m.tier)}`] : []), `run_id = ${ts(m.run_id)}`, `script_path = ${ts(m.script_path)}`, `lab = ${ts(m.lab)}`, @@ -66,6 +71,9 @@ export function writeManifest(runDir: string, m: Manifest): void { `binary = ${ts(m.julia.binary)}`, ...(m.julia.project ? [`project = ${ts(m.julia.project)}`] : []), ...(m.julia.sysimage ? [`sysimage = ${ts(m.julia.sysimage)}`] : []), + ...(hashEntries.length > 0 + ? ['', '[hashes]', ...hashEntries.map(([key, value]) => `${key} = ${ts(value)}`)] + : []), ] atomicWriteFile(runDir, 'run.toml', lines.join('\n') + '\n') } diff --git a/packages/amico-run/src/types.ts b/packages/amico-run/src/types.ts index 069d7561..963e8a83 100644 --- a/packages/amico-run/src/types.ts +++ b/packages/amico-run/src/types.ts @@ -11,6 +11,14 @@ export interface SubmitOpts { runsRoot?: string // default: ~/.amico/runs// julia?: JuliaOpts graceMs?: number // abort SIGTERM→SIGKILL grace; default 5000. Test knob, NOT exposed in the CLI. + spec?: SpecStamp // spec C: gate-passed SolveSpec → solvespec.json persisted + run.toml v2 stamped +} + +/** What a gate-passed --spec launch carries into the run dir (spec C). */ +export interface SpecStamp { + canonical: string // stable-key-order solvespec.json body + tier?: string + hashes?: Record // incl. gate-computed spec_hash } export type RunEvent = diff --git a/packages/amico-run/test/cli.test.ts b/packages/amico-run/test/cli.test.ts index 892cc966..849fa5cb 100644 --- a/packages/amico-run/test/cli.test.ts +++ b/packages/amico-run/test/cli.test.ts @@ -1,7 +1,8 @@ import { describe, it, expect, beforeAll } from 'vitest' import { execFileSync, execFile } from 'node:child_process' +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' -import { tmpRoot, fakeJulia } from './helpers.js' +import { tmpRoot, fakeJulia, readToml } from './helpers.js' const BUNDLE = join(__dirname, '..', 'dist', 'amico-run.js') beforeAll(() => { @@ -52,6 +53,56 @@ describe('amico-run CLI', () => { const r = run([fakeJulia(root, 's.jl', ''), '--executor', 'remote']) expect(r.code).toBe(64) }) + it('--spec: gate failure → 64, one-line stderr reason, NO run dir (spec C)', () => { + const root = tmpRoot() + const script = fakeJulia(root, 's.jl', '') + writeFileSync(join(root, 'bad.json'), JSON.stringify({ nope: true })) + const r = run([script, '--runs-root', join(root, 'runs'), '--spec', join(root, 'bad.json'), + '--julia', fakeJulia(root, 'j', '')]) + expect(r.code).toBe(64) + expect(r.stderr).toMatch(/solvespec schema/) + expect(existsSync(join(root, 'runs'))).toBe(false) + }) + it('--spec pass: solvespec.json persisted canonical + run.toml v2 stamped (spec C)', () => { + const root = tmpRoot() + const script = fakeJulia(root, 's.jl', '') + const spec = { + schema_version: '2', script_path: script, lab_id: 'default', + executor: 'local', tier: 'vetted', + hashes: { system_hash: 'sha256:ab' }, + } + writeFileSync(join(root, 'spec.json'), JSON.stringify(spec)) + const r = run([script, '--runs-root', join(root, 'runs'), '--spec', join(root, 'spec.json'), + '--julia', fakeJulia(root, 'j', `console.log('DONE f=0.99')`)]) + expect(r.code).toBe(0) + const match = /runDir=(\S+)/.exec(r.stdout) + expect(match).toBeTruthy() + const runDir = match![1] + const persisted = JSON.parse(readFileSync(join(runDir, 'solvespec.json'), 'utf8')) + expect(persisted).toMatchObject({ tier: 'vetted', lab_id: 'default' }) + const manifest = readToml(join(runDir, 'run.toml')) + expect(manifest.schema_version).toBe('2') + expect(manifest.tier).toBe('vetted') + expect((manifest.hashes as Record).system_hash).toBe('sha256:ab') + expect((manifest.hashes as Record).spec_hash).toMatch(/^sha256:/) + }) + it('--spec env.kind=project sets the julia --project arg from env.project (spec C)', () => { + const root = tmpRoot() + const script = fakeJulia(root, 's.jl', '') + const env = join(root, 'env') + mkdirSync(env, { recursive: true }) + writeFileSync(join(env, 'Project.toml'), `[deps]\n`) + writeFileSync(join(env, 'Manifest.toml'), `julia_version = "1.11.0"\n`) + const spec = { + schema_version: '2', script_path: script, lab_id: 'default', + tier: 'vetted', env: { kind: 'project', project: env }, + } + writeFileSync(join(root, 'spec.json'), JSON.stringify(spec)) + const julia = fakeJulia(root, 'j', `console.log('ARGS ' + process.argv.slice(2).join(' '))`) + const r = run([script, '--runs-root', join(root, 'runs'), '--spec', join(root, 'spec.json'), '--julia', julia]) + expect(r.code).toBe(0) + expect(r.stdout).toContain(`--project=${env}`) + }) it('SIGTERM to the CLI → abort lane, exit 130', async () => { const root = tmpRoot() const julia = fakeJulia(root, 'j', `console.log('READY'); setInterval(() => {}, 1000)`) diff --git a/packages/amico-run/test/run_dir.test.ts b/packages/amico-run/test/run_dir.test.ts index adf7aba1..d05ba01c 100644 --- a/packages/amico-run/test/run_dir.test.ts +++ b/packages/amico-run/test/run_dir.test.ts @@ -7,6 +7,7 @@ import { writeManifest, writeFinished, appendIndex, updateLatest, } from '../src/run_dir.js' import { ConfigError } from '../src/types.js' +import { validate } from '@amicode/schema' describe('deriveLabId', () => { it('uses id pointers verbatim', () => expect(deriveLabId('schuster')).toBe('schuster')) @@ -42,6 +43,29 @@ describe('writers', () => { expect((m.julia as Record).project).toBe('/proj') expect(m).not.toHaveProperty('sizeClass') // spec §5: intentionally absent }) + it('manifest v2: tier + [hashes] emitted only when present; validates as "run" v2 (spec C)', () => { + const root = tmpRoot() + const base = { + run_id: 'r1', script_path: '/s.jl', lab: 'default', lab_id: 'default', + created_at: '2026-07-03T00:00:00Z', orchestrator_version: '0.1.0', + julia: { binary: 'julia' }, + } + // bare (v1) output is byte-stable: no tier/hashes lines at all + writeManifest(root, { schema_version: '1', ...base }) + const v1text = readFileSync(join(root, 'run.toml'), 'utf8') + expect(v1text).not.toContain('tier') + expect(v1text).not.toContain('[hashes]') + // spec-driven (v2) + writeManifest(root, { + schema_version: '2', ...base, tier: 'free', + hashes: { system_hash: 'sha256:ab', spec_hash: 'sha256:cd' }, + }) + const m = readToml(join(root, 'run.toml')) + expect(m.schema_version).toBe('2') + expect(m.tier).toBe('free') + expect((m.hashes as Record).spec_hash).toBe('sha256:cd') + expect(validate(m, 'run').errors).toEqual([]) + }) it('FINISHED carries status + exit_code (snake_case)', () => { const root = tmpRoot() writeFinished(root, 'failed', 7) diff --git a/packages/amico-run/test/s31.test.ts b/packages/amico-run/test/s31.test.ts index 0567ff55..d0914185 100644 --- a/packages/amico-run/test/s31.test.ts +++ b/packages/amico-run/test/s31.test.ts @@ -2,8 +2,12 @@ import { describe, it, expect } from 'vitest' import { readFileSync, readdirSync } from 'node:fs' import { join } from 'node:path' -// S31 / spec §4: no physics flag parsing, no SolveSpec, no MCP, no HTTP in the orchestrator. -const FORBIDDEN = [/SolveSpec/, /--gate\b/, /--system\b/, /--pulse\b/, +// S31 / spec §4: no PHYSICS flag parsing, no MCP, no HTTP in the orchestrator. +// (The original /SolveSpec/ ban is lifted by spec C: amico-run is now the +// named SolveSpec launch gate — it validates + gates the spec before spawning +// Julia. The physics-flag bans below still hold: --spec is a spec-file path, +// NOT a physics knob; all physics stays in the script.) +const FORBIDDEN = [/--gate\b/, /--system\b/, /--pulse\b/, /modelcontextprotocol/i, /node:https?\b/, /\bfetch\s*\(/] describe('S31 grep rule', () => { From 3d4bf71ee29fca22abb71a87fa2ccd86ce36d0df Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 3 Jul 2026 22:41:21 -0400 Subject: [PATCH 047/135] feat(amico-run): resolve + sandbox subcommands (mechanical tier resolution, env generation; existence-guarded dispatch) --- packages/amico-run/src/cli.ts | 10 +- packages/amico-run/src/subcommands.ts | 90 ++++++++++++++++ packages/amico-run/test/subcommands.test.ts | 112 ++++++++++++++++++++ 3 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 packages/amico-run/src/subcommands.ts create mode 100644 packages/amico-run/test/subcommands.test.ts diff --git a/packages/amico-run/src/cli.ts b/packages/amico-run/src/cli.ts index 7c0a64fc..71f2eba2 100644 --- a/packages/amico-run/src/cli.ts +++ b/packages/amico-run/src/cli.ts @@ -4,12 +4,20 @@ import { LocalExecutor } from './local_executor.js' import { ConfigError, type Finished, type SubmitOpts } from './types.js' import { readAuthoring } from './authoring.js' import { runGate } from './gate.js' +import { trySubcommand } from './subcommands.js' const USAGE = `usage: amico-run [--executor local] [--lab ] [--runs-root ] [--julia ] [--project ] [--sysimage ] - [--spec ] (spec C: validate + gate before launch)` + [--spec ] (spec C: validate + gate before launch) + amico-run resolve --platform

--kind --size (tier resolution → JSON) + amico-run sandbox --packages A,B,… (generate env/Project.toml) + (a bare script literally named "resolve"/"sandbox" still launches — dispatch checks the file exists)` export async function main(argv: string[]): Promise { + // spec C subcommands — dispatched before the launch flag loop + const sub = trySubcommand(argv) + if (sub !== undefined) return sub + let script: string | undefined let executor = 'local' let specPath: string | undefined diff --git a/packages/amico-run/src/subcommands.ts b/packages/amico-run/src/subcommands.ts new file mode 100644 index 00000000..d171d15c --- /dev/null +++ b/packages/amico-run/src/subcommands.ts @@ -0,0 +1,90 @@ +// amico-run subcommands (spec C): `resolve` (mechanical tier resolution the +// agent calls to pick a tier + source + packages) and `sandbox` (generate a +// per-problem Julia project from a package set). Both are bash-callable from +// the Amicode workflow. Dispatch only fires when argv[0] is the literal +// subcommand AND is not an existing file (a bare script named `resolve` keeps +// the launch contract). +import { existsSync, mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { readAuthoring } from './authoring.js' +import { loadExemplarsIndex, loadRegistry, matchShape } from './catalog.js' + +/** Tier-3 minimum package set — the free skeleton's `using` block AND the + * re-rollout harness both need these in the sandbox env, so `resolve` returns + * them for tier free (an empty set would generate an uninstantiable env). */ +const TIER3_MIN_PACKAGES = ['Piccolo', 'CairoMakie', 'JLD2', 'TOML', 'Printf'] + +function flagValue(argv: string[], name: string): string | undefined { + const i = argv.indexOf(name) + return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined +} + +export function resolveCommand(argv: string[]): number { + const platform = flagValue(argv, '--platform') + const kind = flagValue(argv, '--kind') + const sizeRaw = flagValue(argv, '--size') + if (!platform || !kind || !sizeRaw) { + console.error('amico-run resolve: --platform, --kind, --size are all required') + return 64 + } + const size = Number(sizeRaw) + if (!Number.isFinite(size)) { console.error(`amico-run resolve: --size must be a number (got ${sizeRaw})`); return 64 } + + const { config } = readAuthoring() + const registry = loadRegistry(config.registry ?? '') + const exemplars = loadExemplarsIndex(config.exemplars ?? '') + const match = matchShape({ platform, kind, size }, registry, exemplars, config.allowlist) + + const out: Record = { tier: match.tier } + if (match.template) { + out.source = { template_id: match.template.id } + out.template_path = match.template.path + out.packages = match.template.packages + } else if (match.exemplar) { + out.source = { exemplar_id: match.exemplar.id } + out.exemplar_path = match.exemplar.path + out.packages = match.exemplar.packages + } else { + out.packages = TIER3_MIN_PACKAGES + } + if (match.blockedHigher) out.blocked_higher = match.blockedHigher + console.log(JSON.stringify(out)) + return 0 +} + +export function sandboxCommand(argv: string[]): number { + const target = argv[0] + if (!target || target.startsWith('-')) { console.error('amico-run sandbox: required'); return 64 } + const packagesRaw = flagValue(argv, '--packages') + if (!packagesRaw) { console.error('amico-run sandbox: --packages A,B,… required'); return 64 } + const packages = packagesRaw.split(',').map((p) => p.trim()).filter(Boolean) + + const { config } = readAuthoring() + const registry = loadRegistry(config.registry ?? '') + const missing = packages.filter((p) => !registry.uuids[p]) + if (missing.length > 0) { + console.error(`amico-run sandbox: no uuid in the registry for: ${missing.join(', ')}`) + return 64 + } + + const deps = packages + .slice() + .sort() + .map((p) => `${p} = ${JSON.stringify(registry.uuids[p])}`) + .join('\n') + const envDir = join(target, 'env') + mkdirSync(envDir, { recursive: true }) + writeFileSync(join(envDir, 'Project.toml'), `[deps]\n${deps}\n`) + console.log(`amico-run: wrote ${join(envDir, 'Project.toml')}`) + console.log(`instantiate it (private git deps need CLI git):`) + console.log(` JULIA_PKG_USE_CLI_GIT=true julia --project=${envDir} -e 'using Pkg; Pkg.instantiate()'`) + return 0 +} + +/** Dispatch a subcommand if argv[0] names one and is not an existing file. */ +export function trySubcommand(argv: string[]): number | undefined { + const head = argv[0] + if (head === 'resolve' && !existsSync(head)) return resolveCommand(argv.slice(1)) + if (head === 'sandbox' && !existsSync(head)) return sandboxCommand(argv.slice(1)) + return undefined +} diff --git a/packages/amico-run/test/subcommands.test.ts b/packages/amico-run/test/subcommands.test.ts new file mode 100644 index 00000000..7af098d7 --- /dev/null +++ b/packages/amico-run/test/subcommands.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, beforeAll } from "vitest" +import { execFileSync } from "node:child_process" +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { readToml } from "./helpers.js" + +const BUNDLE = join(__dirname, "..", "dist", "amico-run.js") +beforeAll(() => { + execFileSync("node", [join(__dirname, "..", "esbuild.config.mjs")], { cwd: join(__dirname, "..") }) +}) + +function run(args: string[], env: Record = {}): { code: number; stdout: string; stderr: string } { + try { + const stdout = execFileSync("node", [BUNDLE, ...args], { encoding: "utf8", env: { ...process.env, ...env } }) + return { code: 0, stdout, stderr: "" } + } catch (e) { + const err = e as { status?: number; stdout?: string; stderr?: string } + return { code: err.status ?? -1, stdout: err.stdout ?? "", stderr: err.stderr ?? "" } + } +} + +const REGISTRY = ` +verify_tolerance = 0.01 +[[template]] +id = "transmon-gate-1q" +platform = "transmon" +kind = "gate_synthesis" +size = 1 +path = "solve_template.jl" +status = "vetted" +packages = ["Piccolo", "CairoMakie", "JLD2", "TOML", "Printf"] +[support] +packages = ["JLD2", "CairoMakie", "TOML", "Printf"] +[uuids] +Piccolo = "c4671d76-df94-11ed-2057-43d4fd632fad" +JLD2 = "033835bb-8acc-5ee8-8aae-3f567f8a3819" +` + +function authoringDir(): string { + const dir = mkdtempSync(join(tmpdir(), "amico-sub-")) + writeFileSync(join(dir, "registry.toml"), REGISTRY) + writeFileSync(join(dir, "index.json"), JSON.stringify({ schema_version: 1, exemplars: [] })) + writeFileSync( + join(dir, "authoring.json"), + JSON.stringify({ + schema_version: 1, + allowlist: ["Piccolo", "Legato", "Intonato", "NamedTrajectories", "DirectTrajOpt"], + support_set: ["JLD2", "CairoMakie", "TOML", "Printf"], + registry: join(dir, "registry.toml"), + exemplars: join(dir, "index.json"), + verify_tolerance: 0.01, + }), + ) + return dir +} + +describe("resolve subcommand", () => { + it("exact vetted shape → tier vetted with template_path + packages", () => { + const dir = authoringDir() + const r = run(["resolve", "--platform", "transmon", "--kind", "gate_synthesis", "--size", "1"], { + AMICO_AUTHORING_FILE: join(dir, "authoring.json"), + }) + expect(r.code).toBe(0) + const out = JSON.parse(r.stdout) + expect(out.tier).toBe("vetted") + expect(out.template_path).toMatch(/solve_template\.jl$/) + expect(out.packages).toContain("Piccolo") + rmSync(dir, { recursive: true, force: true }) + }) + it("unknown shape → tier free WITH the skeleton's minimum package set", () => { + const dir = authoringDir() + const r = run(["resolve", "--platform", "ions", "--kind", "gate_synthesis", "--size", "1"], { + AMICO_AUTHORING_FILE: join(dir, "authoring.json"), + }) + const out = JSON.parse(r.stdout) + expect(out.tier).toBe("free") + expect(out.packages).toEqual(expect.arrayContaining(["Piccolo", "CairoMakie", "JLD2", "TOML", "Printf"])) + rmSync(dir, { recursive: true, force: true }) + }) +}) + +describe("sandbox subcommand", () => { + it("writes env/Project.toml with [deps] uuids + prints instantiate instructions", () => { + const dir = authoringDir() + const target = mkdtempSync(join(tmpdir(), "amico-ws-")) + const r = run(["sandbox", target, "--packages", "Piccolo,JLD2"], { + AMICO_AUTHORING_FILE: join(dir, "authoring.json"), + }) + expect(r.code).toBe(0) + expect(existsSync(join(target, "env", "Project.toml"))).toBe(true) + const proj = readToml(join(target, "env", "Project.toml")) + const deps = proj.deps as Record + expect(deps.Piccolo).toBe("c4671d76-df94-11ed-2057-43d4fd632fad") + expect(deps.JLD2).toBe("033835bb-8acc-5ee8-8aae-3f567f8a3819") + expect(r.stdout).toContain("JULIA_PKG_USE_CLI_GIT=true") + expect(r.stdout).toContain("Pkg.instantiate()") + rmSync(dir, { recursive: true, force: true }) + rmSync(target, { recursive: true, force: true }) + }) + it("unknown package (no uuid in registry) → exit 64 naming it", () => { + const dir = authoringDir() + const target = mkdtempSync(join(tmpdir(), "amico-ws-")) + const r = run(["sandbox", target, "--packages", "Piccolo,Zygote"], { + AMICO_AUTHORING_FILE: join(dir, "authoring.json"), + }) + expect(r.code).toBe(64) + expect(r.stderr).toMatch(/Zygote/) + rmSync(dir, { recursive: true, force: true }) + rmSync(target, { recursive: true, force: true }) + }) +}) From c73cd3494d9dfc185f05b94a1563f28c0009571a Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 3 Jul 2026 22:44:04 -0400 Subject: [PATCH 048/135] feat(amico-run): free-tier post-FINISHED re-rollout invoke + verification.toml fallback (never verification-less) --- packages/amico-run/src/cli.ts | 22 +++++++- packages/amico-run/src/types.ts | 2 + packages/amico-run/src/verify.ts | 57 +++++++++++++++++++ packages/amico-run/test/cli.test.ts | 37 ++++++++++++- packages/amico-run/test/verify.test.ts | 76 ++++++++++++++++++++++++++ 5 files changed, 191 insertions(+), 3 deletions(-) create mode 100644 packages/amico-run/src/verify.ts create mode 100644 packages/amico-run/test/verify.test.ts diff --git a/packages/amico-run/src/cli.ts b/packages/amico-run/src/cli.ts index 71f2eba2..e261b4b4 100644 --- a/packages/amico-run/src/cli.ts +++ b/packages/amico-run/src/cli.ts @@ -1,11 +1,18 @@ import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' +import { parse as parseToml } from 'smol-toml' import { LocalExecutor } from './local_executor.js' import { ConfigError, type Finished, type SubmitOpts } from './types.js' import { readAuthoring } from './authoring.js' import { runGate } from './gate.js' +import { runVerification } from './verify.js' import { trySubcommand } from './subcommands.js' +function readTomlSafe(fp: string): Record | undefined { + try { return parseToml(readFileSync(fp, 'utf8')) as Record } + catch { return undefined } +} + const USAGE = `usage: amico-run [--executor local] [--lab ] [--runs-root ] [--julia ] [--project ] [--sysimage ] [--spec ] (spec C: validate + gate before launch) @@ -73,7 +80,10 @@ export async function main(argv: string[]): Promise { console.error(`amico-run: --project ${opts.julia!.project} overrides the spec's env.project ${env.project}`) else opts.julia!.project = env.project } - opts.spec = { canonical: gate.stamp.specCanonical, tier: gate.stamp.tier, hashes: gate.stamp.hashes } + opts.spec = { + canonical: gate.stamp.specCanonical, tier: gate.stamp.tier, hashes: gate.stamp.hashes, + julia_binary: opts.julia!.julia, env_project: opts.julia!.project, + } } // NOTE: `--sysimage ` is honored (passed through to the Julia process and @@ -108,6 +118,16 @@ export async function main(argv: string[]): Promise { console.error(`amico-run: FINISHED missing in ${handle.runDir} (write fault)`) return 64 } + // spec C: free-tier re-rollout verification runs AFTER FINISHED, BEFORE the + // AMICODE_FINISHED line — so consumers see a settled verification state. The + // harness (or the fallback) always writes verification.toml; the promote gate + // keys off agree==true. + if (opts.spec?.tier === 'free') { + const { config: authoring } = readAuthoring() + await runVerification(handle.runDir, opts.spec, authoring) + const verified = readTomlSafe(join(handle.runDir, 'verification.toml')) + console.log(`AMICODE_VERIFIED agree=${verified?.agree === true}`) + } // stdout protocol line — camelCase by design (spec §4) console.log(`AMICODE_FINISHED status=${f.status} exitCode=${f.exitCode} runDir=${handle.runDir}`) if (f.status === 'aborted') return 130 diff --git a/packages/amico-run/src/types.ts b/packages/amico-run/src/types.ts index 963e8a83..67625612 100644 --- a/packages/amico-run/src/types.ts +++ b/packages/amico-run/src/types.ts @@ -19,6 +19,8 @@ export interface SpecStamp { canonical: string // stable-key-order solvespec.json body tier?: string hashes?: Record // incl. gate-computed spec_hash + julia_binary?: string // resolved julia bin — the free-tier verify harness runs under it + env_project?: string // resolved env project — --project for the harness } export type RunEvent = diff --git a/packages/amico-run/src/verify.ts b/packages/amico-run/src/verify.ts new file mode 100644 index 00000000..eb61fa51 --- /dev/null +++ b/packages/amico-run/src/verify.ts @@ -0,0 +1,57 @@ +// Free-tier re-rollout verification invoke (spec C). After FINISHED, when the +// SolveSpec is tier "free", amico-run runs the FIXED, VETTED re-rollout harness +// (a Julia asset shipped with the extension, path from authoring.json) against +// the run dir's system_verify.jld2 + pulse.jld2. The harness writes +// verification.toml itself; if it is missing, fails to run, or exits without +// writing, we write a fallback verification.toml with agree=false + a reason — +// a free run must NEVER end verification-less (absence would read as "pending" +// forever and mask a failure, and the auto-promote gate keys off agree==true). +import { spawn } from 'node:child_process' +import { existsSync, renameSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import type { AuthoringConfig } from './authoring.js' +import type { SpecStamp } from './types.js' + +function tomlEscape(s: string): string { + return JSON.stringify(s) +} + +function writeFallback(runDir: string, reason: string, tolerance: number): void { + const body = + `schema_version = "1"\n` + + `agree = false\n` + + `fidelity_rerolled = "nan"\n` + + `fidelity_reported = "nan"\n` + + `tolerance = ${tolerance}\n` + + `integrator = "none"\n` + + `error = ${tomlEscape(reason)}\n` + const tmp = join(runDir, `.verification.toml.tmp-${process.pid}`) + writeFileSync(tmp, body) + renameSync(tmp, join(runDir, 'verification.toml')) +} + +/** Run the harness; guarantee a verification.toml exists afterward. Never rejects. */ +export async function runVerification(runDir: string, spec: SpecStamp, authoring: AuthoringConfig): Promise { + const tolerance = authoring.verify_tolerance + const harness = authoring.verify_harness + if (!harness || !existsSync(harness)) { + writeFallback(runDir, `verification harness not found (${harness ?? 'unset'})`, tolerance) + return + } + // The harness interpreter is julia in production; AMICO_VERIFY_RUNNER overrides + // it for tests (node fake-harness). The env's project comes from the spec. + const runner = process.env.AMICO_VERIFY_RUNNER ?? spec.julia_binary ?? 'julia' + const args = runner === 'julia' && spec.env_project + ? [`--project=${spec.env_project}`, harness, runDir, String(tolerance)] + : [harness, runDir, String(tolerance)] + + const exitCode: number = await new Promise((resolvePromise) => { + const child = spawn(runner, args, { stdio: ['ignore', 'inherit', 'inherit'] }) + child.on('error', () => resolvePromise(127)) + child.on('close', (code) => resolvePromise(code ?? 1)) + }) + + if (!existsSync(join(runDir, 'verification.toml'))) { + writeFallback(runDir, `verification harness exited ${exitCode} without writing verification.toml`, tolerance) + } +} diff --git a/packages/amico-run/test/cli.test.ts b/packages/amico-run/test/cli.test.ts index 849fa5cb..bf4368db 100644 --- a/packages/amico-run/test/cli.test.ts +++ b/packages/amico-run/test/cli.test.ts @@ -9,9 +9,9 @@ beforeAll(() => { execFileSync('node', [join(__dirname, '..', 'esbuild.config.mjs')], { cwd: join(__dirname, '..') }) }) -function run(args: string[]): { code: number; stdout: string; stderr: string } { +function run(args: string[], env: Record = {}): { code: number; stdout: string; stderr: string } { try { - const stdout = execFileSync('node', [BUNDLE, ...args], { encoding: 'utf8' }) + const stdout = execFileSync('node', [BUNDLE, ...args], { encoding: 'utf8', env: { ...process.env, ...env } }) return { code: 0, stdout, stderr: '' } } catch (e) { const err = e as { status?: number; stdout?: string; stderr?: string } @@ -103,6 +103,39 @@ describe('amico-run CLI', () => { expect(r.code).toBe(0) expect(r.stdout).toContain(`--project=${env}`) }) + it('--spec tier=free: verification runs after FINISHED (AMICODE_VERIFIED + verification.toml); vetted: neither (spec C)', () => { + const root = tmpRoot() + const script = fakeJulia(root, 's.jl', '') + const env = join(root, 'env') + mkdirSync(env, { recursive: true }) + writeFileSync(join(env, 'Project.toml'), `[deps]\n`) + writeFileSync(join(env, 'Manifest.toml'), `julia_version = "1.11.0"\n`) + // fake harness (node) that writes agree=true; wired as the julia binary so + // runVerification spawns it (AMICO_VERIFY_RUNNER unset → spec.julia_binary) + const harness = fakeJulia(root, 'h.js', + `const fs=require('fs'),p=require('path');fs.writeFileSync(p.join(process.argv[process.argv.length-2],'verification.toml'),'schema_version = "1"\\nagree = true\\n')`) + writeFileSync(join(root, 'authoring.json'), JSON.stringify({ + schema_version: 1, allowlist: ['Piccolo'], support_set: ['JLD2', 'TOML'], + verify_harness: harness, verify_tolerance: 0.01, + })) + const julia = fakeJulia(root, 'j', `console.log('DONE f=0.99')`) + const AUTH = { AMICO_AUTHORING_FILE: join(root, 'authoring.json'), AMICO_VERIFY_RUNNER: harness } + + const freeSpec = { schema_version: '2', script_path: script, lab_id: 'default', tier: 'free', env: { kind: 'sandbox', project: env } } + writeFileSync(join(root, 'free.json'), JSON.stringify(freeSpec)) + const rFree = run([script, '--runs-root', join(root, 'runs'), '--spec', join(root, 'free.json'), '--julia', julia], AUTH) + expect(rFree.code).toBe(0) + expect(rFree.stdout).toMatch(/AMICODE_VERIFIED agree=true/) + const freeDir = /runDir=(\S+)/.exec(rFree.stdout)![1] + expect(existsSync(join(freeDir, 'verification.toml'))).toBe(true) + + const vetSpec = { schema_version: '2', script_path: script, lab_id: 'default', tier: 'vetted', env: { kind: 'provisioned' } } + writeFileSync(join(root, 'vet.json'), JSON.stringify(vetSpec)) + const rVet = run([script, '--runs-root', join(root, 'runs2'), '--spec', join(root, 'vet.json'), '--julia', julia], AUTH) + expect(rVet.stdout).not.toMatch(/AMICODE_VERIFIED/) + const vetDir = /runDir=(\S+)/.exec(rVet.stdout)![1] + expect(existsSync(join(vetDir, 'verification.toml'))).toBe(false) + }) it('SIGTERM to the CLI → abort lane, exit 130', async () => { const root = tmpRoot() const julia = fakeJulia(root, 'j', `console.log('READY'); setInterval(() => {}, 1000)`) diff --git a/packages/amico-run/test/verify.test.ts b/packages/amico-run/test/verify.test.ts new file mode 100644 index 00000000..01a5d70b --- /dev/null +++ b/packages/amico-run/test/verify.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest" +import { mkdtempSync, mkdirSync, existsSync, readFileSync, writeFileSync, chmodSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { runVerification } from "../src/verify.js" +import { readToml } from "./helpers.js" +import type { AuthoringConfig } from "../src/authoring.js" +import type { SpecStamp } from "../src/types.js" + +let root: string +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "amico-verify-")) +}) +afterEach(() => { + delete process.env.AMICO_VERIFY_RUNNER + rmSync(root, { recursive: true, force: true }) +}) + +// A fake harness = a node script that writes verification.toml into argv[1] (the run dir). +function fakeHarness(name: string, body: string): string { + const p = join(root, name) + writeFileSync(p, `#!/usr/bin/env node\n${body}\n`) + chmodSync(p, 0o755) + return p +} + +function authoring(harness?: string): AuthoringConfig { + return { + allowlist: [], + support_set: [], + verify_harness: harness, + verify_tolerance: 0.01, + } +} +const FREE_SPEC: SpecStamp = { canonical: "{}", tier: "free" } + +describe("runVerification", () => { + it("harness writes verification.toml → left intact", async () => { + const runDir = join(root, "run") + mkdirSync(runDir) + const harness = fakeHarness( + "h.js", + `const fs=require('fs'),p=require('path');fs.writeFileSync(p.join(process.argv[2],'verification.toml'),'schema_version = "1"\\nagree = true\\nfidelity_rerolled = 0.998\\n')`, + ) + process.env.AMICO_VERIFY_RUNNER = "node" + await runVerification(runDir, FREE_SPEC, authoring(harness)) + const v = readToml(join(runDir, "verification.toml")) + expect(v.agree).toBe(true) + expect(v.fidelity_rerolled).toBe(0.998) + }) + it("missing harness path → fallback verification.toml agree=false + error", async () => { + const runDir = join(root, "run") + mkdirSync(runDir) + await runVerification(runDir, FREE_SPEC, authoring(join(root, "nonexistent.jl"))) + const v = readToml(join(runDir, "verification.toml")) + expect(v.agree).toBe(false) + expect(String(v.error)).toMatch(/harness/) + }) + it("harness exits nonzero WITHOUT writing → fallback agree=false + error", async () => { + const runDir = join(root, "run") + mkdirSync(runDir) + const harness = fakeHarness("h.js", `process.exit(3)`) + process.env.AMICO_VERIFY_RUNNER = "node" + await runVerification(runDir, FREE_SPEC, authoring(harness)) + const v = readToml(join(runDir, "verification.toml")) + expect(v.agree).toBe(false) + expect(existsSync(join(runDir, "verification.toml"))).toBe(true) + }) + it("no harness configured at all → fallback agree=false (never verification-less)", async () => { + const runDir = join(root, "run") + mkdirSync(runDir) + await runVerification(runDir, FREE_SPEC, authoring(undefined)) + expect(existsSync(join(runDir, "verification.toml"))).toBe(true) + expect(readToml(join(runDir, "verification.toml")).agree).toBe(false) + }) +}) From 96d06a6faf9c5b31d914cd5da57abb48e514f0ff Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 3 Jul 2026 22:50:53 -0400 Subject: [PATCH 049/135] =?UTF-8?q?feat(extension):=20authoring=20assets?= =?UTF-8?q?=20=E2=80=94=20template=20registry,=20tier-3=20skeleton,=20exem?= =?UTF-8?q?plars=20seed=20+=20index=20build,=20native=20re-rollout=20harne?= =?UTF-8?q?ss?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/extension/exemplars/EXEMPLARS.toml | 15 ++ packages/extension/exemplars/index.json | 22 +++ .../extension/exemplars/rydberg-cz/script.jl | 187 ++++++++++++++++++ packages/extension/julia/verify_rollout.jl | 57 ++++++ packages/extension/package.json | 3 +- .../extension/scripts/build_exemplars.mjs | 76 +++++++ packages/extension/templates/registry.toml | 48 +++++ packages/extension/templates/skeleton_free.jl | 134 +++++++++++++ packages/extension/test/packaging.test.ts | 8 + 9 files changed, 549 insertions(+), 1 deletion(-) create mode 100644 packages/extension/exemplars/EXEMPLARS.toml create mode 100644 packages/extension/exemplars/index.json create mode 100644 packages/extension/exemplars/rydberg-cz/script.jl create mode 100644 packages/extension/julia/verify_rollout.jl create mode 100644 packages/extension/scripts/build_exemplars.mjs create mode 100644 packages/extension/templates/registry.toml create mode 100644 packages/extension/templates/skeleton_free.jl diff --git a/packages/extension/exemplars/EXEMPLARS.toml b/packages/extension/exemplars/EXEMPLARS.toml new file mode 100644 index 00000000..b7d8bf7c --- /dev/null +++ b/packages/extension/exemplars/EXEMPLARS.toml @@ -0,0 +1,15 @@ +# In-repo exemplar seed (spec C tier 2). Each entry: a curated solve script the +# resolver can splice a Problem's params into (single-exemplar composition only +# in v1 — cross-exemplar = tier 3). build_exemplars.mjs aggregates this file ∪ +# any $AMICODE_DEMOS_ROOT/*/EXEMPLARS.toml, copies external scripts in-tree, +# computes a masked baseline_hash per entry, and writes exemplars/index.json +# (the artifact amico-run reads — this .toml is the build input, not the index). + +[[exemplar]] +id = "rydberg-cz" +platform = "rydberg" +kind = "gate_synthesis" +size = 2 +path = "rydberg-cz/script.jl" # relative to this file's dir +packages = ["Piccolo", "CairoMakie", "JLD2", "LinearAlgebra", "TOML", "Printf"] +notes = "EXPERIMENTAL exemplar — the NLP's first iteration is pathologically slow at this size in the current Piccolo path (see the script header); splice params but expect long solves until vetted." diff --git a/packages/extension/exemplars/index.json b/packages/extension/exemplars/index.json new file mode 100644 index 00000000..da9c08e8 --- /dev/null +++ b/packages/extension/exemplars/index.json @@ -0,0 +1,22 @@ +{ + "schema_version": 1, + "exemplars": [ + { + "id": "rydberg-cz", + "platform": "rydberg", + "kind": "gate_synthesis", + "size": 2, + "path": "rydberg-cz/script.jl", + "packages": [ + "Piccolo", + "CairoMakie", + "JLD2", + "LinearAlgebra", + "TOML", + "Printf" + ], + "notes": "EXPERIMENTAL exemplar — the NLP's first iteration is pathologically slow at this size in the current Piccolo path (see the script header); splice params but expect long solves until vetted.", + "baseline_hash": "sha256:b059453d448cbdfd4ca167d065a57682d49c1ad9ee41a1bf53a83e3fb0bbdda8" + } + ] +} diff --git a/packages/extension/exemplars/rydberg-cz/script.jl b/packages/extension/exemplars/rydberg-cz/script.jl new file mode 100644 index 00000000..abc23e7b --- /dev/null +++ b/packages/extension/exemplars/rydberg-cz/script.jl @@ -0,0 +1,187 @@ +#!/usr/bin/env julia +# ⚠️ EXPERIMENTAL — NOT YET VETTED. The NLP's first iteration is pathologically +# slow at this problem size in the current Piccolo path (both closure- and +# matrix-form systems tried; single iteration > 25 min where the transmon +# template solves in 72 s). Under investigation — do not wire into scores or +# demos until a vetting solve completes with F > 0.99. +# +# Amicode Rydberg CZ 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). +# +# Physics: two neutral atoms, 3 levels each (|0⟩ dark, laser couples |1⟩↔|r⟩, +# van-der-Waals blockade V·n⊗n on |rr⟩). CZ is the NATIVE entangling gate — +# defined on the {|0⟩,|1⟩}⊗2 computational subspace via EmbeddedOperator, and +# optimized with free_phase=true (CZ up to virtual single-atom Z rotations; +# fixed-phase fidelity systematically underreports entangling gates — both are +# recorded in result.toml, free-phase is the primary metric). +# Defaults = QuEra gate-zone (⁸⁷Rb, deep blockade, d = 2 μm). Units: μs, rad/μs. +using Piccolo +using CairoMakie # loads PiccoloMakieExt → gives LivePulsePlotCallback its impl +using JLD2 +using LinearAlgebra # Diagonal (free-phase goal construction) +using TOML +using Printf + +# ── FILL IN ────────────────────────────────────────────────────────────── +Ω_max = 4.6 * 2π # global Rabi bound (rad/μs) — QuEra gate zone +Δ_max = 20.0 * 2π # global detuning bound (rad/μs) +C6 = 28_800.0 * 2π # van der Waals coefficient (rad/μs·μm⁶) +distance = 2.0 # atom spacing (μm) → V = C6/d⁶ (deep blockade: V/Ω ≈ 98) +T = 0.5 # gate time (μs) — J&P speed limit ≈ 7.61/Ω_max ≈ 0.26 μs; fixed-phase needs extra slack +N = 51 # timesteps +max_iter = 400 +# ───────────────────────────────────────────────────────────────────────── + +V = C6 / distance^6 + +# Single-atom 3-level operators: basis |0⟩=1, |1⟩=2, |r⟩=3 (|0⟩ is dark). +const σx_1r = ComplexF64[0 0 0; 0 0 1; 0 1 0] # |1⟩⟨r| + h.c. +const σy_1r = ComplexF64[0 0 0; 0 0 -im; 0 im 0] # -i|1⟩⟨r| + i|r⟩⟨1| +const n_r = ComplexF64[0 0 0; 0 0 0; 0 0 1] # |r⟩⟨r| +const I3 = ComplexF64[1 0 0; 0 1 0; 0 0 1] + +# Two atoms, global drives (QuEra zoned architecture: no individual addressing). +Hx = kron(σx_1r, I3) + kron(I3, σx_1r) # u[1] = Ωx(t) +Hy = kron(σy_1r, I3) + kron(I3, σy_1r) # u[2] = Ωy(t) +Hn = kron(n_r, I3) + kron(I3, n_r) # u[3] = -Δ(t) (sign folded into H below) +H_drift = V * kron(n_r, n_r) # blockade shift on |rr⟩ + +# Matrix form (NOT a closure): hands Piccolo the bilinear structure analytically — +# the function-Hamiltonian path forces AD through the closure per NLP evaluation +# and is orders of magnitude slower at 9-dim. Detuning sign folded into the drive. +sys = QuantumSystem(H_drift, [Hx, Hy, -Hn], + [(-Ω_max, Ω_max), (-Ω_max, Ω_max), (-Δ_max, Δ_max)]) + +# CZ on the {|0⟩,|1⟩} subspace of each atom, embedded in the 9-dim space. +op = EmbeddedOperator(GATES[:CZ], [1, 2], [1:2, 1:2], [3, 3]) + +times = collect(range(0.0, T, length = N)) +initial = 0.1 * randn(sys.n_drives, N) +qtraj = UnitaryTrajectory(sys, ZeroOrderPulse(initial, times), op) +# NOTE: free_phase=true needs Piccolissimo's global-aware integrator — this +# template stays public-Piccolo-only (the lab project ships Piccolo). So the +# NLP targets FIXED-phase CZ (the detuning drive supplies the authority to null +# the single-atom phases), and the free-phase metric is recovered at report +# time by a virtual-Z scan over the rolled-out unitary (phases are software +# rotations; best-phase fidelity is the honest primary metric). +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 — same blessed idiom as the transmon template (AGENTS.md): +# LivePulsePlotCallback writes iter_.png; the Run Inspector reads the frames. +const PLOT_EVERY = 6 +live_plot = LivePulsePlotCallback(qtraj, prob.trajectory; every = PLOT_EVERY, save_dir = ".") + +# Pulse-data telemetry (#66): AMICODE_PULSE lines per iteration, riding the same +# solver-agnostic (primal, iter) hook as the live plot. Verbatim from the vetted +# transmon template — the contract is identical. +struct PulseEmitCallback <: AbstractIntermediateCallback + inner::Any + traj::Any +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 + 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 + 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 + end + return ok +end +pulse_emit = PulseEmitCallback(live_plot, prob.trajectory) + +let ls = "\"Ωx\",\"Ωy\",\"Δ\"", + bs = "$(-Ω_max):$(Ω_max),$(-Ω_max):$(Ω_max),$(-Δ_max):$(Δ_max)" + println("AMICODE_PULSE_META drives=$(sys.n_drives) knots=$N labels=$ls bounds=$bs") + flush(stdout) +end + +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 on the computational subspace from a fresh rollout (same rationale as +# the transmon template), reported BOTH ways: +# fixed-phase — raw CZ target; +# free-phase — CZ up to the optimized virtual Z's (φ_1, φ_2) — PRIMARY metric. +traj_final = get_trajectory(qcp) +Uroll = iso_vec_to_operator(unitary_rollout(traj_final, sys)[:, end]) +fid_fixed = unitary_fidelity(Uroll, op.operator; subspace = op.subspace) + +# Free-phase metric via post-hoc virtual-Z scan: CZ is equivalent up to +# single-atom Z rotations exp(i(φ₁n₁+φ₂n₂)); scan (φ₁,φ₂), then refine. The +# basis phase per computational state |b₁b₂⟩ is φ₁b₁+φ₂b₂ (the +# _make_free_phase_goal convention). +phase_fid(φ1, φ2) = unitary_fidelity(Uroll, + Diagonal(ComplexF64[1, exp(im * φ2), exp(im * φ1), exp(im * (φ1 + φ2))]) * GATES[:CZ]; + subspace = op.subspace) +best_f, best_φ1, best_φ2 = fid_fixed, 0.0, 0.0 +for φ1 in range(0, 2π; length = 73), φ2 in range(0, 2π; length = 73) + f = phase_fid(φ1, φ2) + if f > best_f + best_f, best_φ1, best_φ2 = f, φ1, φ2 + end +end +for δφ in (0.02, 0.002) # two local refinement passes around the grid optimum + for φ1 in range(best_φ1 - 5δφ, best_φ1 + 5δφ; length = 11), + φ2 in range(best_φ2 - 5δφ, best_φ2 + 5δφ; length = 11) + f = phase_fid(φ1, φ2) + if f > best_f + best_f, best_φ1, best_φ2 = f, φ1, φ2 + end + end +end +fid_free = best_f +phases = [best_φ1, best_φ2] +fid = max(fid_free, fid_fixed) + +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", traj_final) +open("result.toml.tmp", "w") do io + TOML.print(io, Dict( + "schema_version" => "1", + "fidelity" => fid, "iterations" => iters[], "wall_seconds" => wall, + "params" => Dict("platform" => "rydberg", "gate" => "CZ", + "Omega_max" => Ω_max, "Delta_max" => Δ_max, + "C6" => C6, "distance" => distance, + "V_blockade" => V, "T" => T, "N" => N, "max_iter" => max_iter, + "fidelity_fixed_phase" => fid_fixed, + "fidelity_free_phase" => fid_free, + "phi_1" => length(phases) == 2 ? phases[1] : 0.0, + "phi_2" => length(phases) == 2 ? phases[2] : 0.0), + )) +end +mv("result.toml.tmp", "result.toml"; force = true) +println("DONE fidelity=$(fid)"); flush(stdout) diff --git a/packages/extension/julia/verify_rollout.jl b/packages/extension/julia/verify_rollout.jl new file mode 100644 index 00000000..64c3c69b --- /dev/null +++ b/packages/extension/julia/verify_rollout.jl @@ -0,0 +1,57 @@ +#!/usr/bin/env julia +# Amicode tier-3 verification harness (spec C) — FIXED, VETTED ASSET. +# Usage: julia --project= verify_rollout.jl [tolerance] +# +# Reads system_verify.jld2 (+ pulse.jld2 "traj") from the run dir and re-checks +# the reported fidelity with Piccolo's NATIVE re-rollout — the same +# unitary_rollout + unitary_fidelity idiom as the vetted template's tail, and +# the ground-truth path that catches optimizer-vs-rollout divergence. The +# independence the trust chain needs is from the AUTHORED SCRIPT's optimizer +# transcription, NOT from Piccolo: this harness contains no custom integration +# code by design (Aaron directive 2026-07-03). It is shipped with the extension +# and never model-authored. +using JLD2, TOML +using Piccolo + +function main(run_dir::String, tol::Float64) + sv = JLD2.load(joinpath(run_dir, "system_verify.jld2")) + traj = JLD2.load(joinpath(run_dir, "pulse.jld2"), "traj") + + # native reconstruction from the serialized generators (3-arg matrix ctor: + # drift, drives, drive_bounds) + sys = QuantumSystem(sv["H_drift"], sv["H_drives"], sv["drive_bounds"]) + + fid = if sv["goal_kind"] == "unitary" + # native re-rollout, template idiom (solve_template.jl tail). The goal is + # serialized FULL-space; unitary_fidelity(U, U_goal; subspace) restricts + # both to the computational subspace itself (dynamics.jl:291). + Uroll = iso_vec_to_operator(unitary_rollout(traj, sys)[:, end]) + if haskey(sv, "subspace") + unitary_fidelity(Uroll, sv["goal"]; subspace = collect(Int, sv["subspace"])) + else + unitary_fidelity(Uroll, sv["goal"]) + end + else + ψroll = rollout(sv["initial_state"], traj, sys)[:, end] + fidelity(ψroll, sv["goal"]) + end + + reported = try TOML.parsefile(joinpath(run_dir, "result.toml"))["fidelity"] catch; NaN end + agree = isfinite(reported) && abs(fid - reported) <= tol + + open(joinpath(run_dir, "verification.toml.tmp"), "w") do io + TOML.print(io, Dict( + "schema_version" => "1", + "fidelity_rerolled" => fid, + "fidelity_reported" => reported, + "tolerance" => tol, + "agree" => agree, + "integrator" => "piccolo_unitary_rollout", + "checked_at" => string(round(Int, time())), + )) + end + mv(joinpath(run_dir, "verification.toml.tmp"), joinpath(run_dir, "verification.toml"); force = true) + println("VERIFY agree=$(agree) rerolled=$(fid) reported=$(reported)") +end + +main(ARGS[1], length(ARGS) >= 2 ? parse(Float64, ARGS[2]) : 0.01) diff --git a/packages/extension/package.json b/packages/extension/package.json index bb257568..b3aefc3b 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -124,8 +124,9 @@ "test:slow": "vitest run test/slow", "test:smoke": "node test/boot_smoke.mjs", "fetch:opencode": "node scripts/fetch_opencode.mjs", + "build:exemplars": "node scripts/build_exemplars.mjs", "healthcheck": "node scripts/healthcheck.mjs", - "package": "pnpm --filter @amicode/amico-run build && pnpm run build && pnpm run fetch:opencode && vsce package --no-dependencies --allow-missing-repository -o amicode.vsix", + "package": "pnpm --filter @amicode/amico-run build && pnpm run build && pnpm run build:exemplars && pnpm run fetch:opencode && vsce package --no-dependencies --allow-missing-repository -o amicode.vsix", "dev:pulseplot": "esbuild dev/pulseplot_harness/main.ts --bundle --format=iife --outfile=dev/pulseplot_harness/main.js && open dev/pulseplot_harness/index.html" }, "devDependencies": { diff --git a/packages/extension/scripts/build_exemplars.mjs b/packages/extension/scripts/build_exemplars.mjs new file mode 100644 index 00000000..b4b54c8f --- /dev/null +++ b/packages/extension/scripts/build_exemplars.mjs @@ -0,0 +1,76 @@ +#!/usr/bin/env node +// Build the tier-2 exemplars index (spec C). Aggregates the in-repo +// exemplars/EXEMPLARS.toml ∪ every $AMICODE_DEMOS_ROOT/*/EXEMPLARS.toml +// (absent → in-repo only, CI-safe). External scripts are COPIED into +// exemplars// and their `path` rewritten extension-relative, so tier 2 +// works for users without local demo clones (spec: "bundles the index AND the +// referenced exemplar scripts as extension assets"). Each entry gets a masked +// baseline_hash — the SAME mask+sha the amico-run gate recomputes at launch +// (deliberately reimplemented here to keep the build dep-free of amico-run; +// test/exemplars_build.test.ts cross-checks the two via a shared fixture). +import { createHash } from 'node:crypto' +import { existsSync, mkdirSync, copyFileSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { parse as parseToml } from 'smol-toml' + +const here = dirname(fileURLToPath(import.meta.url)) +const exemplarsDir = join(here, '..', 'exemplars') + +// Masked baseline: interior lines between the fill markers → "#MASKED"; marker +// lines kept; unterminated block masks to EOF. MUST match amico-run/src/baseline.ts. +const DEFAULT_BEGIN = /^# ── FILL IN/ +const DEFAULT_END = /^# ─────/ +function maskFillPoints(text, beginSrc, endSrc) { + const begin = beginSrc ? new RegExp(beginSrc) : DEFAULT_BEGIN + const end = endSrc ? new RegExp(endSrc) : DEFAULT_END + const out = [] + let inside = false + for (const line of text.split('\n')) { + if (!inside && begin.test(line)) { inside = true; out.push(line); continue } + if (inside && end.test(line)) { inside = false; out.push(line); continue } + out.push(inside ? '#MASKED' : line) + } + return out.join('\n') +} +function maskedHash(text, beginSrc, endSrc) { + return 'sha256:' + createHash('sha256').update(maskFillPoints(text, beginSrc, endSrc)).digest('hex') +} + +function readEntries(tomlFile) { + if (!existsSync(tomlFile)) return [] + const parsed = parseToml(readFileSync(tomlFile, 'utf8')) + return Array.isArray(parsed.exemplar) ? parsed.exemplar : [] +} + +const exemplars = [] + +// 1. in-repo entries — scripts already live under exemplars/, paths are as-authored +for (const entry of readEntries(join(exemplarsDir, 'EXEMPLARS.toml'))) { + const scriptPath = join(exemplarsDir, entry.path) + if (!existsSync(scriptPath)) { console.error(`build_exemplars: missing in-repo script ${entry.path}`); process.exit(1) } + const text = readFileSync(scriptPath, 'utf8') + exemplars.push({ ...entry, baseline_hash: maskedHash(text, entry.fill_begin, entry.fill_end) }) +} + +// 2. external demo-repo entries — copy the script in-tree, rewrite path +const demosRoot = process.env.AMICODE_DEMOS_ROOT +if (demosRoot && existsSync(demosRoot)) { + for (const demo of readdirSync(demosRoot, { withFileTypes: true })) { + if (!demo.isDirectory()) continue + const tomlFile = join(demosRoot, demo.name, 'EXEMPLARS.toml') + for (const entry of readEntries(tomlFile)) { + const srcScript = join(demosRoot, demo.name, entry.path) + if (!existsSync(srcScript)) { console.error(`build_exemplars: missing demo script ${demo.name}/${entry.path}`); continue } + const destRel = join(entry.id, 'script.jl') + const destAbs = join(exemplarsDir, destRel) + mkdirSync(dirname(destAbs), { recursive: true }) + copyFileSync(srcScript, destAbs) + const text = readFileSync(destAbs, 'utf8') + exemplars.push({ ...entry, path: destRel, baseline_hash: maskedHash(text, entry.fill_begin, entry.fill_end) }) + } + } +} + +writeFileSync(join(exemplarsDir, 'index.json'), JSON.stringify({ schema_version: 1, exemplars }, null, 2) + '\n') +console.log(`build_exemplars: wrote index.json (${exemplars.length} exemplar${exemplars.length === 1 ? '' : 's'})`) diff --git a/packages/extension/templates/registry.toml b/packages/extension/templates/registry.toml new file mode 100644 index 00000000..49cc1fe1 --- /dev/null +++ b/packages/extension/templates/registry.toml @@ -0,0 +1,48 @@ +# Template registry (spec C tier 1) — shape key (platform × kind × size) → a +# vetted solve template. `status` gates tier-1 eligibility: ONLY status="vetted" +# entries resolve as tier 1; "experimental" entries are recorded for provenance +# but never selected (amico-run resolve skips them → the shape falls to tier 2/3). +# `packages` drives the entitlement check + sandbox generation; `entitlement` +# (optional) names the entitlement a gated template requires. + +verify_tolerance = 0.01 # tier-3 re-rollout agreement (absolute; calibration pending — spec C open q1) + +[[template]] +id = "transmon-gate-1q" +platform = "transmon" +kind = "gate_synthesis" +size = 1 +path = "solve_template.jl" # relative to this file's dir (packages/extension/templates/) +status = "vetted" +packages = ["Piccolo", "CairoMakie", "JLD2", "TOML", "Printf"] + +[[template]] +id = "rydberg-cz-2q" +platform = "rydberg" +kind = "gate_synthesis" +size = 2 +path = "solve_rydberg_cz.jl" +status = "experimental" # header: "⚠️ NOT YET VETTED" — NOT tier-1 eligible +packages = ["Piccolo", "CairoMakie", "JLD2", "LinearAlgebra", "TOML", "Printf"] + +# Fixed support set — packages the run-dir contract itself requires (JLD2 for +# pulse.jld2, CairoMakie for iter_.png) plus common numeric stdlibs. The +# import scan allows (entitlement allowlist ∪ this set ∪ Julia stdlibs); without +# it the scan would block the vetted template's OWN launch. +[support] +packages = ["JLD2", "CairoMakie", "Makie", "TOML", "Printf", "LinearAlgebra", "Random", "Statistics", "SparseArrays"] + +# Sandbox uuid map (spec C) — name → uuid for [deps] in a generated env +# Project.toml. Collected from the workspace package manifests. +[uuids] +Piccolo = "c4671d76-df94-11ed-2057-43d4fd632fad" +Legato = "2220d95b-0ec6-412e-99be-c6a5d269006d" +Intonato = "4c8581c7-0eaf-45eb-b8fa-a3474c50779c" +NamedTrajectories = "538bc3a1-5ab9-4fc3-b776-35ca1e893e08" +DirectTrajOpt = "c823fa1f-8872-4af5-b810-2b9b72bbbf56" +Piccolissimo = "8458e44f-bc41-4eb2-9db1-f076c5323fd6" +Strettissimo = "9423f871-5413-41e1-9397-0c868c1e660c" +Intonatissimo = "e3ca2e84-f023-4239-b49b-696c14c5fc73" +JLD2 = "033835bb-8acc-5ee8-8aae-3f567f8a3819" +CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0" +TOML = "fa267f1f-6049-4f14-aa54-33bafae1ed76" diff --git a/packages/extension/templates/skeleton_free.jl b/packages/extension/templates/skeleton_free.jl new file mode 100644 index 00000000..186c772d --- /dev/null +++ b/packages/extension/templates/skeleton_free.jl @@ -0,0 +1,134 @@ +#!/usr/bin/env julia +# Amicode TIER-3 FREE-AUTHORING skeleton (spec C). Author the physics in the +# marked `# ── AUTHOR ──` sections; NEVER edit the `# ── CONTRACT ──` blocks — +# they are the frozen run-dir contract (AMICODE_ITER / AMICODE_PULSE / iter PNGs +# / pulse.jld2 / result.toml / DONE) plus the tier-3 verification snapshot the +# fixed re-rollout harness checks. Launch through the gate: +# amico-run --spec solvespec.json --project solve.jl +# Results are UNTRUSTED until verification.toml records agree = true. +using Piccolo +using CairoMakie # loads PiccoloMakieExt → gives LivePulsePlotCallback its impl +using JLD2 +using TOML +using Printf + +# ── AUTHOR: system + goal ───────────────────────────────────────────────── +# Build your QuantumSystem and target. Define, at minimum: +# sys :: QuantumSystem +# op :: the goal — an EmbeddedOperator (has .operator + .subspace) OR +# a bare Operator/Matrix on the full space +# N :: Int, timesteps +# T :: Float64, total time +# drive_max :: Float64, per-quadrature drive bound +# max_iter :: Int +# Example (single-qubit X on a 3-level transmon): +# δ = 0.2; levels = 3; T = 10.0; N = 50; drive_max = 0.2; max_iter = 60 +# sys = TransmonSystem(; δ = δ, levels = levels, drive_bounds = fill(drive_max, 2)) +# op = EmbeddedOperator(GATES[:X], sys) +# ────────────────────────────────────────────────────────────────────────── + +# ── AUTHOR: trajectory + problem ────────────────────────────────────────── +# Build the trajectory + optimization problem. Define `qtraj`, `qcp`, and: +# prob = hasproperty(qcp, :prob) ? qcp.prob : qcp +# Example: +# 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 +# ────────────────────────────────────────────────────────────────────────── + +# ── CONTRACT: verification snapshot (DO NOT EDIT) ────────────────────────── +# Serialize the CONSTRUCTED problem so the fixed, vetted re-rollout harness can +# re-check the reported fidelity independently (spec C tier-3 verification). +# The AUTHOR sections above must have produced `sys` and `op`; this reads the +# generators + goal off them, full-space, and records the computational subspace. +let goal_is_embedded = isdefined(Main, :op) && hasproperty(op, :operator) && hasproperty(op, :subspace) + U_goal_full = goal_is_embedded ? Matrix{ComplexF64}(op.operator) : Matrix{ComplexF64}(op isa AbstractMatrix ? op : op.operator) + subspace_idx = goal_is_embedded ? collect(Int, op.subspace) : collect(1:size(U_goal_full, 1)) + JLD2.jldopen("system_verify.jld2", "w") do f + f["schema"] = 1 + f["H_drift"] = Matrix{ComplexF64}(sys.H_drift) + f["H_drives"] = [Matrix{ComplexF64}(H) for H in sys.H_drives] + f["goal_kind"] = "unitary" + f["goal"] = U_goal_full + f["subspace"] = subspace_idx + f["drive_bounds"] = collect(Float64, sys.drive_bounds) + end +end +# ────────────────────────────────────────────────────────────────────────── + +# ── CONTRACT: telemetry + solve + artifacts (DO NOT EDIT) ────────────────── +const PLOT_EVERY = 6 +live_plot = LivePulsePlotCallback(qtraj, prob.trajectory; every = PLOT_EVERY, save_dir = ".") + +struct PulseEmitCallback <: AbstractIntermediateCallback + inner::Any + traj::Any +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 + 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 + 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 + 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 + +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 + +Uroll = iso_vec_to_operator(unitary_rollout(get_trajectory(qcp), sys)[:, end]) +fid = hasproperty(op, :operator) ? unitary_fidelity(Uroll, op.operator; subspace = op.subspace) : + unitary_fidelity(Uroll, op isa AbstractMatrix ? op : op.operator) + +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) +open("result.toml.tmp", "w") do io + TOML.print(io, Dict( + "schema_version" => "1", + "fidelity" => fid, "iterations" => iters[], "wall_seconds" => wall, + "params" => Dict("N" => N, "T" => T, "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/test/packaging.test.ts b/packages/extension/test/packaging.test.ts index 69708dbd..098530ef 100644 --- a/packages/extension/test/packaging.test.ts +++ b/packages/extension/test/packaging.test.ts @@ -8,6 +8,14 @@ const REQUIRED = [ 'extension/bin/dist/amico-run.js', 'extension/bin/launcher/amico-run', 'extension/templates/solve_template.jl', + // spec C authoring assets — the tiered resolver + verification chain break + // silently if any of these is dropped from the vsix. + 'extension/templates/registry.toml', // tier-1 template registry + support set + sandbox uuid map + 'extension/templates/skeleton_free.jl', // tier-3 free-authoring skeleton (contract + verify snapshot) + 'extension/exemplars/EXEMPLARS.toml', // tier-2 seed (build input) + 'extension/exemplars/index.json', // tier-2 index (the artifact amico-run reads) + 'extension/exemplars/rydberg-cz/script.jl', // the seeded exemplar script the index points at + 'extension/julia/verify_rollout.jl', // fixed re-rollout harness — the tier-3 trust anchor 'extension/julia/Project.toml', 'extension/julia/Manifest.toml', 'extension/AGENTS.md', From b34c6f81e90f18af47b4b8527bd2c68ea6a64efb Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 3 Jul 2026 23:04:20 -0400 Subject: [PATCH 050/135] feat(extension): issimo package entitlements + authoring.json session-prep seam (spec C) --- packages/extension/scores/entitlements.toml | 10 ++- packages/extension/src/opencode_config.ts | 62 ++++++++++++++++++- packages/extension/src/scores/entitlements.ts | 25 ++++++++ .../test/scores/entitlements_router.test.ts | 27 +++++++- .../test/scores/prep_integration.test.ts | 19 ++++++ 5 files changed, 140 insertions(+), 3 deletions(-) diff --git a/packages/extension/scores/entitlements.toml b/packages/extension/scores/entitlements.toml index 2c6e3391..caea3677 100644 --- a/packages/extension/scores/entitlements.toml +++ b/packages/extension/scores/entitlements.toml @@ -1,3 +1,11 @@ # 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"] +known = ["pasqal-hackathon-2026", "issimo"] + +# Entitlement → Harmoniqs package tiers (spec C). `default` is the public base +# every session gets; each named entitlement adds its gated packages. The +# resolver + the amico-run import scan consume packageAllowlist(this, ents); +# filterRepertoire (score visibility) is unrelated and stays score-only. +[packages] +default = ["Piccolo", "Legato", "Intonato", "NamedTrajectories", "DirectTrajOpt"] +issimo = ["Piccolissimo", "Strettissimo", "Intonatissimo"] diff --git a/packages/extension/src/opencode_config.ts b/packages/extension/src/opencode_config.ts index 7ad19b3a..f4662e99 100644 --- a/packages/extension/src/opencode_config.ts +++ b/packages/extension/src/opencode_config.ts @@ -1,8 +1,9 @@ import * as fs from "node:fs"; import * as path from "node:path"; import * as os from "node:os"; +import { parse as parseToml } from "smol-toml"; import { loadRepertoire } from "./scores/loader"; -import { readLocalEntitlements, filterRepertoire } from "./scores/entitlements"; +import { readLocalEntitlements, filterRepertoire, packageAllowlist } from "./scores/entitlements"; import { buildRouterSection } from "./scores/router"; import { compileScore, spliceIntoAgentsMd } from "./scores/compiler"; @@ -113,6 +114,61 @@ const DEFAULT_PLUGIN_PATH = path.resolve(__dirname, "..", "opencode-plugin", "am * plugin path. Holds SCORE.md manifests, score-local templates, memory hooks. */ export const DEFAULT_SCORES_ROOT = path.resolve(__dirname, "..", "scores"); +/** Bundled spec-C authoring assets (absolute), resolved relative to this module. + * At runtime __dirname is the extension's dist/src dir; the assets ship one + * level up under templates/, exemplars/, julia/. */ +export const AUTHORING_ASSETS = { + registry: path.resolve(__dirname, "..", "templates", "registry.toml"), + exemplars: path.resolve(__dirname, "..", "exemplars", "index.json"), + verifyHarness: path.resolve(__dirname, "..", "julia", "verify_rollout.jl"), +}; + +/** Where amico-run reads the authoring config (spec C seam). $AMICO_AUTHORING_FILE + * overrides (tests + parity with amico-run's own reader). */ +export function authoringFilePath(): string { + const env = process.env.AMICO_AUTHORING_FILE; + if (env && env.trim() !== "") return env; + return path.join(os.homedir(), ".amico", "authoring", "authoring.json"); +} + +/** Assemble + write authoring.json at session prep. Reads verify_tolerance from + * the bundled registry.toml (falls back to 0.01). Never throws — a write + * failure logs and leaves amico-run to use its built-in conservative defaults. */ +export function writeAuthoringConfig(entitlementsDir: string): void { + try { + const ents = readLocalEntitlements(entitlementsDir); + const registry = AUTHORING_ASSETS.registry; + const allowlist = packageAllowlist(registry, ents.entitlements); + let tolerance = 0.01; + let support: string[] = ["JLD2", "CairoMakie", "Makie", "TOML", "Printf", "LinearAlgebra", "Random", "Statistics", "SparseArrays"]; + try { + const reg = parseToml(fs.readFileSync(registry, "utf8")) as { verify_tolerance?: number; support?: { packages?: string[] } }; + if (typeof reg.verify_tolerance === "number") tolerance = reg.verify_tolerance; + if (Array.isArray(reg.support?.packages)) support = reg.support!.packages!; + } catch { /* keep defaults */ } + const file = authoringFilePath(); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync( + file, + JSON.stringify( + { + schema_version: 1, + allowlist, + support_set: support, + registry, + exemplars: AUTHORING_ASSETS.exemplars, + verify_harness: AUTHORING_ASSETS.verifyHarness, + verify_tolerance: tolerance, + }, + null, + 2, + ) + "\n", + ); + } catch (e) { + console.warn(`amicode: failed to write authoring.json (amico-run will use built-in defaults): ${e}`); + } +} + export function buildOpencodeConfigContent( agentsPath: string, templatePath: string, @@ -218,6 +274,10 @@ export function prepareOpencodeProject(opts: OpencodeConfigOptions): OpencodePro } fs.writeFileSync(agentsPath, finalContent, "utf8"); + // spec C: write the authoring.json seam amico-run reads (allowlist resolved + // from the same entitlements the score filter used + the bundled asset paths). + writeAuthoringConfig(opts.entitlementsDir ?? path.join(os.homedir(), ".amico", "amicode")); + // 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). return { projectDir, agentsPath, templatePath: opts.templateSrc }; diff --git a/packages/extension/src/scores/entitlements.ts b/packages/extension/src/scores/entitlements.ts index 0e42deb8..0adba2ea 100644 --- a/packages/extension/src/scores/entitlements.ts +++ b/packages/extension/src/scores/entitlements.ts @@ -47,3 +47,28 @@ export function filterRepertoire(scores: Score[], ents: string[]): Score[] { (s) => (s.manifest.entitlements ?? []).length === 0 || s.manifest.entitlements!.some((e) => ents.includes(e)), ); } + +// Entitlement → Harmoniqs package allowlist (spec C). Reads the [packages] +// table of entitlements.toml: `default` (public base) plus each held +// entitlement's package list. Feeds the resolver + the amico-run import scan — +// SEPARATE from filterRepertoire (score visibility). Missing file / malformed +// table → public defaults, never throws (an entitlement failure must not +// dead-end authoring). +const PUBLIC_PACKAGES = ["Piccolo", "Legato", "Intonato", "NamedTrajectories", "DirectTrajOpt"]; + +export function packageAllowlist(registryFile: string, ents: string[]): string[] { + let packages: { default?: string[] } & Record = {}; + try { + const parsed = parseToml(fs.readFileSync(registryFile, "utf8")) as { packages?: typeof packages }; + if (parsed.packages && typeof parsed.packages === "object") packages = parsed.packages; + } catch { + // missing/malformed → public defaults below + } + const base = Array.isArray(packages.default) ? packages.default : PUBLIC_PACKAGES; + const out = [...base]; + for (const ent of ents) { + const extra = packages[ent]; + if (Array.isArray(extra)) for (const p of extra) if (!out.includes(p)) out.push(p); + } + return out; +} diff --git a/packages/extension/test/scores/entitlements_router.test.ts b/packages/extension/test/scores/entitlements_router.test.ts index ea5c67d5..03515223 100644 --- a/packages/extension/test/scores/entitlements_router.test.ts +++ b/packages/extension/test/scores/entitlements_router.test.ts @@ -2,7 +2,7 @@ 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 { LocalEntitlementProvider, filterRepertoire, packageAllowlist } from "../../src/scores/entitlements"; import { buildRouterSection } from "../../src/scores/router"; import { Score } from "../../src/scores/loader"; @@ -104,3 +104,28 @@ describe("buildRouterSection", () => { expect(buildRouterSection([pub, gated])).toBe(buildRouterSection([pub, gated])); }); }); + +describe("packageAllowlist (spec C entitlement → package tiers)", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pkg-allow-")); + const registry = path.join(dir, "registry.toml"); + fs.writeFileSync( + registry, + `[packages]\ndefault = ["Piccolo", "Legato", "Intonato", "NamedTrajectories", "DirectTrajOpt"]\nissimo = ["Piccolissimo", "Strettissimo", "Intonatissimo"]\n`, + ); + + it("no entitlements → the five public packages", () => { + expect(packageAllowlist(registry, [])).toEqual([ + "Piccolo", "Legato", "Intonato", "NamedTrajectories", "DirectTrajOpt", + ]); + }); + it("issimo entitlement → adds the three gated packages", () => { + const allow = packageAllowlist(registry, ["issimo"]); + expect(allow).toEqual(expect.arrayContaining(["Piccolo", "Piccolissimo", "Strettissimo", "Intonatissimo"])); + expect(allow).toHaveLength(8); + }); + it("missing file / malformed [packages] → public defaults, never throws", () => { + expect(packageAllowlist(path.join(dir, "nope.toml"), ["issimo"])).toEqual([ + "Piccolo", "Legato", "Intonato", "NamedTrajectories", "DirectTrajOpt", + ]); + }); +}); diff --git a/packages/extension/test/scores/prep_integration.test.ts b/packages/extension/test/scores/prep_integration.test.ts index 6cbc2293..fd21d618 100644 --- a/packages/extension/test/scores/prep_integration.test.ts +++ b/packages/extension/test/scores/prep_integration.test.ts @@ -7,14 +7,20 @@ import { prepareOpencodeProject, buildOpencodeConfigContent, DEFAULT_SCORES_ROOT // Hermeticity: prepareOpencodeProject writes the plugin's manifest transport to // the problems root, which defaults into $HOME — point it at a tmp dir for the run. const PROBLEMS_TMP = fs.mkdtempSync(path.join(os.tmpdir(), "prep-problems-")); +const AUTHORING_TMP = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "prep-authoring-")), "authoring.json"); let prevProblemsDir: string | undefined; +let prevAuthoringFile: string | undefined; beforeAll(() => { prevProblemsDir = process.env.AMICODE_PROBLEMS_DIR; process.env.AMICODE_PROBLEMS_DIR = PROBLEMS_TMP; + prevAuthoringFile = process.env.AMICO_AUTHORING_FILE; + process.env.AMICO_AUTHORING_FILE = AUTHORING_TMP; // isolate authoring.json from $HOME }); afterAll(() => { if (prevProblemsDir === undefined) delete process.env.AMICODE_PROBLEMS_DIR; else process.env.AMICODE_PROBLEMS_DIR = prevProblemsDir; + if (prevAuthoringFile === undefined) delete process.env.AMICO_AUTHORING_FILE; + else process.env.AMICO_AUTHORING_FILE = prevAuthoringFile; }); const AGENTS_SRC = path.resolve(__dirname, "..", "..", "AGENTS.md"); @@ -74,6 +80,19 @@ describe("prepareOpencodeProject × scores (spec §6)", () => { const proj = prep({ scoresRoot: "/nonexistent/scores" }); expect(fs.readFileSync(proj.agentsPath, "utf8")).toContain("Stages, in order:"); }); + + it("writes authoring.json (spec C) — public allowlist, support set, existing bundled asset paths, tolerance", () => { + prep(); // entitlementsDir is a fresh empty dir → public entitlements + const authoring = JSON.parse(fs.readFileSync(AUTHORING_TMP, "utf8")); + expect(authoring.schema_version).toBe(1); + expect(authoring.allowlist).toEqual(["Piccolo", "Legato", "Intonato", "NamedTrajectories", "DirectTrajOpt"]); + expect(authoring.support_set).toEqual(expect.arrayContaining(["JLD2", "CairoMakie", "TOML"])); + expect(authoring.verify_tolerance).toBe(0.01); + // the paths point at REAL bundled assets (Task 9 shipped them) + expect(path.isAbsolute(authoring.registry) && fs.existsSync(authoring.registry)).toBe(true); + expect(path.isAbsolute(authoring.exemplars) && fs.existsSync(authoring.exemplars)).toBe(true); + expect(path.isAbsolute(authoring.verify_harness) && fs.existsSync(authoring.verify_harness)).toBe(true); + }); }); describe("buildOpencodeConfigContent × scores", () => { From 97c1fd5e3535e441aaf169f42d7d3a599a4e9760 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 3 Jul 2026 23:14:31 -0400 Subject: [PATCH 051/135] =?UTF-8?q?feat(extension):=20amicode=5Fverify=20t?= =?UTF-8?q?ool=20+=20tiered=20authoring=20workflow=20in=20AGENTS.md/SCORE.?= =?UTF-8?q?md=20(resolve=E2=86=92author=E2=86=92--spec)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/amico-run/src/subcommands.ts | 10 +- packages/extension/AGENTS.md | 92 +++++++++++++------ .../opencode-plugin/amicode_tools.ts | 48 ++++++++++ .../extension/opencode-plugin/entities.ts | 15 +++ .../extension/scores/pulse-designer/SCORE.md | 8 +- packages/extension/scripts/plugin_exercise.ts | 6 ++ packages/extension/test/agents_md.test.ts | 24 +++-- packages/extension/test/amicode_tools.test.ts | 11 +++ 8 files changed, 173 insertions(+), 41 deletions(-) diff --git a/packages/amico-run/src/subcommands.ts b/packages/amico-run/src/subcommands.ts index d171d15c..108da37a 100644 --- a/packages/amico-run/src/subcommands.ts +++ b/packages/amico-run/src/subcommands.ts @@ -5,7 +5,7 @@ // subcommand AND is not an existing file (a bare script named `resolve` keeps // the launch contract). import { existsSync, mkdirSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' +import { dirname, join, resolve } from 'node:path' import { readAuthoring } from './authoring.js' import { loadExemplarsIndex, loadRegistry, matchShape } from './catalog.js' @@ -35,14 +35,18 @@ export function resolveCommand(argv: string[]): number { const exemplars = loadExemplarsIndex(config.exemplars ?? '') const match = matchShape({ platform, kind, size }, registry, exemplars, config.allowlist) + // template/exemplar paths in the catalog are relative to their manifest file; + // resolve to absolute so the agent can copy the script directly. + const registryDir = config.registry ? dirname(config.registry) : process.cwd() + const exemplarsDir = config.exemplars ? dirname(config.exemplars) : process.cwd() const out: Record = { tier: match.tier } if (match.template) { out.source = { template_id: match.template.id } - out.template_path = match.template.path + out.template_path = resolve(registryDir, match.template.path) out.packages = match.template.packages } else if (match.exemplar) { out.source = { exemplar_id: match.exemplar.id } - out.exemplar_path = match.exemplar.path + out.exemplar_path = resolve(exemplarsDir, match.exemplar.path) out.packages = match.exemplar.packages } else { out.packages = TIER3_MIN_PACKAGES diff --git a/packages/extension/AGENTS.md b/packages/extension/AGENTS.md index 97948287..660334d5 100644 --- a/packages/extension/AGENTS.md +++ b/packages/extension/AGENTS.md @@ -13,37 +13,71 @@ and the Run Inspector renders the live solve. ## Workflow (this is the whole job) -1. Read the bundled template `solve_template.jl` at its absolute path: - `{{TEMPLATE_PATH}}`. -2. Copy it to `/tmp/amicode-work/solve.jl` (the exact path step 3 runs) and fill - in the `# FILL IN` parameter block from the user's request: transmon frequency - `ω` (GHz), anharmonicity `δ` (GHz), `levels`, the target gate, gate time `T` - (ns), timesteps `N`, `max_iter`. **Parameters live in the script — never in - this file.** If the user gives a `lab.toml` path, read it in the script. +The script is authored at an explicit TRUST TIER and launched through the gate +`amico-run --spec`. All paths below use the active Problem workspace +`~/.amico/problems//` (open/create/rename with `amicode_problem`; the +workspace owns `solve.jl` — never author in `/tmp`). + +1. **Resolve the tier** once the System + Formulation are recorded. From the + Formulation, run: + ```bash + amico-run resolve --platform --kind --size + ``` + It prints JSON: `{tier, source?, template_path?|exemplar_path?, packages, blocked_higher?}`. +2. **Author `solve.jl` per the tier** into `~/.amico/problems//solve.jl`: + - **vetted** — copy `template_path`, edit ONLY the `# FILL IN` block (physics + params from the request; parameters live in the script, never in this file). + - **composed** — copy `exemplar_path`, edit ONLY its `# FILL IN` block. Editing + outside the fill points makes it no longer the exemplar's physics — the gate + will reject it (see step 6). + - **free** — copy the bundled skeleton `skeleton_free.jl` (its path is + alongside the resolver's template dir), author the `# ── AUTHOR ──` + sections, and NEVER touch the `# ── CONTRACT ──` blocks (they emit the + run-dir contract + the verification snapshot the harness checks). +3. **`blocked_higher` present?** A better tier exists but needs an entitlement. + Say so plainly — "a vetted template for this exists but requires the + `` entitlement" — and get **explicit user + confirmation** before authoring at a lower tier with public packages. Never + silently downgrade. +4. **free tier only — generate the env** (vetted/composed use the provisioned + env unless `resolve` said otherwise): ```bash - mkdir -p /tmp/amicode-work && cp {{TEMPLATE_PATH}} /tmp/amicode-work/solve.jl - # …edit /tmp/amicode-work/solve.jl's FILL IN block… + amico-run sandbox ~/.amico/problems/ --packages + # then run the printed JULIA_PKG_USE_CLI_GIT=true julia --project=… Pkg.instantiate() line ``` -3. Run it **detached** so the chat doesn't block on the ~minutes-long solve: +5. **Assemble `~/.amico/problems//solvespec.json`**: + `{schema_version:"2", script_path:"…/solve.jl", lab_id:"default", + executor:"local", tier:"", env:{kind, project?}, source:, + hashes:{system_hash, formulation_hash}}` — read the hashes from the LAST + matching events in `~/.amico/problems//events.jsonl` (the `hash` field on + the newest `system`/`formulation` events). +6. **Launch through the gate, detached.** Pass `--project` matching the tier's + env: `{{JULIA_PROJECT}}` (the provisioned env) for vetted/composed, or the + sandbox env from step 4 for free (it must equal the spec's `env.project`). ```bash - ( nohup amico-run --project --lab default /tmp/amicode-work/solve.jl \ - > /tmp/amicode-work/solve.log 2>&1 < /dev/null & ) + ( nohup amico-run --spec ~/.amico/problems//solvespec.json \ + --project {{JULIA_PROJECT}} --lab default \ + ~/.amico/problems//solve.jl \ + > ~/.amico/problems//solve.log 2>&1 < /dev/null & ) ``` - (use the project path provided below; `--lab default` tags the run's lab so - it's recorded under `~/.amico/runs/default/`). The outer subshell returns in <1s. - `amico-run` takes only a script path and runner flags — it parses **no** - physics options; all the physics lives in the script you wrote. Then - immediately tell the user: **"Solve launched — watch the Run Inspector - (first run may take a few minutes while Julia warms up)."** -4. Do **not** block on the solve. The Run Inspector streams iterations + the - final fidelity from the run directory, and prompts promotion itself when - F ≥ 0.99 — don't ask. If asked for the result later, read the latest run's - `FINISHED` + `result.toml` under `~/.amico/runs///`. + The gate validates the spec, scans imports against your entitlement allowlist, + checks tier/env consistency, and (composed) checks the masked baseline. A + gate failure prints ONE line on stderr → relay it, fix, retry. A + `demote_to: "free"` rejection means the edits left the exemplar's physics — + **re-assemble as tier free** (which re-runs step 1's env resolution: a sandbox + from the script's ACTUAL imports), never just relabel. Then tell the user: + **"Solve launched — watch the Run Inspector (first run may take a few minutes + while Julia warms up)."** +7. **Do not block on the solve.** The Run Inspector streams iterations + the + final fidelity and prompts promotion itself when F ≥ 0.99 — don't ask. + **free tier:** after `FINISHED`, read `~/.amico/runs///verification.toml` + and record it with `amicode_verify` (agree + both fidelities). Relay the + agree/disagree honestly — a `free` run is UNTRUSTED and cannot be promoted + until verification agrees. There is **no MCP server**. The solve runs through `amico-run` via bash; the -`amicode_*` tools below (when present) record design state under a named **Problem -workspace** (open/create/rename it with `amicode_problem`; one is auto-created if -you don't) — they never replace the bash launch. `amico-run --help` prints usage. +`amicode_*` tools below (when present) record design state under the Problem +workspace — they never replace the bash launch. `amico-run --help` prints usage. ## Answering "What can Amicode do?" @@ -142,9 +176,11 @@ Stages, in order: `amicode_formulate`. 6. **SOLVE PARAMS** — `T`, `N`, `max_iter` (defaults per the regime guidance below); pass them to `amicode_solve` (it records them on the Formulation and - writes the Run entity), then author `solve.jl` from the vetted template - ({{TEMPLATE_PATH}}) and launch it detached per the workflow above (`amico-run` - via bash — the bash launch is still the mechanism). + writes the Run entity, stamped with the resolved `tier`), then author + `solve.jl` and launch it through the tiered gate — **follow the Workflow + steps 1–7 above** (`amico-run resolve` → author per tier → `amico-run --spec` + via bash). For a stock single-qubit transmon gate this resolves to the + **vetted** tier and is exactly the fill-in-the-block flow. 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 diff --git a/packages/extension/opencode-plugin/amicode_tools.ts b/packages/extension/opencode-plugin/amicode_tools.ts index bb1f2549..895687c8 100644 --- a/packages/extension/opencode-plugin/amicode_tools.ts +++ b/packages/extension/opencode-plugin/amicode_tools.ts @@ -536,6 +536,54 @@ export const AmicodeTools = async (_input: unknown) => ({ }, }, + amicode_verify: { + description: + "Record the free-tier re-rollout VERIFICATION outcome on the Run entity (spec C). " + + "Call this AFTER a `tier=\"free\"` solve finishes: amico-run runs the fixed re-rollout " + + "harness and writes verification.toml; read it and pass agree + the two fidelities here. " + + "Bookkeeping AFTER the fact — no stage gate (a verification record must never be lost). " + + "Promotion of a free run is blocked until agree = true.", + args: { + agree: { + type: "boolean", + description: "Did the independent re-rollout agree with the reported fidelity (verification.toml `agree`)?", + }, + fidelity_rerolled: { + type: ["number", "null"], + description: "The harness's re-rolled fidelity; null if unavailable.", + }, + fidelity_reported: { + type: ["number", "null"], + description: "The solve's reported fidelity; null if unavailable.", + }, + }, + async execute(a: { agree: boolean; fidelity_rerolled?: number | null; fidelity_reported?: number | null }) { + const meta = ensureActiveProblem(); + const existing = readEntityJson(meta.slug, "run"); + if (!existing) { + return ( + `No Run entity recorded in "${meta.slug}" yet — record the solve (amicode_solve) ` + + `before its verification.` + ); + } + const merged: RunStub = { + ...existing, + verification: { + agree: a.agree === true, + fidelity_rerolled: given(a.fidelity_rerolled) ? a.fidelity_rerolled : null, + fidelity_reported: given(a.fidelity_reported) ? a.fidelity_reported : null, + }, + }; + const sentinel = recordEntity(meta.slug, "run", merged as any, runStubToml(merged), { + tool: "amicode_verify", + }); + const verdict = a.agree + ? "agree = true — the re-rollout confirms the reported fidelity; the run can be promoted." + : "agree = FALSE — the independent re-rollout disagrees; the run is UNTRUSTED and cannot be promoted. Relay this honestly."; + return `Verification recorded on the Run entity in "${meta.slug}". ${verdict}\n\n${sentinel}`; + }, + }, + amicode_to_hardware: { description: "Record the DeviceSession entity stub (interview stage 8: HARDWARE — guided stub). " + diff --git a/packages/extension/opencode-plugin/entities.ts b/packages/extension/opencode-plugin/entities.ts index 6078769c..ba581ff5 100644 --- a/packages/extension/opencode-plugin/entities.ts +++ b/packages/extension/opencode-plugin/entities.ts @@ -71,6 +71,14 @@ export interface RunStub { script_ref?: string; /** Resolved env binding kind (spec C). */ env?: string; + /** Free-tier re-rollout verification outcome (spec C) — recorded by + * amicode_verify after amico-run's harness writes verification.toml. Spec B's + * entity view renders it beside the tier; promotion is gated on agree. */ + verification?: { + agree: boolean; + fidelity_rerolled?: number | null; + fidelity_reported?: number | null; + }; /** Optional free-text note ("X gate, defaults"). */ note?: string; } @@ -287,6 +295,13 @@ export function runStubToml(stub: RunStub, now?: Date): string { lines.push(`launched_via = ${tomlEscape("bash amico-run")}`); if (stub.note !== undefined) lines.push(`note = ${tomlEscape(stub.note)}`); lines.push(`recorded = ${tomlEscape(isoNow(now))}`); + if (stub.verification !== undefined) { + lines.push("", "[run.verification]", `agree = ${stub.verification.agree}`); + if (stub.verification.fidelity_rerolled != null) + lines.push(`fidelity_rerolled = ${stub.verification.fidelity_rerolled}`); + if (stub.verification.fidelity_reported != null) + lines.push(`fidelity_reported = ${stub.verification.fidelity_reported}`); + } return lines.join("\n") + "\n"; } diff --git a/packages/extension/scores/pulse-designer/SCORE.md b/packages/extension/scores/pulse-designer/SCORE.md index 4ff37297..d3b92e4e 100644 --- a/packages/extension/scores/pulse-designer/SCORE.md +++ b/packages/extension/scores/pulse-designer/SCORE.md @@ -137,9 +137,11 @@ Per-stage notes: `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). + cases. Then author `solve.jl` and launch it through the tiered gate per the + solve workflow (`amico-run resolve` → author per tier → `amico-run --spec` + via bash; `amicode_solve` records the Run entity with its tier). A stock + single-qubit transmon gate resolves to the **vetted** tier — the + fill-in-the-block flow. 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 diff --git a/packages/extension/scripts/plugin_exercise.ts b/packages/extension/scripts/plugin_exercise.ts index eaafa7b1..6a3393d1 100644 --- a/packages/extension/scripts/plugin_exercise.ts +++ b/packages/extension/scripts/plugin_exercise.ts @@ -49,6 +49,12 @@ const s4 = lastSentinel( ); assert(s4.entity === "run", "solve emits a run sentinel"); +// verify (spec C) — record the free-tier re-rollout outcome on the Run entity +const s5 = lastSentinel( + await tools.amicode_verify.execute({ agree: true, fidelity_rerolled: 0.998, fidelity_reported: 0.999 }), +); +assert(s5.entity === "run" && s5.action === "updated", "verify updates the run entity"); + // Workspace layout const ws = path.join(tmp, slug); for (const f of ["entities/system.toml", "entities/system.json", "entities/formulation.toml", "entities/run.toml", "problem.json"]) { diff --git a/packages/extension/test/agents_md.test.ts b/packages/extension/test/agents_md.test.ts index 41006ce5..2914ec81 100644 --- a/packages/extension/test/agents_md.test.ts +++ b/packages/extension/test/agents_md.test.ts @@ -5,12 +5,17 @@ import { join } from 'node:path' const AGENTS = readFileSync(join(__dirname, '..', 'AGENTS.md'), 'utf8') describe('AGENTS.md teaches the D9/D10 script-authoring workflow', () => { - it('points at the bundled template and the amico-run `; } From 83c799813f9a193f8f2efcf2d01168528f613c7a Mon Sep 17 00:00:00 2001 From: kate bonner Date: Mon, 6 Jul 2026 12:00:10 -0400 Subject: [PATCH 086/135] =?UTF-8?q?feat:=20theme-calculated=20Harmoniqs=20?= =?UTF-8?q?yellow=20=E2=80=94=20OKLCH-solved=20brand=20accent=20(brand-wid?= =?UTF-8?q?e)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit brand_accent.ts computes the deployed accent from the active theme at webview boot: the canonical #FFF676 ships EXACTLY wherever contrast vs the theme's editor background clears 3:1 (all dark themes); light themes get the closest-to-brand gold by binary-searching lightness with hue + chroma held (gamut-clamped). Two tokens with different jobs: lines (--color-accent, contrast-solved: borders/rings/marks) and fills (--color-accent-fill, always the brand lemon — black text on it ≈ 19:1; a 3:1-darkened gold passes WCAG math but reads muddy under text). --color-on-accent is contrast-picked; yellow is never text. Recomputed live on theme switch. Inspector + catalog-card webviews apply at boot; brand.css statics remain the no-JS fallback. Pill atom gains a dot-less badge variant (dot = process state; badges describe things). Co-Authored-By: Claude Fable 5 --- packages/extension/media/ui/atoms/pill.ts | 14 +- packages/extension/media/ui/brand_accent.ts | 141 ++++++++++++++++++ .../extension/src/catalog_card_webview.ts | 3 + packages/extension/src/inspector_webview.ts | 3 + packages/extension/test/brand_accent.test.ts | 60 ++++++++ 5 files changed, 219 insertions(+), 2 deletions(-) create mode 100644 packages/extension/media/ui/brand_accent.ts create mode 100644 packages/extension/test/brand_accent.test.ts diff --git a/packages/extension/media/ui/atoms/pill.ts b/packages/extension/media/ui/atoms/pill.ts index d7b3ab26..c150ab9c 100644 --- a/packages/extension/media/ui/atoms/pill.ts +++ b/packages/extension/media/ui/atoms/pill.ts @@ -1,4 +1,7 @@ // Pill atom — a status indicator. State is a class applied here, in TS. +// Variations: the default carries a status dot (live run states — the dot +// pulses while running); `dot: false` yields a plain badge (labels like +// "recommended" that describe a THING, not a process). import { defineStyle } from "../style"; @@ -10,6 +13,7 @@ defineStyle("pill", ` display: inline-flex; align-items: center; gap: var(--space-sm); } .pill::before { content: ""; width: var(--square-dot); height: var(--square-dot); border-radius: 50%; background: currentColor; } + .pill.no-dot::before { content: none; } .pill.idle { color: var(--color-dim); } .pill.running { color: var(--color-run); } .pill.running::before { animation: pill-pulse 1.1s ease-in-out infinite; } @@ -20,15 +24,21 @@ defineStyle("pill", ` export type PillState = "idle" | "running" | "done" | "failed"; +export interface PillOptions { + /** Status dot before the label (default true). Badges pass false. */ + dot?: boolean; +} + export interface PillAtom { el: HTMLSpanElement; set(state: PillState, label: string): void; } -export function pill(state: PillState = "idle", label = state): PillAtom { +export function pill(state: PillState = "idle", label: string = state, opts: PillOptions = {}): PillAtom { const el = document.createElement("span"); + const variant = opts.dot === false ? " no-dot" : ""; const set = (s: PillState, l: string) => { - el.className = "pill " + s; + el.className = "pill " + s + variant; el.textContent = l; }; set(state, label); diff --git a/packages/extension/media/ui/brand_accent.ts b/packages/extension/media/ui/brand_accent.ts new file mode 100644 index 00000000..b4e95d63 --- /dev/null +++ b/packages/extension/media/ui/brand_accent.ts @@ -0,0 +1,141 @@ +// Brand accent solver — the Harmoniqs yellow, theme-calculated. +// +// #FFF676 is the canonical brand accent (brand.css). At ~96% lightness it +// sings on dark themes and vanishes on light ones, so each webview computes +// the DEPLOYED accent from the active theme at boot: hold the brand's OKLCH +// hue + chroma, and if contrast against the theme's editor background already +// meets target, ship the brand hex EXACTLY (dark themes — decision: brand- +// exact wherever physics allows); otherwise walk lightness down to the +// closest-to-brand value that passes (light themes get a deeper gold). +// --color-on-accent is picked black/white by contrast on the computed fill — +// yellow itself is never text (fills + borders only). +// +// Pure math up top (unit-tested in node); applyBrandAccent() is the DOM +// applier — sets --color-accent/--color-on-accent at :root and recomputes on +// theme switches (VS Code mutates body attributes when the theme changes). + +const BRAND_HEX = "#FFF676"; +const CONTRAST_TARGET = 3.0; // WCAG non-text UI component minimum + +type RGB = [number, number, number]; // 0..1 + +export function parseColor(s: string): RGB | undefined { + const t = s.trim(); + const hex = t.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i)?.[1]; + if (hex) { + const h = hex.length === 3 ? [...hex].map((c) => c + c).join("") : hex; + return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255) as RGB; + } + const rgb = t.match(/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/i); + if (rgb) return [+rgb[1] / 255, +rgb[2] / 255, +rgb[3] / 255] as RGB; + return undefined; +} + +const toHex = (rgb: RGB): string => + "#" + rgb.map((c) => Math.round(Math.min(1, Math.max(0, c)) * 255).toString(16).padStart(2, "0")).join("").toUpperCase(); + +// -- OKLCH (Björn Ottosson's OKLab) ----------------------------------------- + +const lin = (c: number): number => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)); +const gam = (c: number): number => (c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055); + +export function srgbToOklch([r, g, b]: RGB): { L: number; C: number; h: number } { + const [lr, lg, lb] = [lin(r), lin(g), lin(b)]; + const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb); + const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb); + const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb); + const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s; + const a = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s; + const bb = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s; + return { L, C: Math.hypot(a, bb), h: (Math.atan2(bb, a) * 180) / Math.PI }; +} + +export function oklchToSrgb({ L, C, h }: { L: number; C: number; h: number }): RGB { + const a = C * Math.cos((h * Math.PI) / 180); + const b = C * Math.sin((h * Math.PI) / 180); + const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3; + const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3; + const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3; + return [ + gam(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s), + gam(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s), + gam(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s), + ] as RGB; +} + +/** In-gamut conversion: reduce chroma until every channel lands in sRGB. */ +function oklchToSrgbClamped(c: { L: number; C: number; h: number }): RGB { + let C = c.C; + for (let i = 0; i < 20; i++) { + const rgb = oklchToSrgb({ ...c, C }); + if (rgb.every((v) => v >= -0.001 && v <= 1.001)) return rgb; + C *= 0.85; + } + return oklchToSrgb({ ...c, C: 0 }); +} + +// -- WCAG contrast ----------------------------------------------------------- + +export function relativeLuminance([r, g, b]: RGB): number { + return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b); +} + +export function contrast(a: RGB, b: RGB): number { + const [hi, lo] = [relativeLuminance(a), relativeLuminance(b)].sort((x, y) => y - x); + return (hi + 0.05) / (lo + 0.05); +} + +// -- The solve --------------------------------------------------------------- + +export interface BrandAccent { + /** Lines: borders, focus rings, ☑ marks — solved to ≥3:1 vs the theme bg. */ + accent: string; + /** Fills: button backgrounds — stays the brand lemon on EVERY theme (black + * text on #FFF676 is ~19:1); on light themes the component's boundary + * comes from a border in `accent`, never from darkening the fill (a + * 3:1-darkened gold passes WCAG math but reads muddy under text). */ + accentFill: string; + /** Text on accentFill, contrast-picked. */ + onAccent: string; + /** True when the LINE accent shipped as the unmodified brand hex (dark themes). */ + brandExact: boolean; +} + +export function solveBrandAccent(background: string): BrandAccent { + const bg = parseColor(background) ?? parseColor("#1e1e1e")!; + const brand = parseColor(BRAND_HEX)!; + const onAccent = + contrast([0, 0, 0], brand) >= contrast([1, 1, 1], brand) ? "#000000" : "#FFFFFF"; + + if (contrast(brand, bg) >= CONTRAST_TARGET) { + return { accent: BRAND_HEX, accentFill: BRAND_HEX, onAccent, brandExact: true }; + } + // Light theme: hold brand hue+chroma, binary-search the HIGHEST lightness + // that still meets target — the closest-to-brand gold that survives. This + // is the LINE color only; the fill stays brand. + const { C, h, L: brandL } = srgbToOklch(brand); + let lo = 0.15, hi = brandL; + for (let i = 0; i < 40; i++) { + const mid = (lo + hi) / 2; + if (contrast(oklchToSrgbClamped({ L: mid, C, h }), bg) >= CONTRAST_TARGET) lo = mid; + else hi = mid; + } + const rgb = oklchToSrgbClamped({ L: lo, C, h }); + return { accent: toHex(rgb), accentFill: BRAND_HEX, onAccent, brandExact: false }; +} + +// -- DOM applier ------------------------------------------------------------- + +/** Compute the accent from the live theme and pin it at :root; re-solve when + * VS Code swaps themes (body attributes mutate). Call once per webview boot. */ +export function applyBrandAccent(): void { + const apply = (): void => { + const bg = getComputedStyle(document.body).getPropertyValue("--vscode-editor-background"); + const { accent, accentFill, onAccent } = solveBrandAccent(bg); + document.documentElement.style.setProperty("--color-accent", accent); + document.documentElement.style.setProperty("--color-accent-fill", accentFill); + document.documentElement.style.setProperty("--color-on-accent", onAccent); + }; + apply(); + new MutationObserver(apply).observe(document.body, { attributes: true }); +} diff --git a/packages/extension/src/catalog_card_webview.ts b/packages/extension/src/catalog_card_webview.ts index fa4f26ed..a8df0376 100644 --- a/packages/extension/src/catalog_card_webview.ts +++ b/packages/extension/src/catalog_card_webview.ts @@ -2,8 +2,11 @@ // (window.__CARD_DATA__, hydrated from the real run dir by the save-to-catalog // flow); the baked fixture below is the fallback for hostless debugging. +import { applyBrandAccent } from "../media/ui/brand_accent"; import { catalogcard, type CatalogEntry, type CardPulse } from "../media/ui/components/catalogcard"; +applyBrandAccent(); // theme-calculated Harmoniqs yellow (brand-wide contract) + declare function acquireVsCodeApi(): { postMessage(msg: unknown): void }; declare global { interface Window { __CARD_DATA__?: { entry: CatalogEntry; pulse?: CardPulse } } } diff --git a/packages/extension/src/inspector_webview.ts b/packages/extension/src/inspector_webview.ts index 47ed85e5..7066d0ce 100644 --- a/packages/extension/src/inspector_webview.ts +++ b/packages/extension/src/inspector_webview.ts @@ -2,8 +2,11 @@ // inspector.ts). No static markup: the view builds its own DOM from atoms/ // components; brand.css + layout.css are linked by the shell (run_inspector.ts). +import { applyBrandAccent } from "../media/ui/brand_accent"; import { createInspectorView } from "../media/ui/views/inspector"; +applyBrandAccent(); // theme-calculated Harmoniqs yellow (brand-wide contract) + declare function acquireVsCodeApi(): { postMessage(msg: unknown): void; }; diff --git a/packages/extension/test/brand_accent.test.ts b/packages/extension/test/brand_accent.test.ts new file mode 100644 index 00000000..1e18ea23 --- /dev/null +++ b/packages/extension/test/brand_accent.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from "vitest"; +import { parseColor, srgbToOklch, oklchToSrgb, contrast, solveBrandAccent } from "../media/ui/brand_accent"; + +// Theme-calculated Harmoniqs yellow: brand-exact wherever the theme allows +// (dark), contrast-solved to the closest-to-brand gold where it doesn't +// (light). Yellow is never text: on-accent is picked by contrast on the fill. + +describe("solveBrandAccent — the theme-calculated Harmoniqs yellow", () => { + it("dark themes ship the canonical hex EXACTLY", () => { + for (const bg of ["#1e1e1e", "#000000", "rgb(30, 30, 30)"]) { + const r = solveBrandAccent(bg); + expect(r.accent).toBe("#FFF676"); + expect(r.brandExact).toBe(true); + } + }); + + it("light themes get a contrast-solved gold LINE: ≥3:1, brand hue held, lightness reduced", () => { + const r = solveBrandAccent("#ffffff"); + expect(r.brandExact).toBe(false); + const solved = parseColor(r.accent)!; + expect(contrast(solved, parseColor("#ffffff")!)).toBeGreaterThanOrEqual(2.98); // binary-search tolerance + const brand = srgbToOklch(parseColor("#FFF676")!); + const got = srgbToOklch(solved); + expect(Math.abs(got.h - brand.h)).toBeLessThan(8); // hue is the brand carrier + expect(got.L).toBeLessThan(brand.L); + }); + + it("the FILL stays brand lemon on every theme — text readability beats fill-vs-bg contrast", () => { + for (const bg of ["#1e1e1e", "#ffffff", "#f3f3f3"]) { + const r = solveBrandAccent(bg); + expect(r.accentFill).toBe("#FFF676"); + // black text on the lemon fill is always high-contrast (~19:1) + expect(contrast(parseColor(r.onAccent)!, parseColor(r.accentFill)!)).toBeGreaterThan(4.5); + } + }); + + it("on-accent text is picked by contrast on the fill (black on the lemon)", () => { + expect(solveBrandAccent("#1e1e1e").onAccent).toBe("#000000"); + expect(solveBrandAccent("#ffffff").onAccent).toBe("#000000"); + }); + + it("mid-gray themes that already clear 3:1 stay brand-exact", () => { + expect(solveBrandAccent("#808080").brandExact).toBe(true); + }); + + it("parses the color formats getComputedStyle actually returns", () => { + expect(parseColor("#FFF676")).toBeDefined(); + expect(parseColor("rgb(255, 246, 118)")).toBeDefined(); + expect(parseColor("rgba(255, 246, 118, 1)")).toBeDefined(); + expect(parseColor("")).toBeUndefined(); + // garbage input falls back inside solveBrandAccent rather than throwing + expect(() => solveBrandAccent("not-a-color")).not.toThrow(); + }); + + it("OKLCH round-trips the brand hex within a hair", () => { + const rgb = parseColor("#FFF676")!; + const back = oklchToSrgb(srgbToOklch(rgb)); + back.forEach((c, i) => expect(Math.abs(c - rgb[i])).toBeLessThan(0.005)); + }); +}); From 9634f863f596a93a67bb66b7740f480c948b449a Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Mon, 6 Jul 2026 16:17:07 -0400 Subject: [PATCH 087/135] workbench: run picker + pane-ticker pause + runId-tagged controls (+ Kate's theme accent picked) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - amicode.selectRun: QuickPick over the registry (newest first; live/completed/ stopped/failed icons, iter + fidelity + script). Picking pins; "Follow latest" releases the pin via RunsManager.resumeAutoFollow() (jumps to the newest live run). Pre-UX4 utility — unblocks real multi-run testing. - Hidden panes pause their 1 Hz elapsed-strip ticker (Panel.setActive from the router's activate); resumes with a fresh render on re-activation. - Control-row messages carry their pane's runId (post-UX4 correctness; commands still resolve the selected run today). - Cherry-picked Kate's 154650c: OKLCH theme-calculated brand accent — fixes the hardcoded #FFF676 that dies on light themes (audit P0-1, extension side). 408 tests pass; typecheck + build clean. Co-Authored-By: Claude Fable 5 --- .DS_Store | Bin 6148 -> 6148 bytes packages/.DS_Store | Bin 6148 -> 6148 bytes packages/extension/.DS_Store | Bin 10244 -> 10244 bytes .../extension/media/ui/views/inspector.ts | 25 +++++++++++++----- packages/extension/package.json | 24 ++++++++++++----- packages/extension/src/extension.ts | 22 +++++++++++++++ packages/extension/src/runs_manager.ts | 15 +++++++++++ 7 files changed, 73 insertions(+), 13 deletions(-) diff --git a/.DS_Store b/.DS_Store index dca287d0a7be4cd02fbbcb94a6820b7da7c4945c..8722cf59eb5d0e3d06d5c45d00a540112860fea5 100644 GIT binary patch delta 130 zcmZoMXffEJ$`Tir%FDpOz`~%%kj{|FP?DSP;*yk;p9B=+;7yWqQu=q?5mi0~uY5s< uVQ_MOZUIma1H9%MSoNLL{UB diff --git a/packages/.DS_Store b/packages/.DS_Store index a00014fa0eefe470a64e7ce5eba693b1cf60f4f3..71e649abc416adb41ee8542e2015a8aae35676cd 100644 GIT binary patch delta 27 jcmZoMXffDuo{9N#TIl3IOcIk{F~u|8ez4h_d8Y^fq45ii delta 27 jcmZoMXffDuo{9MqQ~TsUOcIk{F~u|8D%kAJyi)`Kn}G`H diff --git a/packages/extension/.DS_Store b/packages/extension/.DS_Store index 1d83c24db3c81a1726fc51107600db74e9011f7d..e97ec0b52fea1f0840adfa8e8e7db42d7618658b 100644 GIT binary patch delta 343 zcmZn(XbIR*E68locW82hU?h{ph0V7GZ5Wvqc-tpS2`4Zvm|QKa!x%NWMp$O^5#a(R z*{4nn3=GT+#SBFZ$+`J1E=f80Nk9>f>pD_SN|OV{Bw3XDG=eARiODc3Ozsxj9jgMA zWnf__VMqmPDnZur!vLt|-*HD&`4qU3*yIZ`41<&Na|=L*GO#ghUMxP5Z;-iZ@?!Bs E0L#W&0RR91 delta 323 zcmZn(XbIR*E68jf$1=G=Fp|l9!RFh7HjK>jA2cUR2`4Z%Os*EzVPu+IBP_G|h;RWD z|LuGR1_ow^Vum7y): void; + /** Hidden panes pause their 1 Hz timing ticker (review/audit #8) — the strip + * re-renders and resumes on activation. */ + setActive(active: boolean): void; } -function createPanel(post: (msg: unknown) => void): Panel { +function createPanel(post: (msg: unknown) => void, runId?: string): Panel { const status = pill("idle"); const runLabel = text("mono small dim"); const pulse = pulseplot(IDLE_HINT); @@ -80,9 +83,9 @@ function createPanel(post: (msg: unknown) => void): Panel { // Control row — Stop / Save pulse / Open run dir. Each posts to the extension // (run_inspector.ts routes {type:"control", action} to the matching command). - const stopBtn = button("■ Stop", () => post({ type: "control", action: "stop" })); - const saveBtn = button("↓ Save pulse", () => post({ type: "control", action: "save" })); - const openBtn = button("↗ Open run dir", () => post({ type: "control", action: "open" })); + const stopBtn = button("■ Stop", () => post({ type: "control", action: "stop", runId })); + const saveBtn = button("↓ Save pulse", () => post({ type: "control", action: "save", runId })); + const openBtn = button("↗ Open run dir", () => post({ type: "control", action: "open", runId })); const controls = document.createElement("div"); controls.className = "row gap-sm wrap push-end"; controls.append(stopBtn.el, saveBtn.el, openBtn.el); @@ -127,6 +130,13 @@ function createPanel(post: (msg: unknown) => void): Panel { return { el, + setActive(active: boolean): void { + if (!active) { clearTick(); return; } + if (createdAtMs !== undefined) { + renderTiming(); + if (!tick) tick = setInterval(renderTiming, 1000); + } + }, apply(msg: Record): void { switch (msg.type) { case "runlabel": @@ -215,7 +225,7 @@ export function createInspectorView(post: (msg: unknown) => void): InspectorView const panelFor = (runId: string): Panel => { let p = panels.get(runId); if (!p) { - p = createPanel(post); + p = createPanel(post, runId); panels.set(runId, p); el.append(p.el); } @@ -225,7 +235,10 @@ export function createInspectorView(post: (msg: unknown) => void): InspectorView const activate = (runId: string): void => { active = runId; empty.el.style.display = "none"; - for (const [id, p] of panels) p.el.classList.toggle("active", id === runId); + for (const [id, p] of panels) { + p.el.classList.toggle("active", id === runId); + p.setActive(id === runId); + } if (!panels.has(runId)) panelFor(runId).el.classList.add("active"); // pane may arrive before data }; diff --git a/packages/extension/package.json b/packages/extension/package.json index 32937841..3a23c5a0 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 — opencode-backed chat + native VS Code surfaces for vault, catalog, and live solve inspection.", + "description": "Amico research IDE \u2014 opencode-backed chat + native VS Code surfaces for vault, catalog, and live solve inspection.", "version": "0.0.1", "publisher": "harmoniqs", "license": "Apache-2.0", @@ -76,6 +76,10 @@ "command": "amicode.openInspector", "title": "Amicode: Open Run Inspector" }, + { + "command": "amicode.selectRun", + "title": "Amicode: Select run to inspect\u2026" + }, { "command": "amicode.restartServer", "title": "Amicode: Restart opencode server" @@ -140,19 +144,25 @@ }, "amicode.skillRoots": { "type": "array", - "items": { "type": "string" }, + "items": { + "type": "string" + }, "default": [], "description": "Roots to search for co-located package skills (.jl/skills//SKILL.md). Empty = ~/harmoniqs/packages. First root containing a package's skills wins." }, "amicode.platformSkills": { "type": "array", - "items": { "type": "string" }, + "items": { + "type": "string" + }, "default": [], - "description": "Platform-skill names indexed from the central library (public). Empty = atoms, transmon, fluxonium, ions, bosonic. Only listed names are indexed — the library also holds process skills that must not leak." + "description": "Platform-skill names indexed from the central library (public). Empty = atoms, transmon, fluxonium, ions, bosonic. Only listed names are indexed \u2014 the library also holds process skills that must not leak." }, "amicode.skillLibraryRoots": { "type": "array", - "items": { "type": "string" }, + "items": { + "type": "string" + }, "default": [], "description": "Roots for the central platform-skill library. Empty = ~/harmoniqs/amico-plugin/skills." }, @@ -169,7 +179,7 @@ "amicode.veloce": { "type": "boolean", "default": false, - "description": "Amico Veloce: start sessions with autonomy on — auto-accept high-confidence downstream recommendations without asking. Resource gates (solve launch, hardware) always confirm; any interruption drops veloce. Off by default." + "description": "Amico Veloce: start sessions with autonomy on \u2014 auto-accept high-confidence downstream recommendations without asking. Resource gates (solve launch, hardware) always confirm; any interruption drops veloce. Off by default." } } }, @@ -217,4 +227,4 @@ "dependencies": { "yaml": "^2.9.0" } -} \ No newline at end of file +} diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index cbaf848e..870e27d3 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -289,6 +289,28 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { vscode.commands.registerCommand("amicode.openInspector", async () => { await vscode.commands.executeCommand("amicode.runInspector.focus"); }), + // Run picker (pre-UX4 utility): switch the inspector between tracked runs. + // Picking pins the selection (a background solve won't steal the view); + // "Follow latest" releases the pin and resumes newest-run auto-follow. + vscode.commands.registerCommand("amicode.selectRun", async () => { + const runs = runsManager?.runs() ?? []; + if (runs.length === 0) { void vscode.window.showInformationMessage("Amicode: no runs tracked yet."); return; } + const items: (vscode.QuickPickItem & { runId?: string; follow?: boolean })[] = [ + { label: "$(radio-tower) Follow latest", description: "auto-follow the newest run (release pin)", follow: true }, + ...[...runs].reverse().map((r) => ({ + label: `${r.phase === "live" ? "$(pulse)" : r.status === "completed" ? "$(pass)" : r.status === "stopped" ? "$(debug-pause)" : "$(error)"} ${r.runId}`, + description: [r.phase === "live" ? `live · iter ${r.latestIter ?? 0}` : r.status, + r.fidelity !== undefined ? `F=${r.fidelity.toFixed(5)}` : undefined, + r.scriptPath ? path.basename(r.scriptPath) : undefined].filter(Boolean).join(" · "), + runId: r.runId, + })), + ]; + const pick = await vscode.window.showQuickPick(items, { placeHolder: "Amicode: select the run to inspect" }); + if (!pick) return; + if (pick.follow) runsManager?.resumeAutoFollow(); + else if (pick.runId) runsManager?.selectRun(pick.runId); + await vscode.commands.executeCommand("amicode.runInspector.focus"); + }), vscode.commands.registerCommand("amicode.stopRun", () => { const dir = runsManager?.getActiveRunDir(); if (!dir) { vscode.window.showWarningMessage("Amicode: no active run to stop."); return; } diff --git a/packages/extension/src/runs_manager.ts b/packages/extension/src/runs_manager.ts index 73a8897d..6244e5bc 100644 --- a/packages/extension/src/runs_manager.ts +++ b/packages/extension/src/runs_manager.ts @@ -249,6 +249,21 @@ export class RunsManager implements vscode.Disposable { return this.selected ? this.registry.get(this.selected)?.runDir : undefined; } + /** Release an explicit pin and resume latest-follow: jump to the newest LIVE + * run if one exists (registration order = creation order), else stay put. + * Backs the run picker's "Follow latest" entry. */ + resumeAutoFollow(): void { + this.pinned = false; + const live = this.registry.all().filter((r) => r.phase === "live"); + const newest = live[live.length - 1]; + if (newest && this.selected !== newest.runId) { + // Route through selectRun for the full display path, then re-release the + // pin it sets (this is the auto lane, not an explicit selection). + this.selectRun(newest.runId); + this.pinned = false; + } + } + // -------- internal -------- private registerRun(runId: string, runDir: string, createdAt?: string, scriptPath?: string): void { From 9d2a564186ca10528b1b6d6cfe3565c32d9a13ed Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Mon, 6 Jul 2026 16:44:56 -0400 Subject: [PATCH 088/135] =?UTF-8?q?workbench:=20wire=20catalog=20what-next?= =?UTF-8?q?=20=E2=80=94=20tune/warm-start=20stage=20a=20concrete=20chat=20?= =?UTF-8?q?prompt=20(clipboard=20+=20open=20chat);=20promote=20says=20Phas?= =?UTF-8?q?e-3=20honestly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .DS_Store | Bin 6148 -> 6148 bytes packages/.DS_Store | Bin 6148 -> 6148 bytes packages/extension/.DS_Store | Bin 10244 -> 10244 bytes packages/extension/src/catalog_card_shell.ts | 18 +++++++++++++++++- 4 files changed, 17 insertions(+), 1 deletion(-) diff --git a/.DS_Store b/.DS_Store index 8722cf59eb5d0e3d06d5c45d00a540112860fea5..ca78d7b7093694918cbc422f5ec34af432c14929 100644 GIT binary patch delta 127 zcmZoMXffEJ#u9tmi-CcGg+Y%YogtH+iErGLjAQRP$c$`@o9 r1}Ep|76A1yFl@Q7xtXPyiAmwiWCeDy$$acFj0+|v&e_b)@s}R}hyZwsDbWHg#wBP_G|h;RWD(<_tBW@7*NA@n&$0Ns8P AyZ`_I delta 48 zcmZn(XbIR*C&<)yXmW#K6yt@>w*}8IGDc0V5ti9}M7V&7>8aCZGqHdC5c(V=0NBkE Awg3PC diff --git a/packages/extension/src/catalog_card_shell.ts b/packages/extension/src/catalog_card_shell.ts index 20c1f394..a631c202 100644 --- a/packages/extension/src/catalog_card_shell.ts +++ b/packages/extension/src/catalog_card_shell.ts @@ -42,7 +42,23 @@ export function registerCatalogCard(ctx: vscode.ExtensionContext): void { open.set(key, panel); panel.onDidDispose(() => open.delete(key), null, ctx.subscriptions); panel.webview.onDidReceiveMessage((m) => { - if (m?.type === "whatnext") vscode.window.showInformationMessage(`what-next → ${m.id} (stub)`); + if (m?.type !== "whatnext") return; + // Wire the save → tune → warm-start ladder to the CHAT (the agent owns the + // solve workflow): stage a concrete prompt on the clipboard and open the + // chat. Promote (team catalog) stays honestly unwired until Phase 3. + const e = data.entry; + const ident = `${e.gate ?? "gate"} on ${e.system ?? String(e.lab_id)} (run ${e.run_id}, F=${Number(e.fidelity).toFixed(5)})`; + if (m.id === "warmstart" || m.id === "tune") { + const prompt = m.id === "warmstart" + ? `Warm-start a new solve from the banked pulse of ${ident}: load ${runDir}/pulse.jld2 as the initial trajectory (load_traj), keep the same formulation, and run it.` + : `Tune the solve for ${ident}: start from ${runDir}/pulse.jld2, keep the formulation but ask me which weights/params (Q, R, T, N, max_iter) to adjust before launching.`; + void vscode.env.clipboard.writeText(prompt).then(async () => { + await vscode.commands.executeCommand("amicode.openChat"); + void vscode.window.showInformationMessage(`Amicode: ${m.id} prompt copied — paste into the chat to launch.`); + }); + } else if (m.id === "promote") { + void vscode.window.showInformationMessage("Amicode: team-catalog promotion isn't wired yet (Phase 3) — the pulse stays in your local bank."); + } }); const uri = (...p: string[]) => panel.webview.asWebviewUri(vscode.Uri.joinPath(ctx.extensionUri, ...p)); const nonce = Math.random().toString(36).slice(2); From 96f5e44c79d2dd6719accb09eb1cdf9cf476c130 Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Mon, 6 Jul 2026 16:49:41 -0400 Subject: [PATCH 089/135] =?UTF-8?q?workbench:=20chat=20theme=20bridge=20?= =?UTF-8?q?=E2=80=94=20iframe=20boots=20with=20=3FcolorScheme=3D=20from=20?= =?UTF-8?q?the=20editor=20theme;=20live=20re-theme=20via=20onDidChangeActi?= =?UTF-8?q?veColorTheme=20=E2=86=92=20two-lane=20relay=20(origin-pinned)?= =?UTF-8?q?=20=E2=86=92=20app's=20setColorScheme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .DS_Store | Bin 6148 -> 6148 bytes packages/.DS_Store | Bin 6148 -> 6148 bytes packages/extension/.DS_Store | Bin 10244 -> 10244 bytes packages/extension/src/chat_panel.ts | 39 ++++++++++++++++++++++++--- 4 files changed, 35 insertions(+), 4 deletions(-) diff --git a/.DS_Store b/.DS_Store index ca78d7b7093694918cbc422f5ec34af432c14929..369a9522a2b46aa25ed89a2f6de2d7b0ab83af5a 100644 GIT binary patch delta 127 zcmZoMXffEJ#uEEuAp-*g3xgg*IzuKyNp8N2OHxjL5>SjIupwxP>c8WTsPZXzw delta 127 zcmZoMXffEJ#u9tmi-CcGg+Y%YogtH+iErGLjAQRP$c$`@o9 r1}Ep|76A1yFl@Q7xtXPyiAmwiWCeDy$$acFj0+|v&e_b)@s}R}hyZwsDbWHg#wBP_G|h;RWD(<_tBW@7*NA@n&$0Ns8P AyZ`_I diff --git a/packages/extension/src/chat_panel.ts b/packages/extension/src/chat_panel.ts index f82c40a5..747a662d 100644 --- a/packages/extension/src/chat_panel.ts +++ b/packages/extension/src/chat_panel.ts @@ -20,13 +20,28 @@ const BRIDGE_ALLOWED_COMMANDS: ReadonlySet = new Set([ "amicode.openInspector", ]); + +/** VS Code theme kind → the fork app's ColorScheme. */ +function themeKindToScheme(kind: vscode.ColorThemeKind): "light" | "dark" { + return kind === vscode.ColorThemeKind.Light || kind === vscode.ColorThemeKind.HighContrastLight ? "light" : "dark"; +} + export class ChatPanel { private static current?: ChatPanel; private readonly disposables: vscode.Disposable[] = []; + + private constructor(private readonly panel: vscode.WebviewPanel, opencodeUrl: URL) { this.panel.webview.html = this.renderHtml(opencodeUrl); this.panel.onDidDispose(() => this.dispose(), null, this.disposables); + // Live theme bridge: editor theme changes flow extension → outer relay → + // iframe → the app's setColorScheme (boot theme rides ?colorScheme=). + vscode.window.onDidChangeActiveColorTheme( + (t) => void this.panel.webview.postMessage({ source: "amicode", kind: "theme", colorScheme: themeKindToScheme(t.kind) }), + null, + this.disposables, + ); this.panel.webview.onDidReceiveMessage( (msg) => { // iframe → extension command bridge: the opencode "Amico" palette group @@ -88,6 +103,11 @@ export class ChatPanel { "connect-src 'self'", ].join("; "); const origin = JSON.stringify(opencodeUrl.origin); + // Boot theme: the app's preload reads ?colorScheme= and seeds its scheme + // storage, so the chat opens in the EDITOR's theme (prefers-color-scheme + // inside the webview iframe reports the OS, not VS Code). + const framed = new URL(opencodeUrl.href); + framed.searchParams.set("colorScheme", themeKindToScheme(vscode.window.activeColorTheme.kind)); return /* html */ ` @@ -100,7 +120,7 @@ export class ChatPanel { - + `; - })); + }, + ), + ); } /** Build the card's data from real run artifacts. Returns undefined when the * dir lacks the promote-shaped basics. Shape mirrors the webview's CARD_DATA. * Exported for tests. */ -export function hydrateFromRunDir(runDir: string, systemName?: string, tags?: string[]): { entry: Record; pulse?: { meta: unknown; record: unknown } } | undefined { +export function hydrateFromRunDir( + runDir: string, + systemName?: string, + tags?: string[], +): { entry: Record; pulse?: { meta: unknown; record: unknown } } | undefined { const manifest = readTomlSafe(path.join(runDir, "run.toml")); const result = readTomlSafe(path.join(runDir, "result.toml")); if (!manifest || !result) return undefined; @@ -110,11 +131,15 @@ export function hydrateFromRunDir(runDir: string, systemName?: string, tags?: st let meta: PulseEvent | undefined, newest: PulseEvent | undefined; for (const line of fs.readFileSync(path.join(runDir, "run.log"), "utf8").split("\n")) { const e = stream.onLine(line); - if (e?.type === "meta") { meta = e; newest = undefined; } - else if (e?.type === "record") newest = e; + if (e?.type === "meta") { + meta = e; + newest = undefined; + } else if (e?.type === "record") newest = e; } if (meta?.type === "meta" && newest?.type === "record") pulse = { meta: meta.meta, record: newest.record }; - } catch { /* no run.log → card renders the not-hydrated state */ } + } catch { + /* no run.log → card renders the not-hydrated state */ + } return { entry, pulse }; } diff --git a/packages/extension/src/catalog_card_webview.ts b/packages/extension/src/catalog_card_webview.ts index a8df0376..ab937b8b 100644 --- a/packages/extension/src/catalog_card_webview.ts +++ b/packages/extension/src/catalog_card_webview.ts @@ -5,10 +5,14 @@ import { applyBrandAccent } from "../media/ui/brand_accent"; import { catalogcard, type CatalogEntry, type CardPulse } from "../media/ui/components/catalogcard"; -applyBrandAccent(); // theme-calculated Harmoniqs yellow (brand-wide contract) +applyBrandAccent(); // theme-calculated Harmoniqs yellow (brand-wide contract) declare function acquireVsCodeApi(): { postMessage(msg: unknown): void }; -declare global { interface Window { __CARD_DATA__?: { entry: CatalogEntry; pulse?: CardPulse } } } +declare global { + interface Window { + __CARD_DATA__?: { entry: CatalogEntry; pulse?: CardPulse }; + } +} // Grounded in packages/schema/test/fixtures/valid/catalog-entry.toml; the // `proposed` block is NOT schema — it renders visibly marked (field-selection @@ -26,17 +30,27 @@ const ENTRY: CatalogEntry = { }; const PULSE: CardPulse = { - meta: { drives: 2, knots: 25, labels: ["u_1", "u_2"], bounds: [[-0.2, 0.2], [-0.2, 0.2]] }, + meta: { + drives: 2, + knots: 25, + labels: ["u_1", "u_2"], + bounds: [ + [-0.2, 0.2], + [-0.2, 0.2], + ], + }, record: { iter: 60, dt: 0.4, values: [ - [0.012, 0.048, 0.096, 0.141, 0.172, 0.184, 0.176, 0.149, 0.108, 0.058, - 0.006, -0.043, -0.084, -0.113, -0.128, -0.127, -0.111, -0.083, -0.047, -0.008, - 0.028, 0.055, 0.068, 0.062, 0.033], - [-0.021, -0.052, -0.079, -0.096, -0.100, -0.089, -0.065, -0.031, 0.009, 0.049, - 0.084, 0.109, 0.121, 0.118, 0.100, 0.070, 0.032, -0.009, -0.048, -0.079, - -0.098, -0.102, -0.089, -0.061, -0.024], + [ + 0.012, 0.048, 0.096, 0.141, 0.172, 0.184, 0.176, 0.149, 0.108, 0.058, 0.006, -0.043, -0.084, -0.113, -0.128, + -0.127, -0.111, -0.083, -0.047, -0.008, 0.028, 0.055, 0.068, 0.062, 0.033, + ], + [ + -0.021, -0.052, -0.079, -0.096, -0.1, -0.089, -0.065, -0.031, 0.009, 0.049, 0.084, 0.109, 0.121, 0.118, 0.1, + 0.07, 0.032, -0.009, -0.048, -0.079, -0.098, -0.102, -0.089, -0.061, -0.024, + ], ], }, }; @@ -51,7 +65,7 @@ const vscodeApi = acquireVsCodeApi(); const injected = window.__CARD_DATA__; const card = catalogcard(injected?.entry ?? ENTRY, { pulse: injected ? injected.pulse : PULSE, - siblings: injected ? [] : SIBLINGS, // sibling entries need a store — none yet on the real path + siblings: injected ? [] : SIBLINGS, // sibling entries need a store — none yet on the real path onAction: (id) => vscodeApi.postMessage({ type: "whatnext", id }), }); document.body.style.padding = "16px"; diff --git a/packages/extension/src/chat_panel.ts b/packages/extension/src/chat_panel.ts index d70d32b5..94cf6ee7 100644 --- a/packages/extension/src/chat_panel.ts +++ b/packages/extension/src/chat_panel.ts @@ -24,7 +24,6 @@ const BRIDGE_ALLOWED_COMMANDS: ReadonlySet = new Set([ "workbench.action.showCommands", ]); - /** VS Code theme kind → the fork app's ColorScheme. */ function themeKindToScheme(kind: vscode.ColorThemeKind): "light" | "dark" { return kind === vscode.ColorThemeKind.Light || kind === vscode.ColorThemeKind.HighContrastLight ? "light" : "dark"; @@ -34,15 +33,21 @@ export class ChatPanel { private static current?: ChatPanel; private readonly disposables: vscode.Disposable[] = []; - - - private constructor(private readonly panel: vscode.WebviewPanel, opencodeUrl: URL) { + private constructor( + private readonly panel: vscode.WebviewPanel, + opencodeUrl: URL, + ) { this.panel.webview.html = this.renderHtml(opencodeUrl); this.panel.onDidDispose(() => this.dispose(), null, this.disposables); // Live theme bridge: editor theme changes flow extension → outer relay → // iframe → the app's setColorScheme (boot theme rides ?colorScheme=). vscode.window.onDidChangeActiveColorTheme( - (t) => void this.panel.webview.postMessage({ source: "amicode", kind: "theme", colorScheme: themeKindToScheme(t.kind) }), + (t) => + void this.panel.webview.postMessage({ + source: "amicode", + kind: "theme", + colorScheme: themeKindToScheme(t.kind), + }), null, this.disposables, ); @@ -59,7 +64,7 @@ export class ChatPanel { (msg as { source?: unknown }).source === "amicode" && (msg as { kind?: unknown }).kind === "open-external" && typeof (msg as { url?: unknown }).url === "string" && - /^https:\/\//i.test((msg as { url: string }).url) // scheme is case-insensitive (RFC 3986) + /^https:\/\//i.test((msg as { url: string }).url) // scheme is case-insensitive (RFC 3986) ) { // target=_blank/window.open are dead inside the framed app — open // https links via the editor (system browser). https-only. @@ -79,9 +84,16 @@ export class ChatPanel { // panel must not be able to sample the clipboard in the background — // reads only answer while the user can see the chat. if (!this.panel.visible) return; - void vscode.env.clipboard.readText().then((text) => - this.panel.webview.postMessage({ source: "amicode", kind: "clipboard", nonce: (msg as { nonce?: string }).nonce, text }), - ); + void vscode.env.clipboard + .readText() + .then((text) => + this.panel.webview.postMessage({ + source: "amicode", + kind: "clipboard", + nonce: (msg as { nonce?: string }).nonce, + text, + }), + ); return; } if ( @@ -107,19 +119,14 @@ export class ChatPanel { ChatPanel.current.panel.reveal(vscode.ViewColumn.One); return ChatPanel.current; } - const panel = vscode.window.createWebviewPanel( - "amicode.chat", - "Amicode Chat", - vscode.ViewColumn.One, - { - enableScripts: true, - retainContextWhenHidden: true, - // The chat lives at localhost; we let the webview reach out via http://127.0.0.1 - // through normal browser networking. No localResourceRoots needed for the iframe - // itself — we only host one extension-local asset (the loading splash). - localResourceRoots: [vscode.Uri.joinPath(ctx.extensionUri, "media")], - }, - ); + const panel = vscode.window.createWebviewPanel("amicode.chat", "Amicode Chat", vscode.ViewColumn.One, { + enableScripts: true, + retainContextWhenHidden: true, + // The chat lives at localhost; we let the webview reach out via http://127.0.0.1 + // through normal browser networking. No localResourceRoots needed for the iframe + // itself — we only host one extension-local asset (the loading splash). + localResourceRoots: [vscode.Uri.joinPath(ctx.extensionUri, "media")], + }); panel.iconPath = vscode.Uri.joinPath(ctx.extensionUri, "media", "amico.svg"); ChatPanel.current = new ChatPanel(panel, opencodeUrl); return ChatPanel.current; @@ -189,7 +196,9 @@ export class ChatPanel { dispose(): void { for (const d of this.disposables) { - try { d.dispose(); } catch {} + try { + d.dispose(); + } catch {} } this.disposables.length = 0; if (ChatPanel.current === this) ChatPanel.current = undefined; diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 1b60fd30..92682a59 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -39,7 +39,6 @@ let opencodeReadyUrl: URL | undefined; * and the distillNow command read it lazily (undefined = distiller disabled). */ let distillerSetup: DistillerSetup | undefined; - /** Run dirs with a cooperative stop in flight (escalation timer armed) — a * second Stop click must not stack a second dialog. */ const pendingStops = new Set(); @@ -55,7 +54,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // 1. UI surfaces const trees = registerTrees(ctx); registerRunInspector(ctx); - registerCatalogCard(ctx); // #47 dev scaffold — card opens via the save-to-catalog flow + registerCatalogCard(ctx); // #47 dev scaffold — card opens via the save-to-catalog flow ctx.subscriptions.push( // #47 session catalog: record the save (workspaceState + tree), then open // the card. Both prompts (demo replay, live promote) route through here. @@ -79,7 +78,11 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { prompt: "Tags (comma-separated, optional)", placeHolder: "e.g. high-R, T=8, fast", }); - const tags = tagsRaw?.split(",").map((t) => t.trim()).filter(Boolean) ?? []; + const tags = + tagsRaw + ?.split(",") + .map((t) => t.trim()) + .filter(Boolean) ?? []; await trees.catalog.save({ run_id: String(manifest.run_id ?? path.basename(runDir)), runDir, @@ -146,9 +149,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { const opencodeProject = prepareOpencodeProject({ agentsSrc: path.resolve(ctx.extensionPath, "AGENTS.md"), templateSrc: path.resolve(ctx.extensionPath, "templates", "solve_template.jl"), - juliaProject: resolveJuliaProject( - vscode.workspace.getConfiguration("amicode").get("juliaProject", ""), - ), + juliaProject: resolveJuliaProject(vscode.workspace.getConfiguration("amicode").get("juliaProject", "")), skillRoots: cfgArr("skillRoots"), platformSkills: cfgArr("platformSkills"), skillLibraryRoots: cfgArr("skillLibraryRoots"), @@ -188,7 +189,9 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // gets the Julia project from AGENTS.md (substituted at session-copy time) // and passes it as `--project`. PATH just needs to resolve the launcher. if (amicoRunBinDir === undefined) { - opencodeChannel.appendLine(`[boot] WARNING: amico-run launcher not found — chat can author but solves won't run (build amico-run or check the VSIX)`); + opencodeChannel.appendLine( + `[boot] WARNING: amico-run launcher not found — chat can author but solves won't run (build amico-run or check the VSIX)`, + ); } // opencode owns the LLM credential (0.3): amico injects NO key into the // spawn env — opencode resolves its provider from its own env / config / @@ -209,7 +212,16 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // config, so the model/provider are preserved. This is what makes the // chat actually author + run solves instead of behaving like vanilla // opencode (the session cwd is the workspace, not opencodeProject.projectDir). - OPENCODE_CONFIG_CONTENT: buildOpencodeConfigContent(opencodeProject.agentsPath, opencodeProject.templatePath, runsRoot, undefined, undefined, opencodeProject.skillPaths, opencodeProject.skillsStageDir, opencodeProject.vaultDir), + OPENCODE_CONFIG_CONTENT: buildOpencodeConfigContent( + opencodeProject.agentsPath, + opencodeProject.templatePath, + runsRoot, + undefined, + undefined, + opencodeProject.skillPaths, + opencodeProject.skillsStageDir, + opencodeProject.vaultDir, + ), }, channel: opencodeChannel, }); @@ -232,7 +244,9 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { model: vscode.workspace.getConfiguration("amicode").get("distillerModel", "opencode/big-pickle"), }; initDistillerTransport(distillerSetup); - opencodeChannel.appendLine(`[boot] distiller armed (vault: ${opencodeProject.vaultDir}, model: ${distillerSetup.model})`); + opencodeChannel.appendLine( + `[boot] distiller armed (vault: ${opencodeProject.vaultDir}, model: ${distillerSetup.model})`, + ); } catch (e) { opencodeChannel.appendLine(`[boot] distiller transport failed (memory disabled this session): ${e}`); distillerSetup = undefined; @@ -280,7 +294,9 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // openOrReveal as undefined (or reveal a panel bound to a stale server). const readyUrl = opencodeReadyUrl; if (!readyUrl) { - vscode.window.showWarningMessage("Amicode: opencode server isn't ready yet. Check the 'Amicode — opencode' output channel."); + vscode.window.showWarningMessage( + "Amicode: opencode server isn't ready yet. Check the 'Amicode — opencode' output channel.", + ); return; } // Creds gate — opencode serves HTTP 200 (→ "ready") even with zero @@ -303,18 +319,33 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // "Follow latest" releases the pin and resumes newest-run auto-follow. vscode.commands.registerCommand("amicode.selectRun", async () => { const runs = runsManager?.runs() ?? []; - if (runs.length === 0) { void vscode.window.showInformationMessage("Amicode: no runs tracked yet."); return; } + if (runs.length === 0) { + void vscode.window.showInformationMessage("Amicode: no runs tracked yet."); + return; + } const items: (vscode.QuickPickItem & { runId?: string; follow?: boolean })[] = [ - { label: "$(radio-tower) Follow latest", description: "auto-follow the newest run (release pin)", follow: true }, + { + label: "$(radio-tower) Follow latest", + description: "auto-follow the newest run (release pin)", + follow: true, + }, ...[...runs].reverse().map((r) => { // A "live" run whose log has gone cold is stalled — the picker must // agree with the status bar, not advertise a wedge as live. const stalled = r.phase === "live" && stopPlan(r.runDir) === "force"; return { label: `${r.phase === "live" ? (stalled ? "$(warning)" : "$(pulse)") : r.status === "completed" ? "$(pass)" : r.status === "stopped" ? "$(debug-pause)" : "$(error)"} ${r.runId}`, - description: [r.phase === "live" ? (stalled ? `stalled · iter ${r.latestIter ?? 0}` : `live · iter ${r.latestIter ?? 0}`) : r.status, - r.fidelity !== undefined ? `F=${r.fidelity.toFixed(5)}` : undefined, - r.scriptPath ? path.basename(r.scriptPath) : undefined].filter(Boolean).join(" · "), + description: [ + r.phase === "live" + ? stalled + ? `stalled · iter ${r.latestIter ?? 0}` + : `live · iter ${r.latestIter ?? 0}` + : r.status, + r.fidelity !== undefined ? `F=${r.fidelity.toFixed(5)}` : undefined, + r.scriptPath ? path.basename(r.scriptPath) : undefined, + ] + .filter(Boolean) + .join(" · "), runId: r.runId, }; }), @@ -327,12 +358,15 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { }), vscode.commands.registerCommand("amicode.stopRun", async () => { const dir = runsManager?.getActiveRunDir(); - if (!dir) { vscode.window.showWarningMessage("Amicode: no active run to stop."); return; } + if (!dir) { + vscode.window.showWarningMessage("Amicode: no active run to stop."); + return; + } // Escalation ladder: cooperative STOP only works while a solver is alive // to poll it — a stalled run gets the hard path immediately, a healthy // one gets a grace window and then an explicit Force-stop offer (never a // silent kill: one long Ipopt iteration can look wedged). - const label = path.basename(dir); // every toast names the run — stop A, start B, a nameless dialog at t+120s reads as "B is wedged" + const label = path.basename(dir); // every toast names the run — stop A, start B, a nameless dialog at t+120s reads as "B is wedged" if (pendingStops.has(dir)) { vscode.window.showInformationMessage(`Amicode: stop already in progress for ${label}.`); return; @@ -344,19 +378,25 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { } // Best-effort: a deleted run dir throws ENOENT here, and the force path // below must still be reachable to clear the registry/UI entry. - try { writeStopFile(dir); } catch { /* dir gone — force path handles it */ } + try { + writeStopFile(dir); + } catch { + /* dir gone — force path handles it */ + } if (plan === "force") { await forceStop(dir); vscode.window.showInformationMessage(`Amicode: run ${label} was stalled — force-stopped and marked aborted.`); return; } - vscode.window.showInformationMessage(`Amicode: stop requested for ${label} — the solve will halt at the next iteration.`); + vscode.window.showInformationMessage( + `Amicode: stop requested for ${label} — the solve will halt at the next iteration.`, + ); const mtimeAtStop = runLogMtime(dir); pendingStops.add(dir); const timer = setTimeout(async () => { pendingStops.delete(dir); - if (stopPlan(dir) === "already-finished") return; // cooperative stop landed - if (runLogMtime(dir) !== mtimeAtStop) return; // still iterating — let it reach the callback + if (stopPlan(dir) === "already-finished") return; // cooperative stop landed + if (runLogMtime(dir) !== mtimeAtStop) return; // still iterating — let it reach the callback const pick = await vscode.window.showWarningMessage( `Amicode: run ${label} hasn't responded to stop.`, "Force stop", @@ -367,24 +407,38 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { vscode.window.showInformationMessage(`Amicode: run ${label} force-stopped and marked aborted.`); } }, 120_000); - ctx.subscriptions.push({ dispose: () => { clearTimeout(timer); pendingStops.delete(dir); } }); + ctx.subscriptions.push({ + dispose: () => { + clearTimeout(timer); + pendingStops.delete(dir); + }, + }); }), vscode.commands.registerCommand("amicode.openRunDir", async () => { const dir = runsManager?.getActiveRunDir(); - if (!dir) { vscode.window.showWarningMessage("Amicode: no active run."); return; } + if (!dir) { + vscode.window.showWarningMessage("Amicode: no active run."); + return; + } // revealFileInOS wants a FILE (a bare directory errors on macOS) — reveal // the manifest, which every run dir has from birth; fall back to opening // the folder externally if the reveal still fails. const manifest = path.join(dir, "run.toml"); try { - await vscode.commands.executeCommand("revealFileInOS", vscode.Uri.file(fs.existsSync(manifest) ? manifest : dir)); + await vscode.commands.executeCommand( + "revealFileInOS", + vscode.Uri.file(fs.existsSync(manifest) ? manifest : dir), + ); } catch { await vscode.env.openExternal(vscode.Uri.file(dir)); } }), vscode.commands.registerCommand("amicode.savePulse", async () => { const dir = runsManager?.getActiveRunDir(); - if (!dir) { vscode.window.showWarningMessage("Amicode: no active run."); return; } + if (!dir) { + vscode.window.showWarningMessage("Amicode: no active run."); + return; + } const catalog = catalogPulsesDir(); const picks = [catalog ? "Save to catalog" : undefined, "Save to file…"].filter(Boolean) as string[]; const choice = await vscode.window.showQuickPick(picks, { title: "Save pulse" }); @@ -399,7 +453,10 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { filters: { JLD2: ["jld2"] }, defaultUri: vscode.Uri.file(path.join(dir, "pulse.jld2")), }); - if (uri) { savePulseTo(dir, uri.fsPath); vscode.window.showInformationMessage("Amicode: pulse saved."); } + if (uri) { + savePulseTo(dir, uri.fsPath); + vscode.window.showInformationMessage("Amicode: pulse saved."); + } } } catch (e) { vscode.window.showErrorMessage(`Amicode: ${(e as Error).message}`); @@ -450,7 +507,8 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { if (fid >= 0.99) { const choice = await vscode.window.showInformationMessage( `Amicode: demo solve converged (F=${fid.toFixed(4)}). Save to catalog?`, - "Save to catalog", "Not now", + "Save to catalog", + "Not now", ); if (choice === "Save to catalog") await vscode.commands.executeCommand("amicode.catalog.save", runDir); } @@ -472,4 +530,3 @@ export function deactivate(): void { runsManager?.dispose(); statusBar?.dispose(); } - diff --git a/packages/extension/src/inspector_webview.ts b/packages/extension/src/inspector_webview.ts index 7066d0ce..1f9fdfa4 100644 --- a/packages/extension/src/inspector_webview.ts +++ b/packages/extension/src/inspector_webview.ts @@ -5,7 +5,7 @@ import { applyBrandAccent } from "../media/ui/brand_accent"; import { createInspectorView } from "../media/ui/views/inspector"; -applyBrandAccent(); // theme-calculated Harmoniqs yellow (brand-wide contract) +applyBrandAccent(); // theme-calculated Harmoniqs yellow (brand-wide contract) declare function acquireVsCodeApi(): { postMessage(msg: unknown): void; diff --git a/packages/extension/src/log_tailer.ts b/packages/extension/src/log_tailer.ts index 319f81c4..15749863 100644 --- a/packages/extension/src/log_tailer.ts +++ b/packages/extension/src/log_tailer.ts @@ -8,7 +8,12 @@ import * as vscode from "vscode"; // live run's run.log PLUS one on the append-only runs/index (discovery). // =========================================================================== -export interface LogTailerOptions { path: string; channel: vscode.OutputChannel; onLine: (line: string) => void; startOffset?: number } +export interface LogTailerOptions { + path: string; + channel: vscode.OutputChannel; + onLine: (line: string) => void; + startOffset?: number; +} export class LogTailer implements vscode.Disposable { private watcher?: fs.FSWatcher; @@ -42,7 +47,11 @@ export class LogTailer implements vscode.Disposable { dispose(): void { this.disposed = true; if (this.pollTimer) clearTimeout(this.pollTimer); - try { this.watcher?.close(); } catch { /* noop */ } + try { + this.watcher?.close(); + } catch { + /* noop */ + } this.watcher = undefined; } @@ -69,10 +78,17 @@ export class LogTailer implements vscode.Disposable { private drain(): void { if (this.disposed) return; let fd: number; - try { fd = fs.openSync(this.opts.path, "r"); } catch { return; } + try { + fd = fs.openSync(this.opts.path, "r"); + } catch { + return; + } try { const size = fs.fstatSync(fd).size; - if (size < this.offset) { this.offset = 0; this.buf = ""; } + if (size < this.offset) { + this.offset = 0; + this.buf = ""; + } if (size === this.offset) return; const chunk = Buffer.allocUnsafe(size - this.offset); const read = fs.readSync(fd, chunk, 0, chunk.length, this.offset); @@ -82,10 +98,18 @@ export class LogTailer implements vscode.Disposable { while ((nl = this.buf.indexOf("\n")) >= 0) { const line = this.buf.slice(0, nl); this.buf = this.buf.slice(nl + 1); - try { this.opts.onLine(line); } catch (e) { this.opts.channel.appendLine(`[runs] onLine threw: ${String(e)}`); } + try { + this.opts.onLine(line); + } catch (e) { + this.opts.channel.appendLine(`[runs] onLine threw: ${String(e)}`); + } } } finally { - try { fs.closeSync(fd); } catch { /* noop */ } + try { + fs.closeSync(fd); + } catch { + /* noop */ + } } } } diff --git a/packages/extension/src/run_controls.ts b/packages/extension/src/run_controls.ts index a12bb8c8..1fada267 100644 --- a/packages/extension/src/run_controls.ts +++ b/packages/extension/src/run_controls.ts @@ -27,7 +27,11 @@ export const STALL_AFTER_MS = 10 * 60 * 1000; /** run.log mtime, or undefined before the log exists. */ export function runLogMtime(runDir: string): number | undefined { - try { return fs.statSync(path.join(runDir, "run.log")).mtimeMs; } catch { return undefined; } + try { + return fs.statSync(path.join(runDir, "run.log")).mtimeMs; + } catch { + return undefined; + } } /** What stopping this run requires right now: nothing (already terminal), the @@ -52,7 +56,11 @@ export function runScriptPath(runDir: string): string | undefined { try { const m = /^script_path\s*=\s*(".*")\s*$/m.exec(fs.readFileSync(path.join(runDir, "run.toml"), "utf8")); if (!m) return undefined; - try { return JSON.parse(m[1]) as string; } catch { return m[1].slice(1, -1); } + try { + return JSON.parse(m[1]) as string; + } catch { + return m[1].slice(1, -1); + } } catch { return undefined; } @@ -68,7 +76,11 @@ function lsofPath(): string { } function realpathOr(p: string): string { - try { return fs.realpathSync(p); } catch { return path.resolve(p); } + try { + return fs.realpathSync(p); + } catch { + return path.resolve(p); + } } /** PIDs belonging to THIS run: command line references the run's solve script @@ -84,7 +96,11 @@ export function findRunPids( execFileSync(cmd, args, { encoding: "utf8", maxBuffer: 8 * 1024 * 1024 }), ): number[] { let psOut = ""; - try { psOut = exec("/bin/ps", ["-A", "-o", "pid=,args="]); } catch { return []; } + try { + psOut = exec("/bin/ps", ["-A", "-o", "pid=,args="]); + } catch { + return []; + } const candidates: number[] = []; for (const line of psOut.split("\n")) { const m = /^\s*(\d+)\s+(.*)$/.exec(line); @@ -116,8 +132,13 @@ export function forceFinalize(runDir: string): void { fs.writeFileSync(tmp, 'status = "aborted"\nexit_code = -1\n'); fs.renameSync(tmp, path.join(runDir, "FINISHED")); try { - fs.appendFileSync(path.join(runDir, "run.log"), "\nAMICODE_ABORTED force-stopped by user (solver not responding)\n"); - } catch { /* log breadcrumb is best-effort */ } + fs.appendFileSync( + path.join(runDir, "run.log"), + "\nAMICODE_ABORTED force-stopped by user (solver not responding)\n", + ); + } catch { + /* log breadcrumb is best-effort */ + } } /** The hard path: TERM any live solver process provably tied to this run dir, @@ -126,14 +147,28 @@ export function forceFinalize(runDir: string): void { * fully dead run — the pid scan just comes back empty. */ export async function forceStop(runDir: string): Promise { const pids = findRunPids(runDir, runScriptPath(runDir)); - for (const pid of pids) { try { process.kill(pid, "SIGTERM"); } catch { /* already gone */ } } + for (const pid of pids) { + try { + process.kill(pid, "SIGTERM"); + } catch { + /* already gone */ + } + } if (pids.length > 0) { await new Promise((r) => setTimeout(r, 1500)); // Re-prove ownership before the KILL sweep — a pid that exited on TERM can // be reused by an unrelated process inside the window, and the two-key // safety property is the whole point of this module. const survivors = new Set(findRunPids(runDir, runScriptPath(runDir))); - for (const pid of pids) { if (survivors.has(pid)) { try { process.kill(pid, "SIGKILL"); } catch { /* exited */ } } } + for (const pid of pids) { + if (survivors.has(pid)) { + try { + process.kill(pid, "SIGKILL"); + } catch { + /* exited */ + } + } + } } // The orchestrator (cwd ≠ run dir, outside the kill set) may observe the // child's death and write its own truthful FINISHED (e.g. failed/143) during @@ -141,7 +176,9 @@ export async function forceStop(runDir: string): Promise { // Best-effort on a DELETED run dir (nothing to finalize, nothing to crash). try { if (!fs.existsSync(path.join(runDir, "FINISHED"))) forceFinalize(runDir); - } catch { /* run dir removed underneath us */ } + } catch { + /* run dir removed underneath us */ + } } /** Copy the run's pulse.jld2 to an absolute destination path. */ diff --git a/packages/extension/src/run_dir_reader.ts b/packages/extension/src/run_dir_reader.ts index 44ff2b83..79f908e6 100644 --- a/packages/extension/src/run_dir_reader.ts +++ b/packages/extension/src/run_dir_reader.ts @@ -34,7 +34,12 @@ export const AMICODE_PULSE_META_RE = new RegExp( String.raw`^AMICODE_PULSE_META\s+drives=(\d+)\s+knots=(\d+)\s+labels=((?:"[^",]*")(?:,"[^",]*")*)\s+bounds=(${NUM}:${NUM}(?:,${NUM}:${NUM})*)\s*$`, ); -export interface PulseMeta { drives: number; knots: number; labels: string[]; bounds: [number, number][] } +export interface PulseMeta { + drives: number; + knots: number; + labels: string[]; + bounds: [number, number][]; +} /** Parse an AMICODE_PULSE_META line. Returns undefined for anything malformed. */ export function parsePulseMetaLine(line: string): PulseMeta | undefined { @@ -52,7 +57,11 @@ export const AMICODE_PULSE_RE = new RegExp( String.raw`^AMICODE_PULSE\s+iter=(\d+)\s+dt=(${NUM})\s+a=(${NUM}(?:,${NUM})*(?:;${NUM}(?:,${NUM})*)*)\s*$`, ); -export interface PulseRecord { iter: number; dt: number; values: number[][] } +export interface PulseRecord { + iter: number; + dt: number; + values: number[][]; +} /** Parse an AMICODE_PULSE record line. Returns undefined for anything malformed. */ export function parsePulseRecordLine(line: string): PulseRecord | undefined { @@ -62,9 +71,7 @@ export function parsePulseRecordLine(line: string): PulseRecord | undefined { return { iter: parseInt(m[1], 10), dt: parseAmicoNum(m[2]), values }; } -export type PulseEvent = - | { type: "meta"; meta: PulseMeta } - | { type: "record"; record: PulseRecord }; +export type PulseEvent = { type: "meta"; meta: PulseMeta } | { type: "record"; record: PulseRecord }; /** Cross-line policy for the pulse stream — the single gate BOTH delivery * paths (replay ingest, live tail) feed lines through. Policy (#66 AC4): @@ -95,7 +102,7 @@ export class PulseStream { } const record = parsePulseRecordLine(line); if (record) { - if (!this.meta) return undefined; // record before meta — nothing to interpret it against + if (!this.meta) return undefined; // record before meta — nothing to interpret it against if (record.values.length !== this.meta.drives) return undefined; if (record.values.some((d) => d.length !== this.meta!.knots)) return undefined; return { type: "record", record }; @@ -104,13 +111,27 @@ export class PulseStream { } } -export interface IterRecord { iter: number; f_val: number; inf_pr: number; inf_du: number } +export interface IterRecord { + iter: number; + f_val: number; + inf_pr: number; + inf_du: number; +} /** Terminal completion, built by readTerminalState and flowed WHOLE to every * consumer (never exploded into positional args mid-pipe) — the #84 funnel. * Additive contract fields join HERE + readTerminalState and reach all paths * by construction: #81's `formulation?` next, then #64 hashing / #41 usage. */ -export interface RunCompletion { runId: string; runDir: string; status: RunStatus; fidelity?: number } -export interface PromoteInfo { runId: string; runDir: string; fidelity: number } +export interface RunCompletion { + runId: string; + runDir: string; + status: RunStatus; + fidelity?: number; +} +export interface PromoteInfo { + runId: string; + runDir: string; + fidelity: number; +} /** Where ingestRunDir routes its findings. The live impl carries the * newest-wins + promote-once guards; the test impl is plain spies. */ @@ -132,12 +153,17 @@ export class SinkDedup { if (iter > this.latestIter) this.latestIter = iter; } /** Highest iteration seen. */ - get high(): number { return this.latestIter; } + get high(): number { + return this.latestIter; + } } export function readTomlSafe(fp: string): Record | undefined { - try { return parse(fs.readFileSync(fp, "utf8")) as Record; } - catch { return undefined; } + try { + return parse(fs.readFileSync(fp, "utf8")) as Record; + } catch { + return undefined; + } } /** spec C promote gate: rendering is tier-blind, PROMOTION is not. A `free`-tier @@ -151,8 +177,11 @@ export function readTomlSafe(fp: string): Record | undefined { export type PromoteEligibility = "eligible" | "pending_verification" | "suppressed"; export function promoteEligibility(runDir: string): PromoteEligibility { let spec: Record | undefined; - try { spec = JSON.parse(fs.readFileSync(path.join(runDir, "solvespec.json"), "utf8")); } - catch { return "eligible"; } // no/unreadable spec → a bare run, unchanged behavior + try { + spec = JSON.parse(fs.readFileSync(path.join(runDir, "solvespec.json"), "utf8")); + } catch { + return "eligible"; + } // no/unreadable spec → a bare run, unchanged behavior if (spec?.tier !== "free") return "eligible"; const verification = readTomlSafe(path.join(runDir, "verification.toml")); if (!verification) return "pending_verification"; @@ -218,12 +247,16 @@ export function readTerminalState( * appended after the read are tailed) and no overlap (already-replayed lines). */ export function ingestRunDir(runDir: string, sink: RunSink, promoteThreshold = 0.99): number { const manifest = readTomlSafe(path.join(runDir, "run.toml")); - if (!manifest || !validateManifest(manifest).ok) return 0; // no valid manifest → not a run dir yet + if (!manifest || !validateManifest(manifest).ok) return 0; // no valid manifest → not a run dir yet const runId = String(manifest.run_id); // run.log body → iter records (replay; the live tailer handles appended lines) let logBody: string | undefined; - try { logBody = fs.readFileSync(path.join(runDir, "run.log"), "utf8"); } catch { /* none yet */ } + try { + logBody = fs.readFileSync(path.join(runDir, "run.log"), "utf8"); + } catch { + /* none yet */ + } let logBytes = 0; if (logBody) { logBytes = Buffer.byteLength(logBody, "utf8"); @@ -235,9 +268,20 @@ export function ingestRunDir(runDir: string, sink: RunSink, promoteThreshold = 0 let newestPulse: PulseEvent | undefined; for (const line of logBody.split("\n")) { const m = AMICODE_ITER_RE.exec(line); - if (m) { sink.iter({ iter: +m[1], f_val: parseAmicoNum(m[2]), inf_pr: parseAmicoNum(m[3]), inf_du: parseAmicoNum(m[4]) }); continue; } + if (m) { + sink.iter({ + iter: +m[1], + f_val: parseAmicoNum(m[2]), + inf_pr: parseAmicoNum(m[3]), + inf_du: parseAmicoNum(m[4]), + }); + continue; + } const e = pulses.onLine(line); - if (e?.type === "meta") { pulseMeta = e; newestPulse = undefined; } // new meta governs; stale records don't cross it + if (e?.type === "meta") { + pulseMeta = e; + newestPulse = undefined; + } // new meta governs; stale records don't cross it else if (e?.type === "record") newestPulse = e; } if (pulseMeta) sink.pulse(pulseMeta); @@ -251,7 +295,7 @@ export function ingestRunDir(runDir: string, sink: RunSink, promoteThreshold = 0 if (!t) return logBytes; sink.run({ runId, runDir, ...t }); if (t.status === "completed" && t.fidelity !== undefined && t.fidelity >= promoteThreshold) { - const eligibility = promoteEligibility(runDir); // tier-blind render, tier-aware promote (spec C) + const eligibility = promoteEligibility(runDir); // tier-blind render, tier-aware promote (spec C) if (eligibility === "eligible") sink.promote({ runId, runDir, fidelity: t.fidelity }); else console.warn(`[amico] promote skipped for ${runId}: free-tier verification ${eligibility}`); } diff --git a/packages/extension/src/run_inspector.ts b/packages/extension/src/run_inspector.ts index 6c9050bc..91495f5b 100644 --- a/packages/extension/src/run_inspector.ts +++ b/packages/extension/src/run_inspector.ts @@ -22,10 +22,10 @@ const REFRESH_INTERVAL_MS = 200; // 5 Hz cap on pulse-record refresh (per run) /** Timing payload for the elapsed/rate/ETA strip. */ export interface TimingInfo { - createdAtMs?: number; // run.toml created_at → live elapsed base - maxIter?: number; // parsed from the run script → ETA - wallSeconds?: number; // result.toml wall_seconds → frozen elapsed on finish - terminal?: boolean; // true once the run is finished + createdAtMs?: number; // run.toml created_at → live elapsed base + maxIter?: number; // parsed from the run script → ETA + wallSeconds?: number; // result.toml wall_seconds → frozen elapsed on finish + terminal?: boolean; // true once the run is finished } let INSPECTOR: InspectorView | undefined; @@ -39,9 +39,9 @@ interface PaneBuffer { warming: boolean; completion?: { status: string; fidelity?: number }; pulseMeta?: PulseMeta; - pulseRecord?: PulseRecord; // newest record (throttle coalesces to this) + pulseRecord?: PulseRecord; // newest record (throttle coalesces to this) iterRecord?: { iter: number; f_val: number; kkt_error: number; eq_viol: number; ineq_viol: number; rho: number }; - timing?: TimingInfo; // elapsed/rate/ETA strip state (his run_timing UI) + timing?: TimingInfo; // elapsed/rate/ETA strip state (his run_timing UI) pulseTimer?: NodeJS.Timeout; pendingPulse?: PulseRecord; } @@ -81,10 +81,16 @@ class InspectorView implements vscode.WebviewViewProvider { // the target run from the manager's selected run. const msgSub = view.webview.onDidReceiveMessage((msg: { type?: string; action?: string }) => { if (msg?.type !== "control") return; - const cmd = ({ stop: "amicode.stopRun", save: "amicode.savePulse", open: "amicode.openRunDir" } as Record)[msg.action ?? ""]; + const cmd = ( + { stop: "amicode.stopRun", save: "amicode.savePulse", open: "amicode.openRunDir" } as Record + )[msg.action ?? ""]; if (cmd) void vscode.commands.executeCommand(cmd); }); - view.onDidDispose(() => { this.view = undefined; this.clearAllTimers(); msgSub.dispose(); }); + view.onDidDispose(() => { + this.view = undefined; + this.clearAllTimers(); + msgSub.dispose(); + }); // S36 replay: rebuild EVERY pane from its buffer (not just the active one), // so switching to a background run after reopen shows its state too. Per @@ -104,7 +110,13 @@ class InspectorView implements vscode.WebviewViewProvider { if (p.pulseMeta) view.webview.postMessage({ type: "pulsemeta", runId: rid, ...p.pulseMeta }); if (p.pulseRecord) view.webview.postMessage({ type: "pulse", runId: rid, ...p.pulseRecord }); if (p.iterRecord) view.webview.postMessage({ type: "iteration", runId: rid, ...p.iterRecord, t_post: Date.now() }); - if (p.completion) view.webview.postMessage({ type: "completed", runId: rid, status: p.completion.status, fidelity: p.completion.fidelity }); + if (p.completion) + view.webview.postMessage({ + type: "completed", + runId: rid, + status: p.completion.status, + fidelity: p.completion.fidelity, + }); } // -------- public surface used by RunsManager (all runId-keyed) -------- @@ -112,8 +124,15 @@ class InspectorView implements vscode.WebviewViewProvider { postIterationRecord(runId: string, rec: { iter: number; f_val: number; inf_pr: number; inf_du: number }): void { const p = this.paneFor(runId); p.warming = false; - p.iterRecord = { iter: rec.iter, f_val: rec.f_val, kkt_error: rec.inf_du, eq_viol: rec.inf_pr, ineq_viol: 0, rho: 1.0 }; - if (!this.view) return; // buffered above; reopen replays it + p.iterRecord = { + iter: rec.iter, + f_val: rec.f_val, + kkt_error: rec.inf_du, + eq_viol: rec.inf_pr, + ineq_viol: 0, + rho: 1.0, + }; + if (!this.view) return; // buffered above; reopen replays it this.view.webview.postMessage({ type: "iteration", runId, ...p.iterRecord, t_post: Date.now() }); } @@ -163,9 +182,12 @@ class InspectorView implements vscode.WebviewViewProvider { return; } // record - p.pulseRecord = e.record; // newest wins for reopen replay + p.pulseRecord = e.record; // newest wins for reopen replay if (!this.view) return; - if (p.pulseTimer) { p.pendingPulse = e.record; return; } // window open — coalesce + if (p.pulseTimer) { + p.pendingPulse = e.record; + return; + } // window open — coalesce this.view.webview.postMessage({ type: "pulse", runId, ...e.record }); p.pulseTimer = setTimeout(() => { p.pulseTimer = undefined; @@ -194,7 +216,7 @@ class InspectorView implements vscode.WebviewViewProvider { /** Make `runId` the visible pane (1.3 selection seam). Buffered until the * webview materializes; resolveWebviewView replays it last. */ activate(runId: string): void { - this.paneFor(runId); // ensure a pane exists even before any data + this.paneFor(runId); // ensure a pane exists even before any data this.activeRunId = runId; if (this.view) this.view.webview.postMessage({ type: "activate", runId }); } @@ -204,22 +226,23 @@ class InspectorView implements vscode.WebviewViewProvider { // default so a starting solve never steals focus; the status-bar item and // the explicit open command (which bypasses reveal) remain available. if (!autoOpenEnabled()) return; - vscode.commands.executeCommand("amicode.runInspector.focus") - .then(undefined, () => undefined); + vscode.commands.executeCommand("amicode.runInspector.focus").then(undefined, () => undefined); } // -------- internal -------- private clearAllTimers(): void { for (const p of this.panes.values()) { - if (p.pulseTimer) { clearTimeout(p.pulseTimer); p.pulseTimer = undefined; } + if (p.pulseTimer) { + clearTimeout(p.pulseTimer); + p.pulseTimer = undefined; + } p.pendingPulse = undefined; } } private renderHtml(webview: vscode.Webview): string { - const uri = (...parts: string[]) => - webview.asWebviewUri(vscode.Uri.joinPath(this.ctx.extensionUri, ...parts)); + const uri = (...parts: string[]) => webview.asWebviewUri(vscode.Uri.joinPath(this.ctx.extensionUri, ...parts)); const nonce = newNonce(); // The view is TS-composed (media/ui/views/inspector.ts → dist bundle): the // script builds its own DOM from atoms/components and injects their styles diff --git a/packages/extension/src/runs_manager.ts b/packages/extension/src/runs_manager.ts index f27feaa3..fd2fad87 100644 --- a/packages/extension/src/runs_manager.ts +++ b/packages/extension/src/runs_manager.ts @@ -8,8 +8,18 @@ import { parseMaxIter } from "./run_timing"; import type { StatusBarManager } from "./status_bar"; import type { RunStatus } from "./types"; import { - AMICODE_ITER_RE, ingestRunDir, readTerminalState, readTomlSafe, parseAmicoNum, PulseStream, SinkDedup, - type IterRecord, type PulseEvent, type RunCompletion, type PromoteInfo, type RunSink, + AMICODE_ITER_RE, + ingestRunDir, + readTerminalState, + readTomlSafe, + parseAmicoNum, + PulseStream, + SinkDedup, + type IterRecord, + type PulseEvent, + type RunCompletion, + type PromoteInfo, + type RunSink, } from "./run_dir_reader"; import { STALL_AFTER_MS } from "./run_controls"; @@ -82,10 +92,17 @@ class RunPipeline implements vscode.Disposable { dirWatcher?: fs.FSWatcher; tailer?: LogTailer; - constructor(readonly runId: string, readonly runDir: string) {} + constructor( + readonly runId: string, + readonly runDir: string, + ) {} dispose(): void { - try { this.dirWatcher?.close(); } catch { /* noop */ } + try { + this.dirWatcher?.close(); + } catch { + /* noop */ + } this.tailer?.dispose(); this.dirWatcher = undefined; this.tailer = undefined; @@ -133,7 +150,7 @@ export class RunsManager implements vscode.Disposable { }, }); this.booting = true; - this.indexTailer.start(); // synchronous initial drain — boot replay + this.indexTailer.start(); // synchronous initial drain — boot replay this.booting = false; this.rootWatcher = fs.watch(this.opts.runsRoot, { persistent: false }, (_e, filename) => { if (filename === "index") this.indexTailer?.poke(); @@ -142,7 +159,9 @@ export class RunsManager implements vscode.Disposable { // host (e.g. the watched dir deleted). The poll backstop keeps us live. this.rootWatcher.on("error", (e) => this.opts.channel.appendLine(`[runs] root watch error: ${String(e)}`)); this.poll = setInterval(() => this.tick(), RunsManager.POLL_MS); - this.opts.channel.appendLine(`[runs] watching ${this.opts.runsRoot}/index (fs.watch + ${RunsManager.POLL_MS}ms poll)`); + this.opts.channel.appendLine( + `[runs] watching ${this.opts.runsRoot}/index (fs.watch + ${RunsManager.POLL_MS}ms poll)`, + ); } /** Poll backstop — macOS FSEvents coalesces/drops events, so re-poke the @@ -160,16 +179,33 @@ export class RunsManager implements vscode.Disposable { // wedges mid-watch would keep "running · iter N" forever without this. // DOWNGRADE only (never stamps "running": warming/iter flow owns that). const sel = this.selected ? this.registry.get(this.selected) : undefined; - if (sel && sel.phase !== "finished" && sel.latestIter !== undefined && this.liveStatus(sel.runDir) === "stalled") { - this.opts.statusBar?.setRun({ runId: sel.runId, outputDir: sel.runDir, startedAt: 0, status: "stalled", latestIter: sel.latestIter }); + if ( + sel && + sel.phase !== "finished" && + sel.latestIter !== undefined && + this.liveStatus(sel.runDir) === "stalled" + ) { + this.opts.statusBar?.setRun({ + runId: sel.runId, + outputDir: sel.runDir, + startedAt: 0, + status: "stalled", + latestIter: sel.latestIter, + }); } - } catch { /* transient fs race — next tick retries */ } + } catch { + /* transient fs race — next tick retries */ + } } dispose(): void { if (this.poll) clearInterval(this.poll); this.poll = undefined; - try { this.rootWatcher?.close(); } catch { /* noop */ } + try { + this.rootWatcher?.close(); + } catch { + /* noop */ + } this.rootWatcher = undefined; this.indexTailer?.dispose(); this.indexTailer = undefined; @@ -189,7 +225,9 @@ export class RunsManager implements vscode.Disposable { this.registerRun(e.runId, e.runDir); return; } - this.opts.channel.appendLine(`[runs] scheduler ${e.kind} ${e.runId ?? e.queueId}${e.message ? `: ${e.message}` : ""}`); + this.opts.channel.appendLine( + `[runs] scheduler ${e.kind} ${e.runId ?? e.queueId}${e.message ? `: ${e.message}` : ""}`, + ); }); } @@ -213,7 +251,7 @@ export class RunsManager implements vscode.Disposable { const ins = getInspector(); ins?.reveal(); ins?.setRunLabel(runId, runId); - ins?.activate(runId); // 1.3: switch the visible pane + ins?.activate(runId); // 1.3: switch the visible pane const p = this.pipelines.get(runId); if (p) { // FINISHED may have landed inside the poll window — complete it NOW, @@ -223,16 +261,22 @@ export class RunsManager implements vscode.Disposable { // (routeIter/completeRun keep it current from here). const r = this.registry.get(runId)!; this.opts.statusBar?.setRun({ - runId, outputDir: r.runDir, startedAt: 0, + runId, + outputDir: r.runDir, + startedAt: 0, status: r.phase === "finished" ? (r.status ?? "completed") : this.liveStatus(r.runDir), - latestIter: r.latestIter, fidelity: r.fidelity, + latestIter: r.latestIter, + fidelity: r.fidelity, }); } else { // Never fanned (no pipeline) — display replay from disk (late-join safe). // Promote inside the replay stays guarded by promotedRuns, so // re-selecting a finished run never re-pops the prompt. - try { ingestRunDir(rec.runDir, this.displaySink(rec), this.opts.promoteThreshold ?? 0.99); } - catch (err) { this.opts.channel.appendLine(`[runs] replay failed: ${(err as Error).message}`); } + try { + ingestRunDir(rec.runDir, this.displaySink(rec), this.opts.promoteThreshold ?? 0.99); + } catch (err) { + this.opts.channel.appendLine(`[runs] replay failed: ${(err as Error).message}`); + } } // Fresh/live run → Julia warming up. Disk-checked (FINISHED may exist while // the registry still says live); the host's setWarmingUp also no-ops if the @@ -299,7 +343,15 @@ export class RunsManager implements vscode.Disposable { if (t) { // Terminal at discovery: record it (status/fidelity for the registry) but // render nothing and never re-pop the promote prompt (β launch parity). - this.registry.register({ runId, runDir, createdAt, scriptPath, phase: "finished", status: t.status, fidelity: t.fidelity }); + this.registry.register({ + runId, + runDir, + createdAt, + scriptPath, + phase: "finished", + status: t.status, + fidelity: t.fidelity, + }); this.promotedRuns.add(runId); return; } @@ -320,8 +372,16 @@ export class RunsManager implements vscode.Disposable { const manifest = readTomlSafe(path.join(runDir, "run.toml")); const createdAtMs = manifest?.created_at ? Date.parse(String(manifest.created_at)) : NaN; let maxIter: number | undefined; - try { if (manifest?.script_path) maxIter = parseMaxIter(fs.readFileSync(String(manifest.script_path), "utf8")); } catch { /* script gone */ } - getInspector()?.postTiming(runId, { createdAtMs: Number.isFinite(createdAtMs) ? createdAtMs : undefined, maxIter, terminal: false }); + try { + if (manifest?.script_path) maxIter = parseMaxIter(fs.readFileSync(String(manifest.script_path), "utf8")); + } catch { + /* script gone */ + } + getInspector()?.postTiming(runId, { + createdAtMs: Number.isFinite(createdAtMs) ? createdAtMs : undefined, + maxIter, + terminal: false, + }); } // Auto-follow BEFORE the replay (β latest-follow parity: a newly REGISTERED @@ -334,7 +394,7 @@ export class RunsManager implements vscode.Disposable { if (follow && this.selected !== runId) { this.selected = runId; const ins = getInspector(); - if (!this.booting) ins?.reveal(); // boot replay must not steal focus + if (!this.booting) ins?.reveal(); // boot replay must not steal focus ins?.setRunLabel(runId, runId); ins?.activate(runId); } @@ -343,8 +403,11 @@ export class RunsManager implements vscode.Disposable { // high-water, fans history runId-tagged, and yields the byte offset the // live tail starts from. let logBytes = 0; - try { logBytes = ingestRunDir(runDir, this.pipelineSink(p), this.opts.promoteThreshold ?? 0.99); } - catch (err) { this.opts.channel.appendLine(`[runs] replay failed: ${(err as Error).message}`); } + try { + logBytes = ingestRunDir(runDir, this.pipelineSink(p), this.opts.promoteThreshold ?? 0.99); + } catch (err) { + this.opts.channel.appendLine(`[runs] replay failed: ${(err as Error).message}`); + } // FINISHED landed between the existsSync check and the replay (rare race): // completeRun already tore the pipeline down (and — selection was assigned @@ -363,7 +426,15 @@ export class RunsManager implements vscode.Disposable { channel: this.opts.channel, onLine: (line) => { const m = AMICODE_ITER_RE.exec(line); - if (m) { this.routeIter(p, { iter: +m[1], f_val: parseAmicoNum(m[2]), inf_pr: parseAmicoNum(m[3]), inf_du: parseAmicoNum(m[4]) }); return; } + if (m) { + this.routeIter(p, { + iter: +m[1], + f_val: parseAmicoNum(m[2]), + inf_pr: parseAmicoNum(m[3]), + inf_du: parseAmicoNum(m[4]), + }); + return; + } const e = p.pulses.onLine(line); if (e) this.routePulse(p.runId, e); }, @@ -386,8 +457,11 @@ export class RunsManager implements vscode.Disposable { iter: (rec: IterRecord) => this.routeIter(p, rec), // A FINISHED that landed between the existsSync check and this replay — // rare race; treat exactly like a live completion (fans out + promotes). - run: (c: RunCompletion) => this.completeRun(c), // whole object — see completeRun (#84 seam) - pulse: (e: PulseEvent) => { if (e.type === "meta") p.pulses.arm(e.meta); this.routePulse(p.runId, e); }, + run: (c: RunCompletion) => this.completeRun(c), // whole object — see completeRun (#84 seam) + pulse: (e: PulseEvent) => { + if (e.type === "meta") p.pulses.arm(e.meta); + this.routePulse(p.runId, e); + }, promote: (info: PromoteInfo) => this.promptPromote(info), }; } @@ -406,12 +480,25 @@ export class RunsManager implements vscode.Disposable { // A finished run's replay must not stamp running/stalled per line — its // completion event (below) sets the bar exactly once at the end. if (this.registry.get(rid)?.phase !== "finished") { - this.opts.statusBar?.setRun({ runId: rid, outputDir: rec.runDir, startedAt: 0, status: this.liveStatus(rec.runDir), latestIter: r.iter }); + this.opts.statusBar?.setRun({ + runId: rid, + outputDir: rec.runDir, + startedAt: 0, + status: this.liveStatus(rec.runDir), + latestIter: r.iter, + }); } }, run: (c: RunCompletion) => { getInspector()?.postCompletion(rid, c.status, c.fidelity); - this.opts.statusBar?.setRun({ runId: rid, outputDir: rec.runDir, startedAt: 0, status: c.status, latestIter: this.registry.get(rid)?.latestIter, fidelity: c.fidelity }); + this.opts.statusBar?.setRun({ + runId: rid, + outputDir: rec.runDir, + startedAt: 0, + status: c.status, + latestIter: this.registry.get(rid)?.latestIter, + fidelity: c.fidelity, + }); }, pulse: (e: PulseEvent) => { if (e.type === "meta") p?.pulses.arm(e.meta); @@ -421,7 +508,6 @@ export class RunsManager implements vscode.Disposable { }; } - /** "running" only if run.log is actually moving. A FINISHED-less run whose * log has been silent >10 min is wedged (OOM, killed host) — never let a * boot replay of its old iter lines stamp "running · iter N" on the status @@ -436,7 +522,9 @@ export class RunsManager implements vscode.Disposable { let val: "running" | "stalled" = "running"; try { if (now - fs.statSync(path.join(runDir, "run.log")).mtimeMs > STALL_AFTER_MS) val = "stalled"; - } catch { /* no run.log yet — brand-new run, trust the tailer */ } + } catch { + /* no run.log yet — brand-new run, trust the tailer */ + } this.liveStatusCache.set(runDir, { at: now, val }); return val; } @@ -450,7 +538,13 @@ export class RunsManager implements vscode.Disposable { getInspector()?.postIterationRecord(p.runId, rec); if (this.selected === p.runId) { // Live status-bar update — "running · iter N" as it solves (#5 AC3). - this.opts.statusBar?.setRun({ runId: p.runId, outputDir: p.runDir, startedAt: 0, status: this.liveStatus(p.runDir), latestIter: rec.iter }); + this.opts.statusBar?.setRun({ + runId: p.runId, + outputDir: p.runDir, + startedAt: 0, + status: this.liveStatus(p.runDir), + latestIter: rec.iter, + }); } } @@ -464,7 +558,7 @@ export class RunsManager implements vscode.Disposable { if (p.finishedSeen) return; if (!fs.existsSync(path.join(p.runDir, "FINISHED"))) return; const t = this.readTerminal(p.runDir); - if (!t) return; // torn/invalid FINISHED — next tick retries + if (!t) return; // torn/invalid FINISHED — next tick retries p.finishedSeen = true; this.completeRun({ runId: p.runId, runDir: p.runDir, ...t }); } @@ -480,12 +574,14 @@ export class RunsManager implements vscode.Disposable { * re-plumbed per path. Consumers cherry-pick at the leaf, not mid-pipe. */ private completeRun(c: RunCompletion): void { const rec = this.registry.get(c.runId); - if (!rec || rec.phase === "finished") return; // idempotent (watch + poll can both fire) + if (!rec || rec.phase === "finished") return; // idempotent (watch + poll can both fire) this.registry.markFinished(c.runId, c.status, c.fidelity); const p = this.pipelines.get(c.runId); p?.dispose(); this.pipelines.delete(c.runId); - this.opts.channel.appendLine(`[runs] ${c.runId} ${c.status}${c.fidelity !== undefined ? ` F=${c.fidelity.toFixed(6)}` : ""}`); + this.opts.channel.appendLine( + `[runs] ${c.runId} ${c.status}${c.fidelity !== undefined ? ` F=${c.fidelity.toFixed(6)}` : ""}`, + ); if (c.status !== "completed") this.opts.channel.appendLine(`[runs] see ${path.join(rec.runDir, "run.log")}`); // Terminal state to the inspector for EVERY run (its pane's badge stops // saying "running" even in the background); status bar for the selected run. @@ -497,7 +593,14 @@ export class RunsManager implements vscode.Disposable { getInspector()?.postTiming(c.runId, { wallSeconds, terminal: true }); this.opts.onRunFinished?.({ runId: c.runId, runDir: rec.runDir, status: c.status }); if (this.selected === c.runId) { - this.opts.statusBar?.setRun({ runId: c.runId, outputDir: rec.runDir, startedAt: 0, status: c.status, latestIter: rec.latestIter, fidelity: c.fidelity }); + this.opts.statusBar?.setRun({ + runId: c.runId, + outputDir: rec.runDir, + startedAt: 0, + status: c.status, + latestIter: rec.latestIter, + fidelity: c.fidelity, + }); } if (c.status === "completed" && c.fidelity !== undefined && c.fidelity >= (this.opts.promoteThreshold ?? 0.99)) { this.promptPromote({ runId: c.runId, runDir: rec.runDir, fidelity: c.fidelity }); @@ -520,7 +623,8 @@ export class RunsManager implements vscode.Disposable { void (async () => { const choice = await vscode.window.showInformationMessage( `Amicode: solve converged (F=${info.fidelity.toFixed(4)}). Promote pulse to catalog?`, - "Yes — promote", "No — keep local only", + "Yes — promote", + "No — keep local only", ); if (choice === "Yes — promote") { // #47: record in the session catalog + open the card (store persistence diff --git a/packages/extension/src/status_bar.ts b/packages/extension/src/status_bar.ts index f7e64e92..4cb1efb1 100644 --- a/packages/extension/src/status_bar.ts +++ b/packages/extension/src/status_bar.ts @@ -11,17 +11,30 @@ export function statusBarLabel(serverReady: boolean, run?: RunState): { text: st if (!serverReady) return { text: "$(loading~spin) Amicode (booting)", tooltip: "Spawning opencode server…" }; const dir = run?.outputDir ?? ""; switch (run?.status) { - case "starting": return { text: "$(sync~spin) Amicode · warming…", tooltip: `Julia warming up in ${dir}` }; - case "running": return { text: `$(gear~spin) Amicode · iter ${run.latestIter ?? "—"}`, tooltip: `Solve running in ${dir}` }; - case "stalled": return { text: "$(warning) Amicode · stalled", tooltip: `No progress for 10+ min in ${dir} — run may be wedged (OOM?)` }; + case "starting": + return { text: "$(sync~spin) Amicode · warming…", tooltip: `Julia warming up in ${dir}` }; + case "running": + return { text: `$(gear~spin) Amicode · iter ${run.latestIter ?? "—"}`, tooltip: `Solve running in ${dir}` }; + case "stalled": + return { + text: "$(warning) Amicode · stalled", + tooltip: `No progress for 10+ min in ${dir} — run may be wedged (OOM?)`, + }; case "completed": { const f = run.fidelity; - return { text: `$(check) Amicode · F=${f !== undefined ? f.toFixed(4) : "—"}`, tooltip: `Last solve completed in ${dir}` }; + return { + text: `$(check) Amicode · F=${f !== undefined ? f.toFixed(4) : "—"}`, + tooltip: `Last solve completed in ${dir}`, + }; } - case "stopped": return { text: "$(circle-slash) Amicode · stopped", tooltip: `Solve stopped in ${dir}` }; - case "failed": return { text: "$(error) Amicode · solve failed", tooltip: `Solve failed in ${dir} — see run.log` }; - case "aborted": return { text: "$(circle-slash) Amicode · aborted", tooltip: `Solve aborted in ${dir}` }; - default: return { text: "$(comment-discussion) Amicode", tooltip: "Open the Run Inspector" }; + case "stopped": + return { text: "$(circle-slash) Amicode · stopped", tooltip: `Solve stopped in ${dir}` }; + case "failed": + return { text: "$(error) Amicode · solve failed", tooltip: `Solve failed in ${dir} — see run.log` }; + case "aborted": + return { text: "$(circle-slash) Amicode · aborted", tooltip: `Solve aborted in ${dir}` }; + default: + return { text: "$(comment-discussion) Amicode", tooltip: "Open the Run Inspector" }; } } diff --git a/packages/extension/src/trees.ts b/packages/extension/src/trees.ts index 56b385d2..2c21a099 100644 --- a/packages/extension/src/trees.ts +++ b/packages/extension/src/trees.ts @@ -21,7 +21,9 @@ class PlaceholderTree implements vscode.TreeDataProvider { getChildren(): string[] { return [this.hint]; } - refresh(): void { this._onDidChange.fire(); } + refresh(): void { + this._onDidChange.fire(); + } } /** A saved session-catalog entry (#47). Promote-shaped, mirrors the card's @@ -62,7 +64,10 @@ export class SessionCatalogTree implements vscode.TreeDataProvider { - await this.ctx.workspaceState.update(CATALOG_KEY, this.entries().filter((e) => e.run_id !== run_id)); + await this.ctx.workspaceState.update( + CATALOG_KEY, + this.entries().filter((e) => e.run_id !== run_id), + ); this._onDidChange.fire(); } @@ -77,8 +82,12 @@ export class SessionCatalogTree implements vscode.TreeDataProvider ({ dispose() {} }), }, revealCount: 0, - reveal() { this.revealCount += 1; }, - onDidDispose(cb: () => void, _thisArg?: unknown, _subs?: unknown) { disposeCbs.push(cb); return { dispose() {} }; }, - dispose() { for (const cb of disposeCbs) cb(); }, + reveal() { + this.revealCount += 1; + }, + onDidDispose(cb: () => void, _thisArg?: unknown, _subs?: unknown) { + disposeCbs.push(cb); + return { dispose() {} }; + }, + dispose() { + for (const cb of disposeCbs) cb(); + }, }; }, }; @@ -28,7 +35,11 @@ const registeredCommands = new Map unknown>(); export const commands = { registerCommand: (id: string, fn: (...a: unknown[]) => unknown) => { registeredCommands.set(id, fn); - return { dispose() { registeredCommands.delete(id); } }; + return { + dispose() { + registeredCommands.delete(id); + }, + }; }, executeCommand: (id: string, ...a: unknown[]) => Promise.resolve(registeredCommands.get(id)?.(...a)), }; @@ -40,7 +51,7 @@ export const workspace = { export const Uri = { file: (p: string) => ({ fsPath: p, toString: () => p }), joinPath: (base: { fsPath?: string } | string, ...parts: string[]) => { - const root = typeof base === "string" ? base : base.fsPath ?? ""; + const root = typeof base === "string" ? base : (base.fsPath ?? ""); const full = [root, ...parts].join("/"); return { fsPath: full, toString: () => full }; }, @@ -57,6 +68,9 @@ export class TreeItem { description?: string; tooltip?: string; command?: unknown; - constructor(public label: string, public collapsibleState?: number) {} + constructor( + public label: string, + public collapsibleState?: number, + ) {} } export const TreeItemCollapsibleState = { None: 0, Collapsed: 1, Expanded: 2 }; diff --git a/packages/extension/test/brand_accent.test.ts b/packages/extension/test/brand_accent.test.ts index 1e18ea23..d4916fac 100644 --- a/packages/extension/test/brand_accent.test.ts +++ b/packages/extension/test/brand_accent.test.ts @@ -18,10 +18,10 @@ describe("solveBrandAccent — the theme-calculated Harmoniqs yellow", () => { const r = solveBrandAccent("#ffffff"); expect(r.brandExact).toBe(false); const solved = parseColor(r.accent)!; - expect(contrast(solved, parseColor("#ffffff")!)).toBeGreaterThanOrEqual(2.98); // binary-search tolerance + expect(contrast(solved, parseColor("#ffffff")!)).toBeGreaterThanOrEqual(2.98); // binary-search tolerance const brand = srgbToOklch(parseColor("#FFF676")!); const got = srgbToOklch(solved); - expect(Math.abs(got.h - brand.h)).toBeLessThan(8); // hue is the brand carrier + expect(Math.abs(got.h - brand.h)).toBeLessThan(8); // hue is the brand carrier expect(got.L).toBeLessThan(brand.L); }); diff --git a/packages/extension/test/catalog_shell.test.ts b/packages/extension/test/catalog_shell.test.ts index 1e0155b6..78f5c285 100644 --- a/packages/extension/test/catalog_shell.test.ts +++ b/packages/extension/test/catalog_shell.test.ts @@ -12,16 +12,22 @@ import { SessionCatalogTree, type SessionCatalogEntry } from "../src/trees"; function stageRun(opts: { pulseLines?: string; gate?: string; system?: string }): string { const dir = mkdtempSync(join(tmpdir(), "card-run-")); - writeFileSync(join(dir, "run.toml"), + writeFileSync( + join(dir, "run.toml"), 'schema_version = "1"\nrun_id = "r20260703-000000Z-cafe"\nlab_id = "default"\nscript_path = "/s.jl"\n' + - 'lab = "default"\ncreated_at = "2026-07-03T00:00:00Z"\norchestrator_version = "0.1.0"\n[julia]\nbinary = "julia"\n'); + 'lab = "default"\ncreated_at = "2026-07-03T00:00:00Z"\norchestrator_version = "0.1.0"\n[julia]\nbinary = "julia"\n', + ); const params = [ opts.system ? `system = "${opts.system}"` : "", opts.gate ? `gate = "${opts.gate}"` : "", "levels = 3", - ].filter(Boolean).join("\n"); - writeFileSync(join(dir, "result.toml"), - `schema_version = "1"\nfidelity = 0.9998\niterations = 60\nwall_seconds = 41.5\n[params]\n${params}\n`); + ] + .filter(Boolean) + .join("\n"); + writeFileSync( + join(dir, "result.toml"), + `schema_version = "1"\nfidelity = 0.9998\niterations = 60\nwall_seconds = 41.5\n[params]\n${params}\n`, + ); if (opts.pulseLines !== undefined) writeFileSync(join(dir, "run.log"), opts.pulseLines); return dir; } @@ -29,7 +35,8 @@ function stageRun(opts: { pulseLines?: string; gate?: string; system?: string }) describe("hydrateFromRunDir — entry from real run artifacts", () => { it("maps identity, fidelity, params (gate lifted to top level), proposed block, and the newest pulse", () => { const dir = stageRun({ - gate: "X", system: "transmon", + gate: "X", + system: "transmon", pulseLines: 'AMICODE_PULSE_META drives=1 knots=2 labels="u_1" bounds=-0.2:0.2\n' + "AMICODE_PULSE iter=1 dt=0.2 a=0.1,0.2\n" + @@ -44,7 +51,7 @@ describe("hydrateFromRunDir — entry from real run artifacts", () => { proposed: { iterations: 60, wall_seconds: 41.5 }, }); expect((data.entry.params as Record).system).toBe("transmon"); - expect(data.pulse).toMatchObject({ record: { iter: 2 } }); // newest record, not the first + expect(data.pulse).toMatchObject({ record: { iter: 2 } }); // newest record, not the first }); it("degrades: no run.log → no pulse; missing result.toml → undefined", () => { @@ -64,13 +71,13 @@ describe("registerCatalogCard — reveal-or-create panel dedupe", () => { await vscode.commands.executeCommand("amicode.catalogCard.open", dir); await vscode.commands.executeCommand("amicode.catalogCard.open", dir); - expect(spy).toHaveBeenCalledTimes(1); // one panel per run_id + expect(spy).toHaveBeenCalledTimes(1); // one panel per run_id const panel = spy.mock.results[0].value as { revealCount: number; dispose: () => void }; - expect(panel.revealCount).toBe(1); // second click re-focuses + expect(panel.revealCount).toBe(1); // second click re-focuses - panel.dispose(); // user closes the tab + panel.dispose(); // user closes the tab await vscode.commands.executeCommand("amicode.catalogCard.open", dir); - expect(spy).toHaveBeenCalledTimes(2); // closed → fresh panel + expect(spy).toHaveBeenCalledTimes(2); // closed → fresh panel spy.mockRestore(); }); }); @@ -81,20 +88,29 @@ describe("SessionCatalogTree — pointer records, newest first", () => { return { workspaceState: { get: (k: string, d: unknown) => (store.has(k) ? store.get(k) : d), - update: (k: string, v: unknown) => { store.set(k, v); return Promise.resolve(); }, + update: (k: string, v: unknown) => { + store.set(k, v); + return Promise.resolve(); + }, }, } as never; } const entry = (run_id: string, over: Partial = {}): SessionCatalogEntry => ({ - run_id, runDir: `/runs/${run_id}`, lab_id: "default", fidelity: 0.999, - gate: "X", system: "transmon", saved_at: "2026-07-03T00:00:00Z", ...over, + run_id, + runDir: `/runs/${run_id}`, + lab_id: "default", + fidelity: 0.999, + gate: "X", + system: "transmon", + saved_at: "2026-07-03T00:00:00Z", + ...over, }); it("saves newest-first, dedupes by run_id, and rows open the card for the run dir", async () => { const tree = new SessionCatalogTree(makeCtx()); await tree.save(entry("r1")); await tree.save(entry("r2")); - await tree.save(entry("r1", { fidelity: 0.5 })); // re-save moves to front, replaces + await tree.save(entry("r1", { fidelity: 0.5 })); // re-save moves to front, replaces const rows = tree.getChildren() as SessionCatalogEntry[]; expect(rows.map((r) => r.run_id)).toEqual(["r1", "r2"]); expect(rows[0].fidelity).toBe(0.5); @@ -102,7 +118,7 @@ describe("SessionCatalogTree — pointer records, newest first", () => { const item = tree.getTreeItem(rows[1]) as { label: string; command?: { command: string; arguments: unknown[] } }; expect(item.label).toContain("transmon"); expect(item.command?.command).toBe("amicode.catalogCard.open"); - expect(item.command?.arguments).toEqual(["/runs/r2", "transmon", undefined]); // runDir + name + tags → card + expect(item.command?.arguments).toEqual(["/runs/r2", "transmon", undefined]); // runDir + name + tags → card }); it("remove() unsaves the pointer only — remaining entries and order survive", async () => { @@ -113,7 +129,7 @@ describe("SessionCatalogTree — pointer records, newest first", () => { await tree.remove("r2"); const rows = tree.getChildren() as SessionCatalogEntry[]; expect(rows.map((r) => r.run_id)).toEqual(["r3", "r1"]); - await tree.remove("r2"); // idempotent — removing a gone entry is a no-op + await tree.remove("r2"); // idempotent — removing a gone entry is a no-op expect((tree.getChildren() as SessionCatalogEntry[]).length).toBe(2); }); diff --git a/packages/extension/test/inspector_view_contract.test.ts b/packages/extension/test/inspector_view_contract.test.ts index 287fbc92..67643fed 100644 --- a/packages/extension/test/inspector_view_contract.test.ts +++ b/packages/extension/test/inspector_view_contract.test.ts @@ -24,11 +24,17 @@ function renderInspectorHtml(): string { webview: { options: {}, cspSource: "vscode-webview://unit", - asWebviewUri: (u: { fsPath?: string }) => ({ toString: () => "vscode-webview://unit/" + (u?.fsPath ?? String(u)) }), + asWebviewUri: (u: { fsPath?: string }) => ({ + toString: () => "vscode-webview://unit/" + (u?.fsPath ?? String(u)), + }), postMessage: () => undefined, onDidReceiveMessage: () => ({ dispose() {} }), - set html(v: string) { captured = v; }, - get html() { return captured; }, + set html(v: string) { + captured = v; + }, + get html() { + return captured; + }, }, onDidDispose: () => ({ dispose() {} }), }; @@ -48,7 +54,9 @@ describe("Run Inspector shell contract (plumbing ⇄ TS-composed view)", () => { it("keeps the CSP authorizing every grant the view depends on", () => { // Pin grants to their directive, not just "appears somewhere in the CSP". const styleSrc = html.match(/style-src([^;]*)/)?.[1] ?? ""; - expect(styleSrc, "style-src must grant the webview source for the linked stylesheets").toContain("vscode-webview://unit"); + expect(styleSrc, "style-src must grant the webview source for the linked stylesheets").toContain( + "vscode-webview://unit", + ); expect(styleSrc, "style-src keeps 'unsafe-inline' for design-lane static style attrs").toContain("'unsafe-inline'"); expect(html, "no image grants — the view renders from message data (#66)").not.toMatch(/img-src/); @@ -76,7 +84,11 @@ describe("Run Inspector shell contract (plumbing ⇄ TS-composed view)", () => { // throttle; resolve replays EVERY pane (S36) and activate names the visible one. describe("Run Inspector host buffering (#66 pulse events, runId-keyed)", () => { const META = { drives: 1, knots: 2, labels: ["a_1"], bounds: [[-0.2, 0.2]] as [number, number][] }; - const rec = (iter: number): { iter: number; dt: number; values: number[][] } => ({ iter, dt: 0.2, values: [[iter / 10, iter / 5]] }); + const rec = (iter: number): { iter: number; dt: number; values: number[][] } => ({ + iter, + dt: 0.2, + values: [[iter / 10, iter / 5]], + }); function harness() { const ctx = { extensionUri: { fsPath: PKG_ROOT }, subscriptions: [] as unknown[] }; @@ -90,13 +102,24 @@ describe("Run Inspector host buffering (#66 pulse events, runId-keyed)", () => { webview: { options: {}, cspSource: "vscode-webview://unit", - asWebviewUri: (u: { fsPath?: string }) => ({ toString: () => "vscode-webview://unit/" + (u?.fsPath ?? String(u)) }), - postMessage: (m: Record) => { posted.push(m); }, + asWebviewUri: (u: { fsPath?: string }) => ({ + toString: () => "vscode-webview://unit/" + (u?.fsPath ?? String(u)), + }), + postMessage: (m: Record) => { + posted.push(m); + }, onDidReceiveMessage: () => ({ dispose() {} }), - set html(_v: string) { /* ignore */ }, - get html() { return ""; }, + set html(_v: string) { + /* ignore */ + }, + get html() { + return ""; + }, + }, + onDidDispose: (cb: () => void) => { + disposeCb = cb; + return { dispose() {} }; }, - onDidDispose: (cb: () => void) => { disposeCb = cb; return { dispose() {} }; }, }; return { view, posted, dispose: () => disposeCb() }; }; @@ -113,10 +136,10 @@ describe("Run Inspector host buffering (#66 pulse events, runId-keyed)", () => { inspector.resolveWebviewView(view as never); const r1 = posted.filter((m) => m.runId === "r1"); - expect(r1.every((m) => m.runId === "r1")).toBe(true); // every message carries the runId + expect(r1.every((m) => m.runId === "r1")).toBe(true); // every message carries the runId const types = r1.map((m) => m.type); expect(types).toContain("pulsemeta"); - expect(types.filter((t) => t === "pulse")).toHaveLength(1); // newest-wins: iter 1 dropped + expect(types.filter((t) => t === "pulse")).toHaveLength(1); // newest-wins: iter 1 dropped expect(r1.find((m) => m.type === "pulse")).toMatchObject({ iter: 2 }); expect(types.indexOf("pulsemeta")).toBeLessThan(types.indexOf("pulse")); expect(types.indexOf("pulse")).toBeLessThan(types.indexOf("completed")); // terminal state stays the last word @@ -129,13 +152,13 @@ describe("Run Inspector host buffering (#66 pulse events, runId-keyed)", () => { inspector.postPulse("r1", { type: "meta", meta: META }); posted.length = 0; inspector.postPulse("r1", { type: "record", record: rec(1) }); - expect(posted.map((m) => m.type)).toEqual(["pulse"]); // leading edge posts immediately + expect(posted.map((m) => m.type)).toEqual(["pulse"]); // leading edge posts immediately inspector.postPulse("r1", { type: "record", record: rec(2) }); inspector.postPulse("r1", { type: "record", record: rec(3) }); - expect(posted).toHaveLength(1); // inside the window: coalesced + expect(posted).toHaveLength(1); // inside the window: coalesced vi.advanceTimersByTime(200); - expect(posted).toHaveLength(2); // trailing edge: exactly one flush - expect(posted[1]).toMatchObject({ type: "pulse", iter: 3, runId: "r1" }); // …carrying the newest + expect(posted).toHaveLength(2); // trailing edge: exactly one flush + expect(posted[1]).toMatchObject({ type: "pulse", iter: 3, runId: "r1" }); // …carrying the newest }); it("posts straight through once the webview is live", () => { @@ -166,8 +189,8 @@ describe("Run Inspector host buffering (#66 pulse events, runId-keyed)", () => { const { inspector, view, posted } = harness(); inspector.resolveWebviewView(view as never); posted.length = 0; - inspector.postPulse("r1", { type: "record", record: rec(1) }); // opens r1's window (posts) - inspector.postPulse("r2", { type: "record", record: rec(1) }); // r2 has its OWN window (posts) + inspector.postPulse("r1", { type: "record", record: rec(1) }); // opens r1's window (posts) + inspector.postPulse("r2", { type: "record", record: rec(1) }); // r2 has its OWN window (posts) expect(posted.filter((m) => m.type === "pulse")).toHaveLength(2); expect(posted.map((m) => m.runId).sort()).toEqual(["r1", "r2"]); }); @@ -182,7 +205,7 @@ describe("Run Inspector host buffering (#66 pulse events, runId-keyed)", () => { const activate = posted.filter((m) => m.type === "activate"); expect(activate).toHaveLength(1); expect(activate[0]).toMatchObject({ runId: "r2" }); - expect(posted.indexOf(activate[0])).toBe(posted.length - 1); // last word = the visible pane + expect(posted.indexOf(activate[0])).toBe(posted.length - 1); // last word = the visible pane }); it("rebuilds EVERY pane on reopen (S36) — dispose then re-resolve replays all runs", () => { @@ -195,10 +218,10 @@ describe("Run Inspector host buffering (#66 pulse events, runId-keyed)", () => { inspector.postPulse("r2", { type: "meta", meta: META }); inspector.postPulse("r2", { type: "record", record: rec(9) }); inspector.activate("r2"); - a.dispose(); // user closes the panel + a.dispose(); // user closes the panel const b = makeView(); - inspector.resolveWebviewView(b.view as never); // reopen — fresh DOM + inspector.resolveWebviewView(b.view as never); // reopen — fresh DOM // Both panes rebuilt from buffers, each with its newest record, r1 terminal. expect(b.posted.filter((m) => m.type === "pulse" && m.runId === "r1")).toMatchObject([{ iter: 4 }]); expect(b.posted.filter((m) => m.type === "completed" && m.runId === "r1")).toHaveLength(1); @@ -209,14 +232,16 @@ describe("Run Inspector host buffering (#66 pulse events, runId-keyed)", () => { it("setWarmingUp no-ops once the pane has data or terminal state (no clobber of a fanned-in run)", () => { const { inspector, view, posted } = harness(); inspector.resolveWebviewView(view as never); - inspector.postPulse("r1", { type: "record", record: rec(1) }); // r1 has data - inspector.postCompletion("r2", "completed", 0.99); // r2 is terminal + inspector.postPulse("r1", { type: "record", record: rec(1) }); // r1 has data + inspector.postCompletion("r2", "completed", 0.99); // r2 is terminal posted.length = 0; inspector.setWarmingUp("r1"); inspector.setWarmingUp("r2"); - inspector.setWarmingUp("r3"); // fresh run → warming IS shown + inspector.setWarmingUp("r3"); // fresh run → warming IS shown expect(posted.filter((m) => m.type === "warming")).toMatchObject([{ runId: "r3" }]); }); }); -afterEach(() => { vi.useRealTimers(); }); +afterEach(() => { + vi.useRealTimers(); +}); diff --git a/packages/extension/test/inspector_webview_view.test.ts b/packages/extension/test/inspector_webview_view.test.ts index 757fa8a4..763b5262 100644 --- a/packages/extension/test/inspector_webview_view.test.ts +++ b/packages/extension/test/inspector_webview_view.test.ts @@ -13,7 +13,14 @@ import { createInspectorView } from "../media/ui/views/inspector"; // .pane/.active/.pill. Runs under happy-dom because the atoms inject styles via // constructable stylesheets (`new CSSStyleSheet()`), which jsdom can't model. -const iter = (runId: string, n: number) => ({ type: "iteration", runId, iter: n, f_val: 1e-2, eq_viol: 1e-8, kkt_error: 1e-6 }); +const iter = (runId: string, n: number) => ({ + type: "iteration", + runId, + iter: n, + f_val: 1e-2, + eq_viol: 1e-8, + kkt_error: 1e-6, +}); const panes = (v: { el: HTMLElement }) => [...v.el.querySelectorAll(".pane")]; const activePane = (v: { el: HTMLElement }) => v.el.querySelector(".pane.active"); const pillText = (pane: Element | null | undefined) => pane?.querySelector(".pill")?.textContent; @@ -22,17 +29,17 @@ describe("Inspector webview router (1.3 per-run panes)", () => { it("activate shows exactly one pane and hides the empty-state hint", () => { const v = createInspectorView(() => {}); expect(v.el.querySelectorAll(".pane.active")).toHaveLength(0); - const emptyHint = v.el.firstElementChild as HTMLElement; // the idle hint, appended first + const emptyHint = v.el.firstElementChild as HTMLElement; // the idle hint, appended first expect(emptyHint.style.display).not.toBe("none"); v.onMessage(iter("r1", 3)); v.onMessage(iter("r2", 4)); expect(panes(v)).toHaveLength(2); - expect(v.el.querySelectorAll(".pane.active")).toHaveLength(0); // panes exist but none shown yet + expect(v.el.querySelectorAll(".pane.active")).toHaveLength(0); // panes exist but none shown yet v.onMessage({ type: "activate", runId: "r2" }); expect(v.el.querySelectorAll(".pane.active")).toHaveLength(1); - expect(panes(v)[1].classList.contains("active")).toBe(true); // r2 = 2nd-created pane + expect(panes(v)[1].classList.contains("active")).toBe(true); // r2 = 2nd-created pane expect(panes(v)[0].classList.contains("active")).toBe(false); expect(emptyHint.style.display).toBe("none"); }); @@ -54,11 +61,11 @@ describe("Inspector webview router (1.3 per-run panes)", () => { // r2 is a background run — its iteration must land in ITS pane, not r1's. v.onMessage(iter("r2", 99)); - expect(pillText(activePane(v))).toBe("running"); // r1 badge unchanged + expect(pillText(activePane(v))).toBe("running"); // r1 badge unchanged const r2 = panes(v).find((p) => !p.classList.contains("active"))!; - expect(pillText(r2)).toBe("running"); // r2 has its OWN running badge - expect(r1.textContent).toContain("3"); // r1 still reads iter 3… - expect(r1.textContent).not.toContain("99"); // …not r2's 99 (no value bleed) + expect(pillText(r2)).toBe("running"); // r2 has its OWN running badge + expect(r1.textContent).toContain("3"); // r1 still reads iter 3… + expect(r1.textContent).not.toContain("99"); // …not r2's 99 (no value bleed) }); it("pulse is plot-only — it never touches the active pane's badge (#67)", () => { @@ -68,20 +75,20 @@ describe("Inspector webview router (1.3 per-run panes)", () => { expect(pillText(r1)).toBe("idle"); v.onMessage({ type: "pulsemeta", runId: "r1", drives: 1, knots: 2, labels: ["a_1"], bounds: [[-0.2, 0.2]] }); v.onMessage({ type: "pulse", runId: "r1", iter: 1, dt: 0.2, values: [[0.1, 0.2]] }); - expect(pillText(r1)).toBe("idle"); // pulse did NOT flip the badge + expect(pillText(r1)).toBe("idle"); // pulse did NOT flip the badge }); it("switching activate moves the visible pane, each pane keeps its own state", () => { const v = createInspectorView(() => {}); v.onMessage(iter("r1", 1)); - v.onMessage({ type: "completed", runId: "r1", status: "completed", fidelity: 0.999 }); // hidden pane still updates + v.onMessage({ type: "completed", runId: "r1", status: "completed", fidelity: 0.999 }); // hidden pane still updates v.onMessage(iter("r2", 2)); v.onMessage({ type: "activate", runId: "r1" }); - expect(pillText(activePane(v))).toBe("converged"); // r1 terminal badge shows on activate + expect(pillText(activePane(v))).toBe("converged"); // r1 terminal badge shows on activate v.onMessage({ type: "activate", runId: "r2" }); expect(v.el.querySelectorAll(".pane.active")).toHaveLength(1); - expect(pillText(activePane(v))).toBe("running"); // now r2 is visible + expect(pillText(activePane(v))).toBe("running"); // now r2 is visible const r1 = panes(v).find((p) => !p.classList.contains("active"))!; - expect(pillText(r1)).toBe("converged"); // r1 untouched by the switch + expect(pillText(r1)).toBe("converged"); // r1 untouched by the switch }); }); diff --git a/packages/extension/test/log_tailer.test.ts b/packages/extension/test/log_tailer.test.ts index 098185e7..72f7be37 100644 --- a/packages/extension/test/log_tailer.test.ts +++ b/packages/extension/test/log_tailer.test.ts @@ -22,12 +22,12 @@ function harness(content?: string, startOffset = 0) { describe("LogTailer", () => { it("emits complete lines once; a torn final line (no newline yet) waits and heals", () => { - const { p, t, lines } = harness("a\t1\t/s.jl\nb\t2\t/s"); // second line torn mid-write + const { p, t, lines } = harness("a\t1\t/s.jl\nb\t2\t/s"); // second line torn mid-write t.poke(); - expect(lines).toEqual(["a\t1\t/s.jl"]); // torn tail NOT emitted - appendFileSync(p, ".jl\nc\t3\t/t.jl\n"); // writer finishes + appends + expect(lines).toEqual(["a\t1\t/s.jl"]); // torn tail NOT emitted + appendFileSync(p, ".jl\nc\t3\t/t.jl\n"); // writer finishes + appends t.poke(); - expect(lines).toEqual(["a\t1\t/s.jl", "b\t2\t/s.jl", "c\t3\t/t.jl"]); // healed, no split + expect(lines).toEqual(["a\t1\t/s.jl", "b\t2\t/s.jl", "c\t3\t/t.jl"]); // healed, no split t.dispose(); }); @@ -35,9 +35,9 @@ describe("LogTailer", () => { const { p, t, lines } = harness("one\ntwo\n"); t.poke(); expect(lines).toEqual(["one", "two"]); - writeFileSync(p, "one\n"); // file shrank (rewrite) + writeFileSync(p, "one\n"); // file shrank (rewrite) t.poke(); - expect(lines).toEqual(["one", "two", "one"]); // full re-read from 0 + expect(lines).toEqual(["one", "two", "one"]); // full re-read from 0 t.dispose(); }); @@ -50,11 +50,11 @@ describe("LogTailer", () => { }); it("poke() self-attaches when the file appears after start()", () => { - const { p, t, lines } = harness(undefined); // file doesn't exist yet + const { p, t, lines } = harness(undefined); // file doesn't exist yet t.poke(); expect(lines).toEqual([]); writeFileSync(p, "late\n"); - t.poke(); // attaches + drains + t.poke(); // attaches + drains expect(lines).toEqual(["late"]); t.dispose(); }); diff --git a/packages/extension/test/run_controls.test.ts b/packages/extension/test/run_controls.test.ts index f084abb0..52422933 100644 --- a/packages/extension/test/run_controls.test.ts +++ b/packages/extension/test/run_controls.test.ts @@ -90,7 +90,8 @@ describe("forceFinalize", () => { describe("findRunPids (two-key match: cmdline AND cwd)", () => { const RUN_DIR = "/fake/runs/default/r1"; const SCRIPT = "/fake/problems/x/solve.jl"; - const fakeExec = (psLines: string, cwdByPid: Record) => + const fakeExec = + (psLines: string, cwdByPid: Record) => (cmd: string, args: string[]): string => { if (cmd === "/bin/ps") return psLines; const pid = args[args.indexOf("-p") + 1]; @@ -100,18 +101,26 @@ describe("findRunPids (two-key match: cmdline AND cwd)", () => { it("kills only processes running the script FROM this run dir", () => { const ps = [ - ` 101 julia --project=/x ${SCRIPT}`, // ours: script + cwd match - ` 202 julia --project=/x ${SCRIPT}`, // sibling run, other cwd - ` 303 vim ${RUN_DIR}/run.log`, // references dir, wrong cwd + ` 101 julia --project=/x ${SCRIPT}`, // ours: script + cwd match + ` 202 julia --project=/x ${SCRIPT}`, // sibling run, other cwd + ` 303 vim ${RUN_DIR}/run.log`, // references dir, wrong cwd " 404 unrelated", ].join("\n"); - const pids = findRunPids(RUN_DIR, SCRIPT, fakeExec(ps, { "101": RUN_DIR, "202": "/fake/runs/default/r2", "303": "/home" })); + const pids = findRunPids( + RUN_DIR, + SCRIPT, + fakeExec(ps, { "101": RUN_DIR, "202": "/fake/runs/default/r2", "303": "/home" }), + ); expect(pids).toEqual([101]); }); it("returns [] when nothing matches or lsof cannot prove ownership", () => { const ps = ` 505 julia ${SCRIPT}\n`; expect(findRunPids(RUN_DIR, SCRIPT, fakeExec(ps, {}))).toEqual([]); - expect(findRunPids(RUN_DIR, SCRIPT, () => { throw new Error("ps down"); })).toEqual([]); + expect( + findRunPids(RUN_DIR, SCRIPT, () => { + throw new Error("ps down"); + }), + ).toEqual([]); }); }); diff --git a/packages/extension/test/run_registry.test.ts b/packages/extension/test/run_registry.test.ts index d501c91c..ca2daa6a 100644 --- a/packages/extension/test/run_registry.test.ts +++ b/packages/extension/test/run_registry.test.ts @@ -8,14 +8,16 @@ import { parseIndexLine, RunRegistry } from "../src/run_registry"; describe("parseIndexLine — runs/index grammar", () => { it("parses the writer's TSV line", () => { expect(parseIndexLine("r20260703-010203Z-ab12\t2026-07-03T01:02:03Z\t/tmp/solve.jl")).toEqual({ - runId: "r20260703-010203Z-ab12", createdAt: "2026-07-03T01:02:03Z", scriptPath: "/tmp/solve.jl", + runId: "r20260703-010203Z-ab12", + createdAt: "2026-07-03T01:02:03Z", + scriptPath: "/tmp/solve.jl", }); }); it("rejects blank and malformed lines (torn final line heals on next drain)", () => { expect(parseIndexLine("")).toBeUndefined(); expect(parseIndexLine(" ")).toBeUndefined(); expect(parseIndexLine("r1\tonly-two-fields")).toBeUndefined(); - expect(parseIndexLine("\t\t/s.jl")).toBeUndefined(); // empty runId + expect(parseIndexLine("\t\t/s.jl")).toBeUndefined(); // empty runId }); it("re-joins extra tabs into the path (defensive — the writer sanitizes)", () => { expect(parseIndexLine("r1\t2026-01-01T00:00:00Z\t/a\tb.jl")?.scriptPath).toBe("/a\tb.jl"); @@ -27,16 +29,16 @@ describe("RunRegistry", () => { const reg = new RunRegistry(); expect(reg.register({ runId: "r1", runDir: "/runs/r1", phase: "live" })).toBe(true); expect(reg.register({ runId: "r1", runDir: "/elsewhere", phase: "finished" })).toBe(false); - expect(reg.get("r1")?.runDir).toBe("/runs/r1"); // first registration wins + expect(reg.get("r1")?.runDir).toBe("/runs/r1"); // first registration wins expect(reg.get("r1")?.phase).toBe("live"); }); it("noteIter is a monotonic high-water mark", () => { const reg = new RunRegistry(); reg.register({ runId: "r1", runDir: "/runs/r1", phase: "live" }); reg.noteIter("r1", 5); - reg.noteIter("r1", 3); // out-of-order (poll double-delivery) + reg.noteIter("r1", 3); // out-of-order (poll double-delivery) expect(reg.get("r1")?.latestIter).toBe(5); - reg.noteIter("nope", 9); // unknown run — no throw + reg.noteIter("nope", 9); // unknown run — no throw }); it("markFinished sets phase/status/fidelity and keeps latestIter", () => { const reg = new RunRegistry(); @@ -49,12 +51,12 @@ describe("RunRegistry", () => { const reg = new RunRegistry(); reg.register({ runId: "r1", runDir: "/runs/r1", phase: "live" }); reg.markFinished("r1", "completed", 0.999); - reg.markFinished("r1", "failed"); // stray second call (public surface, 1.3 consumers) + reg.markFinished("r1", "failed"); // stray second call (public surface, 1.3 consumers) expect(reg.get("r1")).toMatchObject({ phase: "finished", status: "completed", fidelity: 0.999 }); }); it("backfill fills ONLY missing metadata (scheduler-registered run gains createdAt/scriptPath from a later index line)", () => { const reg = new RunRegistry(); - reg.register({ runId: "r1", runDir: "/runs/r1", phase: "live" }); // scheduler path: no metadata + reg.register({ runId: "r1", runDir: "/runs/r1", phase: "live" }); // scheduler path: no metadata expect(reg.get("r1")?.createdAt).toBeUndefined(); expect(reg.get("r1")?.scriptPath).toBeUndefined(); reg.backfill("r1", { createdAt: "2026-07-03T00:00:00Z", scriptPath: "/s.jl" }); @@ -62,7 +64,7 @@ describe("RunRegistry", () => { // never overwrites a present value (first registration wins for everything) reg.backfill("r1", { createdAt: "2099-01-01T00:00:00Z", scriptPath: "/other.jl" }); expect(reg.get("r1")).toMatchObject({ createdAt: "2026-07-03T00:00:00Z", scriptPath: "/s.jl" }); - reg.backfill("nope", { createdAt: "x" }); // unknown run — no throw + reg.backfill("nope", { createdAt: "x" }); // unknown run — no throw }); it("all() returns COPIES — callers can't mutate registry state", () => { const reg = new RunRegistry(); diff --git a/packages/extension/test/runs_manager.test.ts b/packages/extension/test/runs_manager.test.ts index 5ca07e61..b2d781ef 100644 --- a/packages/extension/test/runs_manager.test.ts +++ b/packages/extension/test/runs_manager.test.ts @@ -39,20 +39,29 @@ const channel = { appendLine() {}, append() {} } as never; const META_LINE = 'AMICODE_PULSE_META drives=1 knots=2 labels="a_1" bounds=-0.2:0.2\n'; /** Minimal StatusBarManager spy — only setRun is exercised. */ -function statusBarSpy() { return { setRun: vi.fn(), clear: vi.fn(), dispose: vi.fn() }; } +function statusBarSpy() { + return { setRun: vi.fn(), clear: vi.fn(), dispose: vi.fn() }; +} function writeManifest(dir: string, runId: string): void { - writeFileSync(join(dir, "run.toml"), + writeFileSync( + join(dir, "run.toml"), `schema_version = "1"\nrun_id = "${runId}"\nscript_path = "/s.jl"\nlab = "default"\n` + - `lab_id = "default"\ncreated_at = "2026-06-15T00:00:00Z"\norchestrator_version = "0.1.0"\n[julia]\nbinary = "julia"\n`); + `lab_id = "default"\ncreated_at = "2026-06-15T00:00:00Z"\norchestrator_version = "0.1.0"\n[julia]\nbinary = "julia"\n`, + ); } /** Stage a run dir + its index line (the amico-run writer's TSV format). */ -function stageRun(root: string, runId: string, opts: { finished?: string; fidelity?: number; log?: string } = {}): string { +function stageRun( + root: string, + runId: string, + opts: { finished?: string; fidelity?: number; log?: string } = {}, +): string { const dir = join(root, runId); mkdirSync(dir, { recursive: true }); writeManifest(dir, runId); if (opts.log !== undefined) writeFileSync(join(dir, "run.log"), opts.log); - if (opts.fidelity !== undefined) writeFileSync(join(dir, "result.toml"), `schema_version = "1"\nfidelity = ${opts.fidelity}\niterations = 9\n`); + if (opts.fidelity !== undefined) + writeFileSync(join(dir, "result.toml"), `schema_version = "1"\nfidelity = ${opts.fidelity}\niterations = 9\n`); if (opts.finished) writeFileSync(join(dir, "FINISHED"), `status = "${opts.finished}"\nexit_code = 0\n`); appendFileSync(join(root, "index"), `${runId}\t2026-07-03T00:00:00Z\t/s.jl\n`); return dir; @@ -60,7 +69,9 @@ function stageRun(root: string, runId: string, opts: { finished?: string; fideli const tick = (m: RunsManager): void => (m as unknown as { tick(): void }).tick(); describe("RunsManager state machine (ported from RunsRootWatcher)", () => { - beforeEach(() => { for (const f of Object.values(inspector)) f.mockClear(); }); + beforeEach(() => { + for (const f of Object.values(inspector)) f.mockClear(); + }); it("a run already FINISHED at launch stays idle — nothing re-rendered", () => { const root = mkdtempSync(join(tmpdir(), "runs-")); @@ -71,7 +82,7 @@ describe("RunsManager state machine (ported from RunsRootWatcher)", () => { expect(inspector.postPulse).not.toHaveBeenCalled(); expect(inspector.postCompletion).not.toHaveBeenCalled(); expect(inspector.setWarmingUp).not.toHaveBeenCalled(); - expect(m.runs()).toHaveLength(1); // …but it IS registered + expect(m.runs()).toHaveLength(1); // …but it IS registered expect(m.runs()[0]).toMatchObject({ phase: "finished", status: "completed", fidelity: 0.9999 }); m.dispose(); }); @@ -82,11 +93,11 @@ describe("RunsManager state machine (ported from RunsRootWatcher)", () => { m.start(); // Registered AFTER boot (a run that STARTS while the user works) — the // boot-replay path is warming-quiet by design (see the boot test below). - const run = stageRun(root, "r2"); // manifest only, no data yet + const run = stageRun(root, "r2"); // manifest only, no data yet tick(m); expect(inspector.setWarmingUp).toHaveBeenCalledWith("r2"); expect(inspector.setRunLabel).toHaveBeenCalledWith("r2", "r2"); - expect(inspector.activate).toHaveBeenCalledWith("r2"); // 1.3: selection = activate the pane + expect(inspector.activate).toHaveBeenCalledWith("r2"); // 1.3: selection = activate the pane expect(m.selectedRun).toBe("r2"); // result.toml alone must NOT complete the run (FINISHED is authoritative). @@ -102,12 +113,12 @@ describe("RunsManager state machine (ported from RunsRootWatcher)", () => { it("BOOT replay is warming/reveal-quiet: a live run discovered at start() is tracked but never steals focus", () => { const root = mkdtempSync(join(tmpdir(), "runs-")); - stageRun(root, "rBoot"); // live run exists BEFORE start + stageRun(root, "rBoot"); // live run exists BEFORE start const m = new RunsManager({ runsRoot: root, channel }); m.start(); - expect(m.selectedRun).toBe("rBoot"); // state still selects it… - expect(inspector.setWarmingUp).not.toHaveBeenCalled(); // …but no warming focus - expect(inspector.reveal).not.toHaveBeenCalled(); // …and no reveal at boot + expect(m.selectedRun).toBe("rBoot"); // state still selects it… + expect(inspector.setWarmingUp).not.toHaveBeenCalled(); // …but no warming focus + expect(inspector.reveal).not.toHaveBeenCalled(); // …and no reveal at boot m.dispose(); }); @@ -122,12 +133,19 @@ describe("RunsManager state machine (ported from RunsRootWatcher)", () => { tick(m); expect(inspector.postPulse).toHaveBeenCalledTimes(2); expect(inspector.postPulse).toHaveBeenNthCalledWith(1, "p1", expect.objectContaining({ type: "meta" })); - expect(inspector.postPulse).toHaveBeenNthCalledWith(2, "p1", expect.objectContaining({ type: "record", record: expect.objectContaining({ iter: 1 }) })); + expect(inspector.postPulse).toHaveBeenNthCalledWith( + 2, + "p1", + expect.objectContaining({ type: "record", record: expect.objectContaining({ iter: 1 }) }), + ); appendFileSync(join(run, "run.log"), "AMICODE_PULSE iter=2 dt=0.2 a=0.3,0.4\n"); tick(m); expect(inspector.postPulse).toHaveBeenCalledTimes(3); - expect(inspector.postPulse).toHaveBeenLastCalledWith("p1", expect.objectContaining({ type: "record", record: expect.objectContaining({ iter: 2 }) })); + expect(inspector.postPulse).toHaveBeenLastCalledWith( + "p1", + expect.objectContaining({ type: "record", record: expect.objectContaining({ iter: 2 }) }), + ); m.dispose(); }); @@ -136,19 +154,24 @@ describe("RunsManager state machine (ported from RunsRootWatcher)", () => { // Mid-flight discovery: meta + one record ALREADY on disk, run not finished. const run = stageRun(root, "p2", { log: META_LINE + "AMICODE_PULSE iter=3 dt=0.2 a=0.1,0.2\n" }); const m = new RunsManager({ runsRoot: root, channel }); - m.start(); // display replay → meta + newest record + m.start(); // display replay → meta + newest record expect(inspector.postPulse).toHaveBeenCalledTimes(2); appendFileSync(join(run, "run.log"), "AMICODE_PULSE iter=4 dt=0.2 a=0.3,0.4\n"); - tick(m); // record parses against the armed meta + tick(m); // record parses against the armed meta expect(inspector.postPulse).toHaveBeenCalledTimes(3); - expect(inspector.postPulse).toHaveBeenLastCalledWith("p2", expect.objectContaining({ type: "record", record: expect.objectContaining({ iter: 4 }) })); + expect(inspector.postPulse).toHaveBeenLastCalledWith( + "p2", + expect.objectContaining({ type: "record", record: expect.objectContaining({ iter: 4 }) }), + ); m.dispose(); }); }); describe("RunsManager multi-run (#57 / #58 fan-out)", () => { - beforeEach(() => { for (const f of Object.values(inspector)) f.mockClear(); }); + beforeEach(() => { + for (const f of Object.values(inspector)) f.mockClear(); + }); it("two concurrent live runs: newest auto-selected; both fanned to the inspector, status bar tracks the selected only", () => { const root = mkdtempSync(join(tmpdir(), "runs-")); @@ -158,9 +181,9 @@ describe("RunsManager multi-run (#57 / #58 fan-out)", () => { m.start(); expect(m.selectedRun).toBe("rA"); - const b = stageRun(root, "rB"); // second solve starts - tick(m); // index tail discovers it - expect(m.selectedRun).toBe("rB"); // auto-follow the newest start + const b = stageRun(root, "rB"); // second solve starts + tick(m); // index tail discovers it + expect(m.selectedRun).toBe("rB"); // auto-follow the newest start inspector.postIterationRecord.mockClear(); statusBar.setRun.mockClear(); @@ -169,8 +192,8 @@ describe("RunsManager multi-run (#57 / #58 fan-out)", () => { appendFileSync(join(a, "run.log"), "AMICODE_ITER iter=7 f=0.1 inf_pr=1e-8 inf_du=1e-6\n"); tick(m); expect(inspector.postIterationRecord).toHaveBeenCalledWith("rA", expect.objectContaining({ iter: 7 })); - expect(statusBar.setRun).not.toHaveBeenCalled(); // selection-gated: rB is selected - expect(m.runs().find(r => r.runId === "rA")?.latestIter).toBe(7); + expect(statusBar.setRun).not.toHaveBeenCalled(); // selection-gated: rB is selected + expect(m.runs().find((r) => r.runId === "rA")?.latestIter).toBe(7); // A finishes in the background: registry terminal, completion fanned to the // inspector (rA's pane badge), status bar still untouched… @@ -179,8 +202,8 @@ describe("RunsManager multi-run (#57 / #58 fan-out)", () => { const promote = vi.spyOn(vscodeMock.window, "showInformationMessage"); tick(m); expect(inspector.postCompletion).toHaveBeenCalledWith("rA", "completed", 0.9995); - expect(statusBar.setRun).not.toHaveBeenCalled(); // still rB selected - expect(m.runs().find(r => r.runId === "rA")).toMatchObject({ phase: "finished", fidelity: 0.9995 }); + expect(statusBar.setRun).not.toHaveBeenCalled(); // still rB selected + expect(m.runs().find((r) => r.runId === "rA")).toMatchObject({ phase: "finished", fidelity: 0.9995 }); // …and the promote prompt STILL fires (fan-out is per-run, not per-selection). expect(promote).toHaveBeenCalledTimes(1); @@ -200,53 +223,56 @@ describe("RunsManager multi-run (#57 / #58 fan-out)", () => { m.start(); writeFileSync(join(a, "result.toml"), 'schema_version = "1"\nfidelity = 0.9999\niterations = 3\n'); writeFileSync(join(a, "FINISHED"), 'status = "completed"\nexit_code = 0\n'); - tick(m); // live completion (promotes once) + tick(m); // live completion (promotes once) stageRun(root, "rB"); - tick(m); // selection moves to rB + tick(m); // selection moves to rB expect(m.selectedRun).toBe("rB"); const promote = vi.spyOn(vscodeMock.window, "showInformationMessage"); inspector.postCompletion.mockClear(); - m.selectRun("rA"); // user switches back (1.3 seam) + m.selectRun("rA"); // user switches back (1.3 seam) expect(inspector.activate).toHaveBeenCalledWith("rA"); expect(inspector.postCompletion).toHaveBeenCalledWith("rA", "completed", 0.9999); - expect(promote).not.toHaveBeenCalled(); // promote-once held + expect(promote).not.toHaveBeenCalled(); // promote-once held promote.mockRestore(); m.dispose(); }); it("PULSE events are fanned to the inspector runId-tagged even for a background run (webview shows only the active pane)", () => { const root = mkdtempSync(join(tmpdir(), "runs-")); - const a = stageRun(root, "rA", { log: META_LINE }); // rA armed with meta + const a = stageRun(root, "rA", { log: META_LINE }); // rA armed with meta const m = new RunsManager({ runsRoot: root, channel }); m.start(); stageRun(root, "rB"); tick(m); - expect(m.selectedRun).toBe("rB"); // rA now background + expect(m.selectedRun).toBe("rB"); // rA now background inspector.postPulse.mockClear(); // A background pulse RECORD on rA reaches the inspector TAGGED "rA" — the // webview routes it to rA's hidden pane, never the visible rB plot. appendFileSync(join(a, "run.log"), "AMICODE_PULSE iter=5 dt=0.2 a=0.1,0.2\n"); tick(m); - expect(inspector.postPulse).toHaveBeenCalledWith("rA", expect.objectContaining({ type: "record", record: expect.objectContaining({ iter: 5 }) })); + expect(inspector.postPulse).toHaveBeenCalledWith( + "rA", + expect.objectContaining({ type: "record", record: expect.objectContaining({ iter: 5 }) }), + ); m.dispose(); }); it("selecting a run whose FINISHED landed inside the poll window shows completion, never warming", () => { const root = mkdtempSync(join(tmpdir(), "runs-")); - const a = stageRun(root, "rA"); // live at discovery → pipeline + selected + const a = stageRun(root, "rA"); // live at discovery → pipeline + selected const m = new RunsManager({ runsRoot: root, channel }); m.start(); stageRun(root, "rB"); - tick(m); // selection moves to rB (rA still "live" in registry) + tick(m); // selection moves to rB (rA still "live" in registry) inspector.setWarmingUp.mockClear(); inspector.postCompletion.mockClear(); // rA finishes on disk but the poll hasn't ticked (registry still says live). writeFileSync(join(a, "result.toml"), 'schema_version = "1"\nfidelity = 0.9999\niterations = 3\n'); writeFileSync(join(a, "FINISHED"), 'status = "completed"\nexit_code = 0\n'); - m.selectRun("rA"); // user switches back BEFORE the tick + m.selectRun("rA"); // user switches back BEFORE the tick // selectRun re-checks disk → completion, NOT warming (no terminal-badge inversion). expect(inspector.postCompletion).toHaveBeenCalledWith("rA", "completed", 0.9999); expect(inspector.setWarmingUp).not.toHaveBeenCalled(); @@ -269,14 +295,22 @@ describe("RunsManager multi-run (#57 / #58 fan-out)", () => { m.start(); let emit!: (e: SchedulerLifecycleEvent) => void; - const scheduler: SchedulerLike = { onEvent: (l) => { emit = l; return () => { /* dispose */ }; } }; + const scheduler: SchedulerLike = { + onEvent: (l) => { + emit = l; + return () => { + /* dispose */ + }; + }, + }; m.attachScheduler(scheduler); // A scheduler-launched run — no index line yet (the executor appends it, // but the started event beats the fs). const dir = join(root, "rSched"); - mkdirSync(dir); writeManifest(dir, "rSched"); - emit({ kind: "queued", queueId: "q1", position: 0 }); // logged, no throw + mkdirSync(dir); + writeManifest(dir, "rSched"); + emit({ kind: "queued", queueId: "q1", position: 0 }); // logged, no throw emit({ kind: "started", queueId: "q1", runId: "rSched", runDir: dir }); expect(m.selectedRun).toBe("rSched"); expect(inspector.setRunLabel).toHaveBeenCalledWith("rSched", "rSched"); @@ -286,7 +320,7 @@ describe("RunsManager multi-run (#57 / #58 fan-out)", () => { // The index line landing later is a no-op (registration is idempotent). appendFileSync(join(root, "index"), "rSched\t2026-07-03T00:00:00Z\t/s.jl\n"); tick(m); - expect(m.runs().filter(r => r.runId === "rSched")).toHaveLength(1); + expect(m.runs().filter((r) => r.runId === "rSched")).toHaveLength(1); m.dispose(); }); @@ -295,19 +329,23 @@ describe("RunsManager multi-run (#57 / #58 fan-out)", () => { const m = new RunsManager({ runsRoot: root, channel }); m.start(); stageRun(root, "rDemo", { - finished: "completed", fidelity: 0.9998, + finished: "completed", + fidelity: 0.9998, log: META_LINE + "AMICODE_PULSE iter=60 dt=0.2 a=0.1,0.2\nAMICODE_ITER iter=60 f=2e-3 inf_pr=1e-9 inf_du=1e-6\n", }); const promote = vi.spyOn(vscodeMock.window, "showInformationMessage"); - m.pokeDiscovery(); // same-tick registration… - expect(inspector.postCompletion).not.toHaveBeenCalled(); // …but no auto-display - m.selectRun("rDemo"); // the replayDemo command's path + m.pokeDiscovery(); // same-tick registration… + expect(inspector.postCompletion).not.toHaveBeenCalled(); // …but no auto-display + m.selectRun("rDemo"); // the replayDemo command's path expect(inspector.setRunLabel).toHaveBeenCalledWith("rDemo", "rDemo"); expect(inspector.activate).toHaveBeenCalledWith("rDemo"); - expect(inspector.postPulse).toHaveBeenCalledWith("rDemo", expect.objectContaining({ type: "record", record: expect.objectContaining({ iter: 60 }) })); + expect(inspector.postPulse).toHaveBeenCalledWith( + "rDemo", + expect.objectContaining({ type: "record", record: expect.objectContaining({ iter: 60 }) }), + ); expect(inspector.postCompletion).toHaveBeenCalledWith("rDemo", "completed", 0.9998); - expect(inspector.setWarmingUp).not.toHaveBeenCalled(); // finished — never "warming" - expect(promote).not.toHaveBeenCalled(); // finished-at-discovery: no prompt + expect(inspector.setWarmingUp).not.toHaveBeenCalled(); // finished — never "warming" + expect(promote).not.toHaveBeenCalled(); // finished-at-discovery: no prompt promote.mockRestore(); m.dispose(); }); @@ -315,25 +353,27 @@ describe("RunsManager multi-run (#57 / #58 fan-out)", () => { // Review #70 findings — one test per fix (jack-champagne's static/design pass). describe("RunsManager review-#70 fixes", () => { - beforeEach(() => { for (const f of Object.values(inspector)) f.mockClear(); }); + beforeEach(() => { + for (const f of Object.values(inspector)) f.mockClear(); + }); it("#1 explicit selection is PINNED — a new live run registering does not steal the view", () => { const root = mkdtempSync(join(tmpdir(), "runs-")); stageRun(root, "rA", { finished: "completed", fidelity: 0.9 }); const m = new RunsManager({ runsRoot: root, channel }); m.start(); - m.selectRun("rA"); // the user deliberately opens rA + m.selectRun("rA"); // the user deliberately opens rA expect(m.selectedRun).toBe("rA"); inspector.setRunLabel.mockClear(); - stageRun(root, "rB"); // background solve starts + stageRun(root, "rB"); // background solve starts tick(m); - expect(m.selectedRun).toBe("rA"); // auto-follow deferred to the pin + expect(m.selectedRun).toBe("rA"); // auto-follow deferred to the pin expect(inspector.setRunLabel).not.toHaveBeenCalledWith("rB", "rB"); expect(inspector.activate).not.toHaveBeenCalledWith("rB"); // visible pane untouched - expect(m.runs().find(r => r.runId === "rB")?.phase).toBe("live"); // …but rB IS tracked + expect(m.runs().find((r) => r.runId === "rB")?.phase).toBe("live"); // …but rB IS tracked - m.selectRun("rB"); // explicit switch still works + m.selectRun("rB"); // explicit switch still works expect(m.selectedRun).toBe("rB"); m.dispose(); }); @@ -345,7 +385,7 @@ describe("RunsManager review-#70 fixes", () => { m.start(); stageRun(root, "rB"); tick(m); - expect(m.selectedRun).toBe("rB"); // no pin → newest live run wins + expect(m.selectedRun).toBe("rB"); // no pin → newest live run wins m.dispose(); }); @@ -355,20 +395,24 @@ describe("RunsManager review-#70 fixes", () => { mkdirSync(dir, { recursive: true }); writeManifest(dir, "rTorn"); writeFileSync(join(dir, "result.toml"), 'schema_version = "1"\nfidelity = 0.9997\niterations = 5\n'); - writeFileSync(join(dir, "FINISHED"), 'status = "comp'); // torn mid-write: invalid TOML + writeFileSync(join(dir, "FINISHED"), 'status = "comp'); // torn mid-write: invalid TOML appendFileSync(join(root, "index"), "rTorn\t2026-07-04T00:00:00Z\t/s.jl\n"); const promote = vi.spyOn(vscodeMock.window, "showInformationMessage"); const m = new RunsManager({ runsRoot: root, channel }); m.start(); // NOT finalized with an undefined status — held live so the retry lane owns it. - expect(m.runs().find(r => r.runId === "rTorn")).toMatchObject({ phase: "live" }); - expect(inspector.setWarmingUp).not.toHaveBeenCalled(); // FINISHED exists on disk — never "warming" + expect(m.runs().find((r) => r.runId === "rTorn")).toMatchObject({ phase: "live" }); + expect(inspector.setWarmingUp).not.toHaveBeenCalled(); // FINISHED exists on disk — never "warming" - writeFileSync(join(dir, "FINISHED"), 'status = "completed"\nexit_code = 0\n'); // the write completes + writeFileSync(join(dir, "FINISHED"), 'status = "completed"\nexit_code = 0\n'); // the write completes tick(m); - expect(m.runs().find(r => r.runId === "rTorn")).toMatchObject({ phase: "finished", status: "completed", fidelity: 0.9997 }); - expect(promote).not.toHaveBeenCalled(); // still a launch replay — promote suppressed + expect(m.runs().find((r) => r.runId === "rTorn")).toMatchObject({ + phase: "finished", + status: "completed", + fidelity: 0.9997, + }); + expect(promote).not.toHaveBeenCalled(); // still a launch replay — promote suppressed promote.mockRestore(); m.dispose(); }); @@ -383,7 +427,7 @@ describe("RunsManager review-#70 fixes", () => { const displayPass = vi.spyOn(RunsManager.prototype as never as { displaySink(): unknown }, "displaySink"); const m = new RunsManager({ runsRoot: root, channel }); m.start(); - expect(displayPass).not.toHaveBeenCalled(); // was 1 per discovery + expect(displayPass).not.toHaveBeenCalled(); // was 1 per discovery // …and the single pass still displayed the history (meta + newest record): expect(inspector.postPulse).toHaveBeenCalledTimes(2); displayPass.mockRestore(); @@ -398,7 +442,7 @@ describe("mid-session stall surfaces on the status bar", () => { const m = new RunsManager({ runsRoot: root, channel, statusBar: statusBar as never }); m.start(); const dir = stageRun(root, "r-wedge", { log: "AMICODE_ITER iter=8 f=1.07e+01 inf_pr=1e-3 inf_du=1e-2\n" }); - tick(m); // registers + replays → status bar sees running/iter 8 via routeIter + tick(m); // registers + replays → status bar sees running/iter 8 via routeIter statusBar.setRun.mockClear(); // age run.log past the stall threshold, then let the poll backstop fire diff --git a/packages/extension/test/slow/scores_e2e.test.ts b/packages/extension/test/slow/scores_e2e.test.ts index 422f5e20..b8d41301 100644 --- a/packages/extension/test/slow/scores_e2e.test.ts +++ b/packages/extension/test/slow/scores_e2e.test.ts @@ -1,11 +1,11 @@ -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' +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. @@ -19,134 +19,151 @@ import { readUsage, reconstructTraversal } from '../../src/scores/usage' // 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 EXT = join(__dirname, "..", ".."); +const OC_BIN = join(EXT, "vendor", "opencode", `${process.platform}-${process.arch}`, "opencode"); // Spec A: the manifest lives at the problems ROOT (the guard's manifestDir), but // interview_state.json / usage.jsonl live in the ACTIVE problem's workspace. The // plugin auto-creates an untitled problem on the first tool call; resolve it via // the `active` pointer. function activeStateDir(problemsRoot: string): string | undefined { - const activeFile = join(problemsRoot, 'active') - if (!existsSync(activeFile)) return undefined - const slug = readFileSync(activeFile, 'utf8').trim() - if (!slug) return undefined - return join(problemsRoot, slug) + const activeFile = join(problemsRoot, "active"); + if (!existsSync(activeFile)) return undefined; + const slug = readFileSync(activeFile, "utf8").trim(); + if (!slug) return undefined; + return join(problemsRoot, slug); } -const AUTH_JSON = join(homedir(), '.local', 'share', 'opencode', 'auth.json') +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 + 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 + return Object.keys(JSON.parse(readFileSync(AUTH_JSON, "utf8"))).length > 0; } catch { - return false + return false; } } -const PROBLEMS = mkdtempSync(join(tmpdir(), 'scores-e2e-problems-')) -const servers: ChildProcess[] = [] +const PROBLEMS = mkdtempSync(join(tmpdir(), "scores-e2e-problems-")); +const servers: ChildProcess[] = []; afterAll(() => { - for (const c of servers) c.kill('SIGTERM') -}) + for (const c of servers) c.kill("SIGTERM"); +}); async function serveWithScores(port: number) { // problems root must match between the extension-side builder (permission grant + // manifest transport) and the Bun-side plugin — pin it before either runs. - process.env.AMICODE_PROBLEMS_DIR = PROBLEMS + process.env.AMICODE_PROBLEMS_DIR = PROBLEMS; 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_PROBLEMS_DIR: PROBLEMS } - env.OPENCODE_CONFIG_CONTENT = buildOpencodeConfigContent(project.agentsPath, join(EXT, 'templates', 'solve_template.jl'), join(homedir(), '.amico', 'runs', 'default')) - 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 + 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_PROBLEMS_DIR: PROBLEMS }; + env.OPENCODE_CONFIG_CONTENT = buildOpencodeConfigContent( + project.agentsPath, + join(EXT, "templates", "solve_template.jl"), + join(homedir(), ".amico", "runs", "default"), + ); + 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)) + 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 } + 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') - // Version-agnostic: SCORE.md version bumps must not rot this pin (it sat - // hardcoded at v1 while the score reached v3 — red on every creds machine). - expect(agents).toMatch(/Compiled from score `pulse-designer` v\d+/) - - 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). State - // now lives in the ACTIVE problem workspace, not the problems root. - const stateDir0 = activeStateDir(PROBLEMS) - if (!stateDir0 || !loadState(stateDir0)) { - 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 stateDir = activeStateDir(PROBLEMS) - expect(stateDir, 'active problem workspace exists').toBeDefined() - const state = loadState(stateDir!) - 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(stateDir!)) - expect(traversal.score_id).toBe('pulse-designer') - expect(traversal.funnel.map((f) => f.stage)).toContain('platform') - }) -}) +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"); + // Version-agnostic: SCORE.md version bumps must not rot this pin (it sat + // hardcoded at v1 while the score reached v3 — red on every creds machine). + expect(agents).toMatch(/Compiled from score `pulse-designer` v\d+/); + + 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). State + // now lives in the ACTIVE problem workspace, not the problems root. + const stateDir0 = activeStateDir(PROBLEMS); + if (!stateDir0 || !loadState(stateDir0)) { + 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 stateDir = activeStateDir(PROBLEMS); + expect(stateDir, "active problem workspace exists").toBeDefined(); + const state = loadState(stateDir!); + 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(stateDir!)); + expect(traversal.score_id).toBe("pulse-designer"); + expect(traversal.funnel.map((f) => f.stage)).toContain("platform"); + }, + ); +}); diff --git a/packages/extension/test/smoke_corpus.test.ts b/packages/extension/test/smoke_corpus.test.ts index 32f43ce7..15f85960 100644 --- a/packages/extension/test/smoke_corpus.test.ts +++ b/packages/extension/test/smoke_corpus.test.ts @@ -51,13 +51,18 @@ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); * offending await, not surface as an opaque suite hang. */ async function pumpUntil(m: RunsManager, pred: () => boolean, what: string, ms = 8000): Promise { const t0 = Date.now(); - while (!pred() && Date.now() - t0 < ms) { tick(m); await sleep(25); } + while (!pred() && Date.now() - t0 < ms) { + tick(m); + await sleep(25); + } tick(m); if (!pred()) throw new Error(`pumpUntil timed out after ${ms}ms waiting for: ${what}`); } describe("smoke corpus — Scheduler → executor → run-dir → RunsManager → inspector", () => { - beforeEach(() => { for (const f of Object.values(inspector)) f.mockClear(); }); + beforeEach(() => { + for (const f of Object.values(inspector)) f.mockClear(); + }); it("runs the corpus serially end-to-end; both runs tracked, runId-keyed, correct fidelity", async () => { const runsRoot = mkdtempSync(join(tmpdir(), "smoke-corpus-")); @@ -73,18 +78,18 @@ describe("smoke corpus — Scheduler → executor → run-dir → RunsManager const a = scheduler.enqueue({ scriptPath: join(CORPUS, "transmon_x.jl"), opts }); const b = scheduler.enqueue({ scriptPath: join(CORPUS, "cavity_displacement.jl"), opts }); - const ha = await a.handle; // head of queue — starts immediately + const ha = await a.handle; // head of queue — starts immediately await pumpUntil(m, () => existsSync(join(ha.runDir, "FINISHED")), "run A FINISHED on disk"); expect((await ha.finished).status).toBe("completed"); - const hb = await b.handle; // resolves only after A finished (serial) + const hb = await b.handle; // resolves only after A finished (serial) await pumpUntil(m, () => existsSync(join(hb.runDir, "FINISHED")), "run B FINISHED on disk"); expect((await hb.finished).status).toBe("completed"); // --- scheduler lifecycle: strict serial ordering, queueIds line up --- const seq = events.map((e) => `${e.kind}:${e.queueId}`); expect(seq.indexOf("finished:q1")).toBeGreaterThan(seq.indexOf("started:q1")); - expect(seq.indexOf("started:q2")).toBeGreaterThan(seq.indexOf("finished:q1")); // B started strictly after A finished + expect(seq.indexOf("started:q2")).toBeGreaterThan(seq.indexOf("finished:q1")); // B started strictly after A finished expect(seq.indexOf("finished:q2")).toBeGreaterThan(seq.indexOf("started:q2")); // --- run-dir contract on disk for BOTH runs (what the executor wrote is @@ -97,12 +102,22 @@ describe("smoke corpus — Scheduler → executor → run-dir → RunsManager } // --- registry: both finished, fidelity + iter high-water from the stream --- - await pumpUntil(m, () => m.runs().filter((r) => r.phase === "finished").length === 2, "both runs terminal in the registry"); + await pumpUntil( + m, + () => m.runs().filter((r) => r.phase === "finished").length === 2, + "both runs terminal in the registry", + ); expect(m.runs().find((r) => r.runId === ha.runId)).toMatchObject({ - phase: "finished", status: "completed", fidelity: 0.9993, latestIter: 4, + phase: "finished", + status: "completed", + fidelity: 0.9993, + latestIter: 4, }); expect(m.runs().find((r) => r.runId === hb.runId)).toMatchObject({ - phase: "finished", status: "completed", fidelity: 0.9981, latestIter: 3, + phase: "finished", + status: "completed", + fidelity: 0.9981, + latestIter: 3, }); // --- inspector fan-out: per-run, runId-keyed, no cross-tagging --- @@ -115,8 +130,12 @@ describe("smoke corpus — Scheduler → executor → run-dir → RunsManager // Pulse stream per run: meta + records with the fixture's shape, and every // record tagged with ITS run — dims prove no stream-crossing (A is 2×8, B is 1×6). const pulses = (rid: string) => inspector.postPulse.mock.calls.filter((c) => c[0] === rid).map((c) => c[1]); - const lastA = pulses(ha.runId).filter((e) => e.type === "record").at(-1); - const lastB = pulses(hb.runId).filter((e) => e.type === "record").at(-1); + const lastA = pulses(ha.runId) + .filter((e) => e.type === "record") + .at(-1); + const lastB = pulses(hb.runId) + .filter((e) => e.type === "record") + .at(-1); expect(pulses(ha.runId).some((e) => e.type === "meta" && e.meta.drives === 2 && e.meta.knots === 8)).toBe(true); expect(pulses(hb.runId).some((e) => e.type === "meta" && e.meta.drives === 1 && e.meta.knots === 6)).toBe(true); expect(lastA.record).toMatchObject({ iter: 4 }); @@ -140,7 +159,10 @@ describe("smoke corpus — Scheduler → executor → run-dir → RunsManager m.attachScheduler(scheduler); const promote = vi.spyOn(vscodeMock.window, "showInformationMessage"); - const f = scheduler.enqueue({ scriptPath: join(CORPUS, "failing_solve.jl"), opts: { runsRoot, julia: { julia: EMITTER } } }); + const f = scheduler.enqueue({ + scriptPath: join(CORPUS, "failing_solve.jl"), + opts: { runsRoot, julia: { julia: EMITTER } }, + }); const hf = await f.handle; await pumpUntil(m, () => existsSync(join(hf.runDir, "FINISHED")), "failing run FINISHED on disk"); expect((await hf.finished).status).toBe("failed"); @@ -148,7 +170,11 @@ describe("smoke corpus — Scheduler → executor → run-dir → RunsManager // Run-dir contract for the failure lane: FINISHED written by the EXECUTOR // (never the script), and no result.toml (the emitter dies before it). expect(existsSync(join(hf.runDir, "result.toml"))).toBe(false); - await pumpUntil(m, () => m.runs().find((r) => r.runId === hf.runId)?.phase === "finished", "failed run terminal in the registry"); + await pumpUntil( + m, + () => m.runs().find((r) => r.runId === hf.runId)?.phase === "finished", + "failed run terminal in the registry", + ); expect(m.runs().find((r) => r.runId === hf.runId)).toMatchObject({ phase: "finished", status: "failed" }); expect(m.runs().find((r) => r.runId === hf.runId)?.fidelity).toBeUndefined(); // …but the telemetry it emitted BEFORE dying was tracked (iters 0-2). diff --git a/packages/extension/test/watcher_contract.test.ts b/packages/extension/test/watcher_contract.test.ts index e6c57588..7085f380 100644 --- a/packages/extension/test/watcher_contract.test.ts +++ b/packages/extension/test/watcher_contract.test.ts @@ -1,215 +1,277 @@ -import { describe, it, expect, vi } from 'vitest' -import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { ingestRunDir, promoteEligibility, AMICODE_ITER_RE, parseAmicoNum, parsePulseMetaLine, parsePulseRecordLine, PulseStream, SinkDedup } from '../src/run_dir_reader' // pure β.1-contract reader (vscode-free) +import { describe, it, expect, vi } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + ingestRunDir, + promoteEligibility, + AMICODE_ITER_RE, + parseAmicoNum, + parsePulseMetaLine, + parsePulseRecordLine, + PulseStream, + SinkDedup, +} from "../src/run_dir_reader"; // pure β.1-contract reader (vscode-free) -function stageRun(opts: { status: string; exit: number; iters: number[]; fidelity?: number; tier?: string; agree?: boolean }): string { - const root = mkdtempSync(join(tmpdir(), 'runs-')) - const runId = 'r20260615-000000Z-ab12' - const dir = join(root, runId); mkdirSync(dir, { recursive: true }) - writeFileSync(join(dir, 'run.toml'), - `schema_version = "1"\nrun_id = "${runId}"\nscript_path = "/s.jl"\nlab = "default"\nlab_id = "default"\ncreated_at = "2026-06-15T00:00:00Z"\norchestrator_version = "0.1.0"\n[julia]\nbinary = "julia"\n`) - writeFileSync(join(dir, 'run.log'), opts.iters.map(k => `AMICODE_ITER iter=${k} f=0.1 inf_pr=1e-8 inf_du=1e-6`).join('\n') + '\n') - if (opts.fidelity !== undefined) writeFileSync(join(dir, 'result.toml'), `schema_version = "1"\nfidelity = ${opts.fidelity}\niterations = ${Math.max(...opts.iters, 0)}\n`) +function stageRun(opts: { + status: string; + exit: number; + iters: number[]; + fidelity?: number; + tier?: string; + agree?: boolean; +}): string { + const root = mkdtempSync(join(tmpdir(), "runs-")); + const runId = "r20260615-000000Z-ab12"; + const dir = join(root, runId); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "run.toml"), + `schema_version = "1"\nrun_id = "${runId}"\nscript_path = "/s.jl"\nlab = "default"\nlab_id = "default"\ncreated_at = "2026-06-15T00:00:00Z"\norchestrator_version = "0.1.0"\n[julia]\nbinary = "julia"\n`, + ); + writeFileSync( + join(dir, "run.log"), + opts.iters.map((k) => `AMICODE_ITER iter=${k} f=0.1 inf_pr=1e-8 inf_du=1e-6`).join("\n") + "\n", + ); + if (opts.fidelity !== undefined) + writeFileSync( + join(dir, "result.toml"), + `schema_version = "1"\nfidelity = ${opts.fidelity}\niterations = ${Math.max(...opts.iters, 0)}\n`, + ); // spec C: a --spec launch persists solvespec.json; free tier gates promotion - if (opts.tier !== undefined) writeFileSync(join(dir, 'solvespec.json'), JSON.stringify({ schema_version: '2', script_path: '/s.jl', lab_id: 'default', tier: opts.tier })) - if (opts.agree !== undefined) writeFileSync(join(dir, 'verification.toml'), `schema_version = "1"\nagree = ${opts.agree}\n`) - writeFileSync(join(dir, 'FINISHED'), `status = "${opts.status}"\nexit_code = ${opts.exit}\n`) - return dir + if (opts.tier !== undefined) + writeFileSync( + join(dir, "solvespec.json"), + JSON.stringify({ schema_version: "2", script_path: "/s.jl", lab_id: "default", tier: opts.tier }), + ); + if (opts.agree !== undefined) + writeFileSync(join(dir, "verification.toml"), `schema_version = "1"\nagree = ${opts.agree}\n`); + writeFileSync(join(dir, "FINISHED"), `status = "${opts.status}"\nexit_code = ${opts.exit}\n`); + return dir; } -const fakeSink = () => ({ iter: vi.fn(), run: vi.fn(), promote: vi.fn(), pulse: vi.fn() }) +const fakeSink = () => ({ iter: vi.fn(), run: vi.fn(), promote: vi.fn(), pulse: vi.fn() }); -describe('ingestRunDir — β.1 contract reading (replay)', () => { - it('completed run: identity from manifest, run.log→iter, FINISHED→completed, promote on F≥0.99', () => { - const sink = fakeSink() - ingestRunDir(stageRun({ status: 'completed', exit: 0, iters: [1, 7, 142], fidelity: 0.9991 }), sink) - expect(sink.iter).toHaveBeenCalledWith(expect.objectContaining({ iter: 142 })) // run.log parsed on REPLAY - expect(sink.run).toHaveBeenCalledWith(expect.objectContaining({ status: 'completed', fidelity: 0.9991 })) - expect(sink.promote).toHaveBeenCalled() - }) - it('failed run: FINISHED→failed, no promote', () => { - const sink = fakeSink() - ingestRunDir(stageRun({ status: 'failed', exit: 3, iters: [1], fidelity: 0.4 }), sink) - expect(sink.run).toHaveBeenCalledWith(expect.objectContaining({ status: 'failed' })) - expect(sink.promote).not.toHaveBeenCalled() - }) - it('aborted run: FINISHED→aborted, no promote', () => { - const sink = fakeSink() - ingestRunDir(stageRun({ status: 'aborted', exit: 143, iters: [] }), sink) - expect(sink.run).toHaveBeenCalledWith(expect.objectContaining({ status: 'aborted' })) - expect(sink.promote).not.toHaveBeenCalled() - }) - it('completed but F<0.99: no promote', () => { - const sink = fakeSink() - ingestRunDir(stageRun({ status: 'completed', exit: 0, iters: [1], fidelity: 0.5 }), sink) - expect(sink.promote).not.toHaveBeenCalled() - }) +describe("ingestRunDir — β.1 contract reading (replay)", () => { + it("completed run: identity from manifest, run.log→iter, FINISHED→completed, promote on F≥0.99", () => { + const sink = fakeSink(); + ingestRunDir(stageRun({ status: "completed", exit: 0, iters: [1, 7, 142], fidelity: 0.9991 }), sink); + expect(sink.iter).toHaveBeenCalledWith(expect.objectContaining({ iter: 142 })); // run.log parsed on REPLAY + expect(sink.run).toHaveBeenCalledWith(expect.objectContaining({ status: "completed", fidelity: 0.9991 })); + expect(sink.promote).toHaveBeenCalled(); + }); + it("failed run: FINISHED→failed, no promote", () => { + const sink = fakeSink(); + ingestRunDir(stageRun({ status: "failed", exit: 3, iters: [1], fidelity: 0.4 }), sink); + expect(sink.run).toHaveBeenCalledWith(expect.objectContaining({ status: "failed" })); + expect(sink.promote).not.toHaveBeenCalled(); + }); + it("aborted run: FINISHED→aborted, no promote", () => { + const sink = fakeSink(); + ingestRunDir(stageRun({ status: "aborted", exit: 143, iters: [] }), sink); + expect(sink.run).toHaveBeenCalledWith(expect.objectContaining({ status: "aborted" })); + expect(sink.promote).not.toHaveBeenCalled(); + }); + it("completed but F<0.99: no promote", () => { + const sink = fakeSink(); + ingestRunDir(stageRun({ status: "completed", exit: 0, iters: [1], fidelity: 0.5 }), sink); + expect(sink.promote).not.toHaveBeenCalled(); + }); // spec C: rendering stays tier-blind (sink.run always fires); promotion is gated - describe('free-tier verification gates promotion (spec C)', () => { - it('(a) no solvespec.json (bare run) → promote fires, unchanged', () => { - const sink = fakeSink() - ingestRunDir(stageRun({ status: 'completed', exit: 0, iters: [1], fidelity: 0.999 }), sink) - expect(sink.promote).toHaveBeenCalled() - }) - it('(b) tier=free, no verification.toml → NO promote, but run STILL rendered (tier-blind)', () => { - const sink = fakeSink() - ingestRunDir(stageRun({ status: 'completed', exit: 0, iters: [1], fidelity: 0.999, tier: 'free' }), sink) - expect(sink.run).toHaveBeenCalled() - expect(sink.promote).not.toHaveBeenCalled() - }) - it('(c) tier=free + agree=true → promote', () => { - const sink = fakeSink() - ingestRunDir(stageRun({ status: 'completed', exit: 0, iters: [1], fidelity: 0.999, tier: 'free', agree: true }), sink) - expect(sink.promote).toHaveBeenCalled() - }) - it('(d) tier=free + agree=false → NO promote', () => { - const sink = fakeSink() - ingestRunDir(stageRun({ status: 'completed', exit: 0, iters: [1], fidelity: 0.999, tier: 'free', agree: false }), sink) - expect(sink.promote).not.toHaveBeenCalled() - }) - it('(e) tier=vetted, no verification → promote (only free is gated)', () => { - const sink = fakeSink() - ingestRunDir(stageRun({ status: 'completed', exit: 0, iters: [1], fidelity: 0.999, tier: 'vetted' }), sink) - expect(sink.promote).toHaveBeenCalled() - }) - it('promoteEligibility: eligible / pending_verification / suppressed / eligible-when-agree', () => { - expect(promoteEligibility(stageRun({ status: 'completed', exit: 0, iters: [1], fidelity: 0.999 }))).toBe('eligible') - expect(promoteEligibility(stageRun({ status: 'completed', exit: 0, iters: [1], fidelity: 0.999, tier: 'free' }))).toBe('pending_verification') - expect(promoteEligibility(stageRun({ status: 'completed', exit: 0, iters: [1], fidelity: 0.999, tier: 'free', agree: false }))).toBe('suppressed') - expect(promoteEligibility(stageRun({ status: 'completed', exit: 0, iters: [1], fidelity: 0.999, tier: 'free', agree: true }))).toBe('eligible') - }) - }) - it('returns the run.log byte offset (so the live tailer attaches without skipping iters)', () => { - const sink = fakeSink() - const bytes = ingestRunDir(stageRun({ status: 'completed', exit: 0, iters: [1, 2], fidelity: 0.999 }), sink) - expect(bytes).toBeGreaterThan(0) // = byte length of run.log consumed during replay - }) + describe("free-tier verification gates promotion (spec C)", () => { + it("(a) no solvespec.json (bare run) → promote fires, unchanged", () => { + const sink = fakeSink(); + ingestRunDir(stageRun({ status: "completed", exit: 0, iters: [1], fidelity: 0.999 }), sink); + expect(sink.promote).toHaveBeenCalled(); + }); + it("(b) tier=free, no verification.toml → NO promote, but run STILL rendered (tier-blind)", () => { + const sink = fakeSink(); + ingestRunDir(stageRun({ status: "completed", exit: 0, iters: [1], fidelity: 0.999, tier: "free" }), sink); + expect(sink.run).toHaveBeenCalled(); + expect(sink.promote).not.toHaveBeenCalled(); + }); + it("(c) tier=free + agree=true → promote", () => { + const sink = fakeSink(); + ingestRunDir( + stageRun({ status: "completed", exit: 0, iters: [1], fidelity: 0.999, tier: "free", agree: true }), + sink, + ); + expect(sink.promote).toHaveBeenCalled(); + }); + it("(d) tier=free + agree=false → NO promote", () => { + const sink = fakeSink(); + ingestRunDir( + stageRun({ status: "completed", exit: 0, iters: [1], fidelity: 0.999, tier: "free", agree: false }), + sink, + ); + expect(sink.promote).not.toHaveBeenCalled(); + }); + it("(e) tier=vetted, no verification → promote (only free is gated)", () => { + const sink = fakeSink(); + ingestRunDir(stageRun({ status: "completed", exit: 0, iters: [1], fidelity: 0.999, tier: "vetted" }), sink); + expect(sink.promote).toHaveBeenCalled(); + }); + it("promoteEligibility: eligible / pending_verification / suppressed / eligible-when-agree", () => { + expect(promoteEligibility(stageRun({ status: "completed", exit: 0, iters: [1], fidelity: 0.999 }))).toBe( + "eligible", + ); + expect( + promoteEligibility(stageRun({ status: "completed", exit: 0, iters: [1], fidelity: 0.999, tier: "free" })), + ).toBe("pending_verification"); + expect( + promoteEligibility( + stageRun({ status: "completed", exit: 0, iters: [1], fidelity: 0.999, tier: "free", agree: false }), + ), + ).toBe("suppressed"); + expect( + promoteEligibility( + stageRun({ status: "completed", exit: 0, iters: [1], fidelity: 0.999, tier: "free", agree: true }), + ), + ).toBe("eligible"); + }); + }); + it("returns the run.log byte offset (so the live tailer attaches without skipping iters)", () => { + const sink = fakeSink(); + const bytes = ingestRunDir(stageRun({ status: "completed", exit: 0, iters: [1, 2], fidelity: 0.999 }), sink); + expect(bytes).toBeGreaterThan(0); // = byte length of run.log consumed during replay + }); - it('pulse lines on replay: forwards the meta plus ONLY the newest record (no history burst at the webview)', () => { - const sink = fakeSink() - const dir = stageRun({ status: 'completed', exit: 0, iters: [1, 2, 3], fidelity: 0.999 }) - writeFileSync(join(dir, 'run.log'), + it("pulse lines on replay: forwards the meta plus ONLY the newest record (no history burst at the webview)", () => { + const sink = fakeSink(); + const dir = stageRun({ status: "completed", exit: 0, iters: [1, 2, 3], fidelity: 0.999 }); + writeFileSync( + join(dir, "run.log"), 'AMICODE_PULSE_META drives=1 knots=2 labels="u_1" bounds=-0.2:0.2\n' + - 'AMICODE_PULSE iter=1 dt=0.2 a=0.1,0.2\n' + - 'AMICODE_ITER iter=1 f=0.1 inf_pr=1e-8 inf_du=1e-6\n' + - 'AMICODE_PULSE iter=2 dt=0.2 a=0.3,0.4\n' + - 'AMICODE_PULSE iter=3 dt=0.2 a=0.5,0.6\n') - ingestRunDir(dir, sink) - expect(sink.pulse).toHaveBeenCalledTimes(2) - expect(sink.pulse).toHaveBeenNthCalledWith(1, expect.objectContaining({ type: 'meta' })) - expect(sink.pulse).toHaveBeenNthCalledWith(2, expect.objectContaining({ type: 'record', record: expect.objectContaining({ iter: 3 }) })) - }) + "AMICODE_PULSE iter=1 dt=0.2 a=0.1,0.2\n" + + "AMICODE_ITER iter=1 f=0.1 inf_pr=1e-8 inf_du=1e-6\n" + + "AMICODE_PULSE iter=2 dt=0.2 a=0.3,0.4\n" + + "AMICODE_PULSE iter=3 dt=0.2 a=0.5,0.6\n", + ); + ingestRunDir(dir, sink); + expect(sink.pulse).toHaveBeenCalledTimes(2); + expect(sink.pulse).toHaveBeenNthCalledWith(1, expect.objectContaining({ type: "meta" })); + expect(sink.pulse).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ type: "record", record: expect.objectContaining({ iter: 3 }) }), + ); + }); - it('pulse-less run.log: sink.pulse never fires (runs render exactly as today)', () => { - const sink = fakeSink() - ingestRunDir(stageRun({ status: 'completed', exit: 0, iters: [1], fidelity: 0.999 }), sink) - expect(sink.pulse).not.toHaveBeenCalled() - }) -}) + it("pulse-less run.log: sink.pulse never fires (runs render exactly as today)", () => { + const sink = fakeSink(); + ingestRunDir(stageRun({ status: "completed", exit: 0, iters: [1], fidelity: 0.999 }), sink); + expect(sink.pulse).not.toHaveBeenCalled(); + }); +}); -describe('AMICODE_ITER parsing — Inf/NaN are kept, not dropped', () => { - it('matches blow-up / stagnation iters (Inf, -Inf, NaN), matching amico-run', () => { - expect(AMICODE_ITER_RE.test('AMICODE_ITER iter=3 f=Inf inf_pr=NaN inf_du=-Inf')).toBe(true) - expect(AMICODE_ITER_RE.test('AMICODE_ITER iter=4 f=1.2e-03 inf_pr=5e-9 inf_du=2.3')).toBe(true) - }) - it('parseAmicoNum maps Julia Inf/NaN to JS values', () => { - expect(parseAmicoNum('Inf')).toBe(Infinity) - expect(parseAmicoNum('-Inf')).toBe(-Infinity) - expect(Number.isNaN(parseAmicoNum('NaN'))).toBe(true) - expect(parseAmicoNum('1.5e-3')).toBeCloseTo(0.0015) - }) -}) +describe("AMICODE_ITER parsing — Inf/NaN are kept, not dropped", () => { + it("matches blow-up / stagnation iters (Inf, -Inf, NaN), matching amico-run", () => { + expect(AMICODE_ITER_RE.test("AMICODE_ITER iter=3 f=Inf inf_pr=NaN inf_du=-Inf")).toBe(true); + expect(AMICODE_ITER_RE.test("AMICODE_ITER iter=4 f=1.2e-03 inf_pr=5e-9 inf_du=2.3")).toBe(true); + }); + it("parseAmicoNum maps Julia Inf/NaN to JS values", () => { + expect(parseAmicoNum("Inf")).toBe(Infinity); + expect(parseAmicoNum("-Inf")).toBe(-Infinity); + expect(Number.isNaN(parseAmicoNum("NaN"))).toBe(true); + expect(parseAmicoNum("1.5e-3")).toBeCloseTo(0.0015); + }); +}); // Pulse-line grammar (#66) — the candidate GA format for client-side pulse // rendering. Additive to the run.log stdout tee: consumers that don't know // these lines ignore them (anchored regex no-match), so the β contract freeze // is untouched. -describe('AMICODE_PULSE_META parsing (#66 pinned grammar)', () => { - it('parses drives/knots/labels/bounds from a well-formed meta line', () => { - const m = parsePulseMetaLine('AMICODE_PULSE_META drives=2 knots=50 labels="u_1","u_2" bounds=-0.2:0.2,-0.2:0.2') +describe("AMICODE_PULSE_META parsing (#66 pinned grammar)", () => { + it("parses drives/knots/labels/bounds from a well-formed meta line", () => { + const m = parsePulseMetaLine('AMICODE_PULSE_META drives=2 knots=50 labels="u_1","u_2" bounds=-0.2:0.2,-0.2:0.2'); expect(m).toEqual({ drives: 2, knots: 50, - labels: ['u_1', 'u_2'], - bounds: [[-0.2, 0.2], [-0.2, 0.2]], - }) - }) -}) + labels: ["u_1", "u_2"], + bounds: [ + [-0.2, 0.2], + [-0.2, 0.2], + ], + }); + }); +}); -describe('AMICODE_PULSE record parsing (#66 pinned grammar)', () => { - it('parses iter/dt and per-drive value lists (drives ;-separated, values ,-separated)', () => { - const r = parsePulseRecordLine('AMICODE_PULSE iter=6 dt=0.204082 a=0.021,-0.013,1.2e-3;0.008,0.031,-4e-2') +describe("AMICODE_PULSE record parsing (#66 pinned grammar)", () => { + it("parses iter/dt and per-drive value lists (drives ;-separated, values ,-separated)", () => { + const r = parsePulseRecordLine("AMICODE_PULSE iter=6 dt=0.204082 a=0.021,-0.013,1.2e-3;0.008,0.031,-4e-2"); expect(r).toEqual({ iter: 6, dt: 0.204082, - values: [[0.021, -0.013, 0.0012], [0.008, 0.031, -0.04]], - }) - }) - it('keeps Inf/NaN values, matching the stats parser', () => { - const r = parsePulseRecordLine('AMICODE_PULSE iter=3 dt=0.2 a=Inf,-Inf;NaN,0.5') - expect(r!.values[0]).toEqual([Infinity, -Infinity]) - expect(Number.isNaN(r!.values[1][0])).toBe(true) - expect(r!.values[1][1]).toBe(0.5) - }) -}) + values: [ + [0.021, -0.013, 0.0012], + [0.008, 0.031, -0.04], + ], + }); + }); + it("keeps Inf/NaN values, matching the stats parser", () => { + const r = parsePulseRecordLine("AMICODE_PULSE iter=3 dt=0.2 a=Inf,-Inf;NaN,0.5"); + expect(r!.values[0]).toEqual([Infinity, -Infinity]); + expect(Number.isNaN(r!.values[1][0])).toBe(true); + expect(r!.values[1][1]).toBe(0.5); + }); +}); // PulseStream — the cross-line policy both delivery paths (replay ingest, live // tail) feed lines through. Policy per #66 AC4: records before any meta are // dropped; the last meta wins and resets state; count-mismatched records and // internally-inconsistent metas are ignored. -describe('PulseStream — cross-line policy (#66)', () => { - const META = 'AMICODE_PULSE_META drives=2 knots=3 labels="u_1","u_2" bounds=-0.2:0.2,-0.2:0.2' - const REC = 'AMICODE_PULSE iter=6 dt=0.2 a=0.1,0.2,0.3;0.4,0.5,0.6' +describe("PulseStream — cross-line policy (#66)", () => { + const META = 'AMICODE_PULSE_META drives=2 knots=3 labels="u_1","u_2" bounds=-0.2:0.2,-0.2:0.2'; + const REC = "AMICODE_PULSE iter=6 dt=0.2 a=0.1,0.2,0.3;0.4,0.5,0.6"; - it('drops records that arrive before any meta', () => { - const ps = new PulseStream() - expect(ps.onLine(REC)).toBeUndefined() - expect(ps.onLine(META)).toMatchObject({ type: 'meta' }) - expect(ps.onLine(REC)).toMatchObject({ type: 'record', record: { iter: 6 } }) - }) + it("drops records that arrive before any meta", () => { + const ps = new PulseStream(); + expect(ps.onLine(REC)).toBeUndefined(); + expect(ps.onLine(META)).toMatchObject({ type: "meta" }); + expect(ps.onLine(REC)).toMatchObject({ type: "record", record: { iter: 6 } }); + }); - it('ignores records whose drive count or knot count disagree with the current meta', () => { - const ps = new PulseStream() - ps.onLine(META) // drives=2 knots=3 - expect(ps.onLine('AMICODE_PULSE iter=1 dt=0.2 a=0.1,0.2,0.3')).toBeUndefined() // 1 drive ≠ 2 - expect(ps.onLine('AMICODE_PULSE iter=2 dt=0.2 a=0.1,0.2;0.3,0.4')).toBeUndefined() // 2 knots ≠ 3 - expect(ps.onLine(REC)).toMatchObject({ type: 'record' }) // conformant still flows - }) + it("ignores records whose drive count or knot count disagree with the current meta", () => { + const ps = new PulseStream(); + ps.onLine(META); // drives=2 knots=3 + expect(ps.onLine("AMICODE_PULSE iter=1 dt=0.2 a=0.1,0.2,0.3")).toBeUndefined(); // 1 drive ≠ 2 + expect(ps.onLine("AMICODE_PULSE iter=2 dt=0.2 a=0.1,0.2;0.3,0.4")).toBeUndefined(); // 2 knots ≠ 3 + expect(ps.onLine(REC)).toMatchObject({ type: "record" }); // conformant still flows + }); - it('treats a meta whose label or bounds count disagrees with drives= as malformed (no state change)', () => { - const ps = new PulseStream() - expect(ps.onLine('AMICODE_PULSE_META drives=2 knots=3 labels="u_1" bounds=-0.2:0.2,-0.2:0.2')).toBeUndefined() // 1 label ≠ 2 drives - expect(ps.onLine('AMICODE_PULSE_META drives=2 knots=3 labels="u_1","u_2" bounds=-0.2:0.2')).toBeUndefined() // 1 bound ≠ 2 drives - expect(ps.onLine(REC)).toBeUndefined() // bad metas did NOT arm the stream - }) + it("treats a meta whose label or bounds count disagrees with drives= as malformed (no state change)", () => { + const ps = new PulseStream(); + expect(ps.onLine('AMICODE_PULSE_META drives=2 knots=3 labels="u_1" bounds=-0.2:0.2,-0.2:0.2')).toBeUndefined(); // 1 label ≠ 2 drives + expect(ps.onLine('AMICODE_PULSE_META drives=2 knots=3 labels="u_1","u_2" bounds=-0.2:0.2')).toBeUndefined(); // 1 bound ≠ 2 drives + expect(ps.onLine(REC)).toBeUndefined(); // bad metas did NOT arm the stream + }); - it('last meta wins: a re-read meta (tailer truncation re-read) re-arms cleanly and its shape governs', () => { - const ps = new PulseStream() - ps.onLine(META) - ps.onLine(REC) + it("last meta wins: a re-read meta (tailer truncation re-read) re-arms cleanly and its shape governs", () => { + const ps = new PulseStream(); + ps.onLine(META); + ps.onLine(REC); // duplicate meta (offset-0 re-read) — same shape, still fine - expect(ps.onLine(META)).toMatchObject({ type: 'meta' }) - expect(ps.onLine(REC)).toMatchObject({ type: 'record' }) + expect(ps.onLine(META)).toMatchObject({ type: "meta" }); + expect(ps.onLine(REC)).toMatchObject({ type: "record" }); // a NEW meta with a different shape governs subsequent records - expect(ps.onLine('AMICODE_PULSE_META drives=1 knots=2 labels="u_1" bounds=-0.1:0.1')).toMatchObject({ type: 'meta' }) - expect(ps.onLine(REC)).toBeUndefined() // old-shape record now ignored - expect(ps.onLine('AMICODE_PULSE iter=9 dt=0.2 a=0.1,0.2')).toMatchObject({ type: 'record', record: { iter: 9 } }) - }) -}) + expect(ps.onLine('AMICODE_PULSE_META drives=1 knots=2 labels="u_1" bounds=-0.1:0.1')).toMatchObject({ + type: "meta", + }); + expect(ps.onLine(REC)).toBeUndefined(); // old-shape record now ignored + expect(ps.onLine("AMICODE_PULSE iter=9 dt=0.2 a=0.1,0.2")).toMatchObject({ type: "record", record: { iter: 9 } }); + }); +}); // SinkDedup — the live sink's iteration high-water mark (status bar / completion). -describe('SinkDedup — iteration high-water mark', () => { - it('high() tracks the max iter seen; out-of-order notes never regress it', () => { - const d = new SinkDedup() - expect(d.high).toBe(-1) - d.noteIter(42) - expect(d.high).toBe(42) - d.noteIter(7) - expect(d.high).toBe(42) - d.noteIter(60) - expect(d.high).toBe(60) - }) -}) +describe("SinkDedup — iteration high-water mark", () => { + it("high() tracks the max iter seen; out-of-order notes never regress it", () => { + const d = new SinkDedup(); + expect(d.high).toBe(-1); + d.noteIter(42); + expect(d.high).toBe(42); + d.noteIter(7); + expect(d.high).toBe(42); + d.noteIter(60); + expect(d.high).toBe(60); + }); +}); From 754bf085a305461ba01fc9f4d50ec226c1e279ac Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Tue, 7 Jul 2026 05:26:51 -0400 Subject: [PATCH 120/135] style: prettier over the branch's touched amico-run files (missed in the previous style commit) Co-Authored-By: Claude Fable 5 --- packages/amico-run/.DS_Store | Bin 6148 -> 6148 bytes packages/amico-run/src/index.ts | 14 +- packages/amico-run/src/scheduler.ts | 133 ++++--- packages/amico-run/test/scheduler.test.ts | 429 ++++++++++++---------- 4 files changed, 306 insertions(+), 270 deletions(-) diff --git a/packages/amico-run/.DS_Store b/packages/amico-run/.DS_Store index b67d98eb715c6c2905f5e0ba3612aed949170f5e..6d06e1ee76f216b280050d72bf98f6715cb04e25 100644 GIT binary patch delta 22 ecmZoMXffFEhL!2I?c{f?1x)f=HYczx5d;8fmI%E7 delta 22 ecmZoMXffFEhL!2o%*pRq3z+2YZcboZA_xF)BMCqN diff --git a/packages/amico-run/src/index.ts b/packages/amico-run/src/index.ts index 7fd1e8e4..dcd0ab44 100644 --- a/packages/amico-run/src/index.ts +++ b/packages/amico-run/src/index.ts @@ -1,7 +1,7 @@ -export * from './types.js' -export * from './telemetry.js' -export * from './run_dir.js' -export * from './schemas.js' -export * from './event_queue.js' -export * from './local_executor.js' -export * from './scheduler.js' +export * from "./types.js"; +export * from "./telemetry.js"; +export * from "./run_dir.js"; +export * from "./schemas.js"; +export * from "./event_queue.js"; +export * from "./local_executor.js"; +export * from "./scheduler.js"; diff --git a/packages/amico-run/src/scheduler.ts b/packages/amico-run/src/scheduler.ts index dd1153bf..82d434ff 100644 --- a/packages/amico-run/src/scheduler.ts +++ b/packages/amico-run/src/scheduler.ts @@ -1,4 +1,4 @@ -import { ConfigError, type Executor, type RunHandle, type RunStatus, type SubmitOpts } from './types.js' +import { ConfigError, type Executor, type RunHandle, type RunStatus, type SubmitOpts } from "./types.js"; // ============================================================================ // Scheduler (Phase 1.1, #56) — a serial run queue built TO the ratified @@ -26,140 +26,155 @@ import { ConfigError, type Executor, type RunHandle, type RunStatus, type Submit /** What to run when this entry reaches the head of the queue. */ export interface SubmitSpec { - scriptPath: string + scriptPath: string; /** Passed to Executor.submit verbatim (lab pointer, runsRoot, julia opts…). */ - opts?: SubmitOpts + opts?: SubmitOpts; } export interface EnqueueOpts { /** Phase-4 seam (opt-in parallel lane) — NOT implemented; throws ConfigError. */ - concurrent?: boolean + concurrent?: boolean; } /** Run lifecycle the RunsManager / StatusBar consume (1.2). `queueId` is the * Scheduler's own id (assigned at enqueue, before any run exists); `runId` * appears once the executor has admitted the run. */ export type SchedulerEvent = - | { kind: 'queued'; queueId: string; position: number } - | { kind: 'started'; queueId: string; runId: string; runDir: string } - | { kind: 'finished'; queueId: string; runId: string; status: RunStatus; exitCode: number } - | { kind: 'cancelled'; queueId: string } - | { kind: 'error'; queueId: string; message: string } + | { kind: "queued"; queueId: string; position: number } + | { kind: "started"; queueId: string; runId: string; runDir: string } + | { kind: "finished"; queueId: string; runId: string; status: RunStatus; exitCode: number } + | { kind: "cancelled"; queueId: string } + | { kind: "error"; queueId: string; message: string }; export interface ScheduledRun { - queueId: string + queueId: string; /** Resolves with the executor's RunHandle when this entry reaches the head * of the queue and submit() succeeds. Rejects if the entry is cancelled * before starting, or if submit() throws (e.g. ConfigError). */ - handle: Promise + handle: Promise; /** Dequeue BEFORE start: true iff the entry was still queued (it will never * run). False in every other case — already started, already cancelled, or * mid-submit (shifted but `started` not yet emitted; `handle` may still * REJECT if that submit fails). To stop a live run, `await handle` (in a * try/catch) and call RunHandle.abort() — a request, per contract (b); * never via the queue. */ - cancel(): boolean + cancel(): boolean; } interface Entry { - queueId: string - spec: SubmitSpec - resolve: (h: RunHandle) => void - reject: (e: Error) => void + queueId: string; + spec: SubmitSpec; + resolve: (h: RunHandle) => void; + reject: (e: Error) => void; } export class Scheduler { - private readonly queue: Entry[] = [] - private running = false - private nextId = 1 - private readonly listeners = new Set<(e: SchedulerEvent) => void>() + private readonly queue: Entry[] = []; + private running = false; + private nextId = 1; + private readonly listeners = new Set<(e: SchedulerEvent) => void>(); constructor(private readonly executor: Executor) {} /** Subscribe to lifecycle events. Returns a dispose function. Multi-consumer * (RunsManager + StatusBar); a throwing listener is isolated. */ onEvent(listener: (e: SchedulerEvent) => void): () => void { - this.listeners.add(listener) - return () => { this.listeners.delete(listener) } + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; } /** Queued + running entries — 0 means an enqueue() would start immediately. */ get depth(): number { - return this.queue.length + (this.running ? 1 : 0) + return this.queue.length + (this.running ? 1 : 0); } enqueue(spec: SubmitSpec, opts: EnqueueOpts = {}): ScheduledRun { if (opts.concurrent) { - throw new ConfigError('Scheduler: the parallel lane (concurrent: true) is deferred to Phase 4 — runs are serial') + throw new ConfigError("Scheduler: the parallel lane (concurrent: true) is deferred to Phase 4 — runs are serial"); } - const queueId = `q${this.nextId++}` - let resolve!: (h: RunHandle) => void - let reject!: (e: Error) => void - const handle = new Promise((res, rej) => { resolve = res; reject = rej }) + const queueId = `q${this.nextId++}`; + let resolve!: (h: RunHandle) => void; + let reject!: (e: Error) => void; + const handle = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); // The Scheduler itself observes failures (error event) — callers that only // consume events must not trip an unhandled-rejection on the same promise. - handle.catch(() => {}) - const entry: Entry = { queueId, spec, resolve, reject } - this.queue.push(entry) - this.emit({ kind: 'queued', queueId, position: this.queue.length - 1 + (this.running ? 1 : 0) }) - void this.pump() + handle.catch(() => {}); + const entry: Entry = { queueId, spec, resolve, reject }; + this.queue.push(entry); + this.emit({ kind: "queued", queueId, position: this.queue.length - 1 + (this.running ? 1 : 0) }); + void this.pump(); return { queueId, handle, cancel: (): boolean => { - const i = this.queue.indexOf(entry) - if (i === -1) return false // already started (or done) — abort via the handle - this.queue.splice(i, 1) - this.emit({ kind: 'cancelled', queueId }) - entry.reject(new Error(`Scheduler: ${queueId} cancelled before start`)) - return true + const i = this.queue.indexOf(entry); + if (i === -1) return false; // already started (or done) — abort via the handle + this.queue.splice(i, 1); + this.emit({ kind: "cancelled", queueId }); + entry.reject(new Error(`Scheduler: ${queueId} cancelled before start`)); + return true; }, - } + }; } // -------- internal -------- private emit(e: SchedulerEvent): void { for (const l of this.listeners) { - try { l(e) } catch { /* a bad listener must not wedge the pump */ } + try { + l(e); + } catch { + /* a bad listener must not wedge the pump */ + } } } /** The serial pump: one entry at a time; advances ONLY on `finished` * resolution (contract (b) — never on abort(), which is just a request). */ private async pump(): Promise { - if (this.running) return - const entry = this.queue.shift() - if (!entry) return - this.running = true + if (this.running) return; + const entry = this.queue.shift(); + if (!entry) return; + this.running = true; try { - let handle: RunHandle + let handle: RunHandle; try { - handle = await this.executor.submit(entry.spec.scriptPath, entry.spec.opts) + handle = await this.executor.submit(entry.spec.scriptPath, entry.spec.opts); } catch (e) { - const err = e instanceof Error ? e : new Error(String(e)) - this.emit({ kind: 'error', queueId: entry.queueId, message: err.message }) - entry.reject(err) - return // finally advances the queue — a config failure must not wedge it + const err = e instanceof Error ? e : new Error(String(e)); + this.emit({ kind: "error", queueId: entry.queueId, message: err.message }); + entry.reject(err); + return; // finally advances the queue — a config failure must not wedge it } - this.emit({ kind: 'started', queueId: entry.queueId, runId: handle.runId, runDir: handle.runDir }) - entry.resolve(handle) + this.emit({ kind: "started", queueId: entry.queueId, runId: handle.runId, runDir: handle.runDir }); + entry.resolve(handle); try { - const fin = await handle.finished // contract: never rejects… - this.emit({ kind: 'finished', queueId: entry.queueId, runId: handle.runId, status: fin.status, exitCode: fin.exitCode }) + const fin = await handle.finished; // contract: never rejects… + this.emit({ + kind: "finished", + queueId: entry.queueId, + runId: handle.runId, + status: fin.status, + exitCode: fin.exitCode, + }); } catch (e) { // …but a rogue executor breaking that must not deadlock every queued run. - const msg = e instanceof Error ? e.message : String(e) - this.emit({ kind: 'error', queueId: entry.queueId, message: `finished rejected: ${msg}` }) + const msg = e instanceof Error ? e.message : String(e); + this.emit({ kind: "error", queueId: entry.queueId, message: `finished rejected: ${msg}` }); } } finally { - this.running = false + this.running = false; // Microtask deferral, NOT a direct call: a contract-violating executor // whose submit() throws SYNCHRONOUSLY would otherwise make this finally // direct recursion — a long backlog of such failures blows the stack and // strands the rest of the queue. Deferring one microtask keeps the chain // flat regardless of how the executor misbehaves. - queueMicrotask(() => void this.pump()) + queueMicrotask(() => void this.pump()); } } } diff --git a/packages/amico-run/test/scheduler.test.ts b/packages/amico-run/test/scheduler.test.ts index 4bf78981..be538dab 100644 --- a/packages/amico-run/test/scheduler.test.ts +++ b/packages/amico-run/test/scheduler.test.ts @@ -1,7 +1,14 @@ -import { describe, it, expect } from 'vitest' -import { Scheduler, type SchedulerEvent } from '../src/scheduler.js' -import { ConfigError, type Executor, type Finished, type RunEvent, type RunHandle, type SubmitOpts } from '../src/types.js' -import { EventQueue } from '../src/event_queue.js' +import { describe, it, expect } from "vitest"; +import { Scheduler, type SchedulerEvent } from "../src/scheduler.js"; +import { + ConfigError, + type Executor, + type Finished, + type RunEvent, + type RunHandle, + type SubmitOpts, +} from "../src/types.js"; +import { EventQueue } from "../src/event_queue.js"; // 1.1 Scheduler (#56) — serial queue built TO the ratified Executor contract // (Track C spec, locked 2026-07-02). The load-bearing behaviors under test: @@ -14,207 +21,218 @@ import { EventQueue } from '../src/event_queue.js' /** Controllable fake executor: each submit() returns a handle whose `finished` * the TEST resolves. Records submit order/args. */ class FakeExecutor implements Executor { - submits: Array<{ scriptPath: string; opts?: SubmitOpts }> = [] - handles: Array<{ handle: RunHandle; finish: (f: Finished) => void; aborted: boolean[] }> = [] + submits: Array<{ scriptPath: string; opts?: SubmitOpts }> = []; + handles: Array<{ handle: RunHandle; finish: (f: Finished) => void; aborted: boolean[] }> = []; /** scripts whose submit() should throw ConfigError */ - failFor = new Set() + failFor = new Set(); async submit(scriptPath: string, opts?: SubmitOpts): Promise { - this.submits.push({ scriptPath, opts }) - if (this.failFor.has(scriptPath)) throw new ConfigError(`bad config: ${scriptPath}`) - const n = this.submits.length - let finish!: (f: Finished) => void - const finished = new Promise(r => { finish = r }) - const aborted: boolean[] = [] + this.submits.push({ scriptPath, opts }); + if (this.failFor.has(scriptPath)) throw new ConfigError(`bad config: ${scriptPath}`); + const n = this.submits.length; + let finish!: (f: Finished) => void; + const finished = new Promise((r) => { + finish = r; + }); + const aborted: boolean[] = []; const handle: RunHandle = { runId: `run-${n}`, runDir: `/runs/run-${n}`, events: new EventQueue(), finished, // Contract (b): abort resolves only when finished does (request, not kill). - abort: async () => { aborted.push(true); await finished }, - } - this.handles.push({ handle, finish, aborted }) - return handle + abort: async () => { + aborted.push(true); + await finished; + }, + }; + this.handles.push({ handle, finish, aborted }); + return handle; } } -const tick = () => new Promise(r => setTimeout(r, 0)) +const tick = () => new Promise((r) => setTimeout(r, 0)); function collect(s: Scheduler): SchedulerEvent[] { - const seen: SchedulerEvent[] = [] - s.onEvent(e => seen.push(e)) - return seen + const seen: SchedulerEvent[] = []; + s.onEvent((e) => seen.push(e)); + return seen; } -describe('Scheduler — serial queue (#56)', () => { - it('runs entries strictly serially: N+1 submits only after N `finished` resolves', async () => { - const ex = new FakeExecutor() - const s = new Scheduler(ex) - const a = s.enqueue({ scriptPath: 'a.jl' }) - const b = s.enqueue({ scriptPath: 'b.jl' }) - await tick() - expect(ex.submits.map(x => x.scriptPath)).toEqual(['a.jl']) // b NOT submitted yet - ex.handles[0].finish({ status: 'completed', exitCode: 0 }) - await tick() - expect(ex.submits.map(x => x.scriptPath)).toEqual(['a.jl', 'b.jl']) - const [ha, hb] = [await a.handle, await b.handle] - expect(ha.runId).toBe('run-1') - expect(hb.runId).toBe('run-2') - }) +describe("Scheduler — serial queue (#56)", () => { + it("runs entries strictly serially: N+1 submits only after N `finished` resolves", async () => { + const ex = new FakeExecutor(); + const s = new Scheduler(ex); + const a = s.enqueue({ scriptPath: "a.jl" }); + const b = s.enqueue({ scriptPath: "b.jl" }); + await tick(); + expect(ex.submits.map((x) => x.scriptPath)).toEqual(["a.jl"]); // b NOT submitted yet + ex.handles[0].finish({ status: "completed", exitCode: 0 }); + await tick(); + expect(ex.submits.map((x) => x.scriptPath)).toEqual(["a.jl", "b.jl"]); + const [ha, hb] = [await a.handle, await b.handle]; + expect(ha.runId).toBe("run-1"); + expect(hb.runId).toBe("run-2"); + }); - it('S12: the resolved handle IS the executor RunHandle (identity passthrough)', async () => { - const ex = new FakeExecutor() - const s = new Scheduler(ex) - const r = s.enqueue({ scriptPath: 'a.jl' }) - await tick() - expect(await r.handle).toBe(ex.handles[0].handle) - }) + it("S12: the resolved handle IS the executor RunHandle (identity passthrough)", async () => { + const ex = new FakeExecutor(); + const s = new Scheduler(ex); + const r = s.enqueue({ scriptPath: "a.jl" }); + await tick(); + expect(await r.handle).toBe(ex.handles[0].handle); + }); - it('passes SubmitOpts through to executor.submit verbatim', async () => { - const ex = new FakeExecutor() - const s = new Scheduler(ex) - const opts: SubmitOpts = { lab: 'lab-7', runsRoot: '/tmp/rr', julia: { project: '/p' } } - s.enqueue({ scriptPath: 'a.jl', opts }) - await tick() - expect(ex.submits[0].opts).toBe(opts) - }) + it("passes SubmitOpts through to executor.submit verbatim", async () => { + const ex = new FakeExecutor(); + const s = new Scheduler(ex); + const opts: SubmitOpts = { lab: "lab-7", runsRoot: "/tmp/rr", julia: { project: "/p" } }; + s.enqueue({ scriptPath: "a.jl", opts }); + await tick(); + expect(ex.submits[0].opts).toBe(opts); + }); - it('contract (b): abort() does NOT advance the queue — only `finished` does', async () => { - const ex = new FakeExecutor() - const s = new Scheduler(ex) - const a = s.enqueue({ scriptPath: 'a.jl' }) - s.enqueue({ scriptPath: 'b.jl' }) - await tick() - const ha = await a.handle - void ha.abort() // request termination… - await tick(); await tick() - expect(ex.submits).toHaveLength(1) // …but the run is still alive: b must NOT start - ex.handles[0].finish({ status: 'aborted', exitCode: 143 }) // FINISHED lands - await tick() - expect(ex.submits).toHaveLength(2) // now b starts - }) + it("contract (b): abort() does NOT advance the queue — only `finished` does", async () => { + const ex = new FakeExecutor(); + const s = new Scheduler(ex); + const a = s.enqueue({ scriptPath: "a.jl" }); + s.enqueue({ scriptPath: "b.jl" }); + await tick(); + const ha = await a.handle; + void ha.abort(); // request termination… + await tick(); + await tick(); + expect(ex.submits).toHaveLength(1); // …but the run is still alive: b must NOT start + ex.handles[0].finish({ status: "aborted", exitCode: 143 }); // FINISHED lands + await tick(); + expect(ex.submits).toHaveLength(2); // now b starts + }); - it('emits the lifecycle: queued → started → finished, with queue position', async () => { - const ex = new FakeExecutor() - const s = new Scheduler(ex) - const seen = collect(s) - s.enqueue({ scriptPath: 'a.jl' }) - s.enqueue({ scriptPath: 'b.jl' }) - await tick() - ex.handles[0].finish({ status: 'completed', exitCode: 0 }) - await tick() - ex.handles[1].finish({ status: 'failed', exitCode: 1 }) - await tick() + it("emits the lifecycle: queued → started → finished, with queue position", async () => { + const ex = new FakeExecutor(); + const s = new Scheduler(ex); + const seen = collect(s); + s.enqueue({ scriptPath: "a.jl" }); + s.enqueue({ scriptPath: "b.jl" }); + await tick(); + ex.handles[0].finish({ status: "completed", exitCode: 0 }); + await tick(); + ex.handles[1].finish({ status: "failed", exitCode: 1 }); + await tick(); expect(seen).toEqual([ - { kind: 'queued', queueId: 'q1', position: 0 }, - { kind: 'queued', queueId: 'q2', position: 1 }, - { kind: 'started', queueId: 'q1', runId: 'run-1', runDir: '/runs/run-1' }, - { kind: 'finished', queueId: 'q1', runId: 'run-1', status: 'completed', exitCode: 0 }, - { kind: 'started', queueId: 'q2', runId: 'run-2', runDir: '/runs/run-2' }, - { kind: 'finished', queueId: 'q2', runId: 'run-2', status: 'failed', exitCode: 1 }, - ]) - }) + { kind: "queued", queueId: "q1", position: 0 }, + { kind: "queued", queueId: "q2", position: 1 }, + { kind: "started", queueId: "q1", runId: "run-1", runDir: "/runs/run-1" }, + { kind: "finished", queueId: "q1", runId: "run-1", status: "completed", exitCode: 0 }, + { kind: "started", queueId: "q2", runId: "run-2", runDir: "/runs/run-2" }, + { kind: "finished", queueId: "q2", runId: "run-2", status: "failed", exitCode: 1 }, + ]); + }); - it('cancel() while queued: never submitted, cancelled event, handle rejects', async () => { - const ex = new FakeExecutor() - const s = new Scheduler(ex) - const seen = collect(s) - s.enqueue({ scriptPath: 'a.jl' }) - const b = s.enqueue({ scriptPath: 'b.jl' }) - await tick() - expect(b.cancel()).toBe(true) - ex.handles[0].finish({ status: 'completed', exitCode: 0 }) - await tick() - expect(ex.submits.map(x => x.scriptPath)).toEqual(['a.jl']) // b never ran - expect(seen.some(e => e.kind === 'cancelled' && e.queueId === 'q2')).toBe(true) - await expect(b.handle).rejects.toThrow(/cancel/i) - }) + it("cancel() while queued: never submitted, cancelled event, handle rejects", async () => { + const ex = new FakeExecutor(); + const s = new Scheduler(ex); + const seen = collect(s); + s.enqueue({ scriptPath: "a.jl" }); + const b = s.enqueue({ scriptPath: "b.jl" }); + await tick(); + expect(b.cancel()).toBe(true); + ex.handles[0].finish({ status: "completed", exitCode: 0 }); + await tick(); + expect(ex.submits.map((x) => x.scriptPath)).toEqual(["a.jl"]); // b never ran + expect(seen.some((e) => e.kind === "cancelled" && e.queueId === "q2")).toBe(true); + await expect(b.handle).rejects.toThrow(/cancel/i); + }); - it('cancel() after start returns false and the run is untouched (abort via the handle instead)', async () => { - const ex = new FakeExecutor() - const s = new Scheduler(ex) - const a = s.enqueue({ scriptPath: 'a.jl' }) - await tick() - await a.handle - expect(a.cancel()).toBe(false) - expect(ex.handles[0].aborted).toHaveLength(0) // cancel is NOT an abort - }) + it("cancel() after start returns false and the run is untouched (abort via the handle instead)", async () => { + const ex = new FakeExecutor(); + const s = new Scheduler(ex); + const a = s.enqueue({ scriptPath: "a.jl" }); + await tick(); + await a.handle; + expect(a.cancel()).toBe(false); + expect(ex.handles[0].aborted).toHaveLength(0); // cancel is NOT an abort + }); - it('a submit() ConfigError rejects that handle, emits error, and the queue advances', async () => { - const ex = new FakeExecutor() - ex.failFor.add('bad.jl') - const s = new Scheduler(ex) - const seen = collect(s) - const bad = s.enqueue({ scriptPath: 'bad.jl' }) - const ok = s.enqueue({ scriptPath: 'ok.jl' }) - await tick() - await expect(bad.handle).rejects.toThrow(/bad config/) - expect(seen.some(e => e.kind === 'error' && e.queueId === 'q1')).toBe(true) - await tick() - expect(ex.submits.map(x => x.scriptPath)).toEqual(['bad.jl', 'ok.jl']) // queue not wedged - expect((await ok.handle).runId).toBe('run-2') // FakeExecutor counts the failed submit too - }) + it("a submit() ConfigError rejects that handle, emits error, and the queue advances", async () => { + const ex = new FakeExecutor(); + ex.failFor.add("bad.jl"); + const s = new Scheduler(ex); + const seen = collect(s); + const bad = s.enqueue({ scriptPath: "bad.jl" }); + const ok = s.enqueue({ scriptPath: "ok.jl" }); + await tick(); + await expect(bad.handle).rejects.toThrow(/bad config/); + expect(seen.some((e) => e.kind === "error" && e.queueId === "q1")).toBe(true); + await tick(); + expect(ex.submits.map((x) => x.scriptPath)).toEqual(["bad.jl", "ok.jl"]); // queue not wedged + expect((await ok.handle).runId).toBe("run-2"); // FakeExecutor counts the failed submit too + }); - it('concurrent: true is a NAMED SEAM — rejected loudly (parallel lane is Phase 4)', () => { - const s = new Scheduler(new FakeExecutor()) - expect(() => s.enqueue({ scriptPath: 'a.jl' }, { concurrent: true })).toThrow(ConfigError) - expect(() => s.enqueue({ scriptPath: 'a.jl' }, { concurrent: true })).toThrow(/Phase 4/) - }) + it("concurrent: true is a NAMED SEAM — rejected loudly (parallel lane is Phase 4)", () => { + const s = new Scheduler(new FakeExecutor()); + expect(() => s.enqueue({ scriptPath: "a.jl" }, { concurrent: true })).toThrow(ConfigError); + expect(() => s.enqueue({ scriptPath: "a.jl" }, { concurrent: true })).toThrow(/Phase 4/); + }); - it('multiple listeners both receive events; a disposed listener stops receiving', async () => { - const ex = new FakeExecutor() - const s = new Scheduler(ex) - const a: SchedulerEvent[] = [] - const b: SchedulerEvent[] = [] - const disposeA = s.onEvent(e => a.push(e)) - s.onEvent(e => b.push(e)) - s.enqueue({ scriptPath: 'x.jl' }) - await tick() - expect(a.length).toBeGreaterThan(0) - expect(b.length).toBe(a.length) - disposeA() - ex.handles[0].finish({ status: 'completed', exitCode: 0 }) - await tick() - expect(b.length).toBeGreaterThan(a.length) // b kept receiving after a disposed - }) + it("multiple listeners both receive events; a disposed listener stops receiving", async () => { + const ex = new FakeExecutor(); + const s = new Scheduler(ex); + const a: SchedulerEvent[] = []; + const b: SchedulerEvent[] = []; + const disposeA = s.onEvent((e) => a.push(e)); + s.onEvent((e) => b.push(e)); + s.enqueue({ scriptPath: "x.jl" }); + await tick(); + expect(a.length).toBeGreaterThan(0); + expect(b.length).toBe(a.length); + disposeA(); + ex.handles[0].finish({ status: "completed", exitCode: 0 }); + await tick(); + expect(b.length).toBeGreaterThan(a.length); // b kept receiving after a disposed + }); - it('a throwing listener cannot wedge the pump or starve other listeners', async () => { - const ex = new FakeExecutor() - const s = new Scheduler(ex) - const good: SchedulerEvent[] = [] - s.onEvent(() => { throw new Error('bad listener') }) - s.onEvent(e => good.push(e)) - s.enqueue({ scriptPath: 'x.jl' }) - await tick() - ex.handles[0].finish({ status: 'completed', exitCode: 0 }) - await tick() - expect(good.some(e => e.kind === 'finished')).toBe(true) // pump survived - }) + it("a throwing listener cannot wedge the pump or starve other listeners", async () => { + const ex = new FakeExecutor(); + const s = new Scheduler(ex); + const good: SchedulerEvent[] = []; + s.onEvent(() => { + throw new Error("bad listener"); + }); + s.onEvent((e) => good.push(e)); + s.enqueue({ scriptPath: "x.jl" }); + await tick(); + ex.handles[0].finish({ status: "completed", exitCode: 0 }); + await tick(); + expect(good.some((e) => e.kind === "finished")).toBe(true); // pump survived + }); - it('contract (d): a rogue `finished` REJECTION is survived — error event, queue advances', async () => { + it("contract (d): a rogue `finished` REJECTION is survived — error event, queue advances", async () => { // `finished` never rejects per contract; a broken executor must still not // wedge every queued run behind it. (Pins the defensive branch — a mutation // deleting it must fail here.) class RogueExecutor extends FakeExecutor { async submit(scriptPath: string, opts?: SubmitOpts): Promise { - const h = await super.submit(scriptPath, opts) - if (scriptPath === 'rogue.jl') return { ...h, finished: Promise.reject(new Error('boom')) } - return h + const h = await super.submit(scriptPath, opts); + if (scriptPath === "rogue.jl") return { ...h, finished: Promise.reject(new Error("boom")) }; + return h; } } - const ex = new RogueExecutor() - const s = new Scheduler(ex) - const seen = collect(s) - s.enqueue({ scriptPath: 'rogue.jl' }) - const ok = s.enqueue({ scriptPath: 'ok.jl' }) - await tick(); await tick() - expect(seen.some(e => e.kind === 'error' && /finished rejected: boom/.test((e as { message: string }).message))).toBe(true) - expect(ex.submits.map(x => x.scriptPath)).toEqual(['rogue.jl', 'ok.jl']) // queue advanced - expect((await ok.handle).runId).toBe('run-2') - }) + const ex = new RogueExecutor(); + const s = new Scheduler(ex); + const seen = collect(s); + s.enqueue({ scriptPath: "rogue.jl" }); + const ok = s.enqueue({ scriptPath: "ok.jl" }); + await tick(); + await tick(); + expect( + seen.some((e) => e.kind === "error" && /finished rejected: boom/.test((e as { message: string }).message)), + ).toBe(true); + expect(ex.submits.map((x) => x.scriptPath)).toEqual(["rogue.jl", "ok.jl"]); // queue advanced + expect((await ok.handle).runId).toBe("run-2"); + }); - it('a SYNC-throwing submit (contract-violating executor) cannot blow the stack or strand the queue', async () => { + it("a SYNC-throwing submit (contract-violating executor) cannot blow the stack or strand the queue", async () => { // The dangerous shape: a big backlog of sync-throwers ACCUMULATES behind one // pending run, then drains in a single chain when it finishes. With a direct // finally re-pump that chain is real recursion (RangeError → stranded queue); @@ -222,52 +240,55 @@ describe('Scheduler — serial queue (#56)', () => { // scheduler never recurses — each enqueue drains its own entry — so the // backlog-behind-a-pending-run setup is load-bearing for this pin.) class SyncThrower implements Executor { - good = new FakeExecutor() + good = new FakeExecutor(); submit(scriptPath: string, opts?: SubmitOpts): Promise { - if (!scriptPath.startsWith('bad-')) return this.good.submit(scriptPath, opts) - throw new ConfigError(`sync boom: ${scriptPath}`) // sync, no Promise + if (!scriptPath.startsWith("bad-")) return this.good.submit(scriptPath, opts); + throw new ConfigError(`sync boom: ${scriptPath}`); // sync, no Promise } } - const ex = new SyncThrower() - const s = new Scheduler(ex) - s.enqueue({ scriptPath: 'first.jl' }) // holds the queue while the backlog builds - await tick() - const bad = Array.from({ length: 8000 }, (_, i) => s.enqueue({ scriptPath: `bad-${i}.jl` })) - const good = s.enqueue({ scriptPath: 'good.jl' }) - ex.good.handles[0].finish({ status: 'completed', exitCode: 0 }) // release → drain the 8000 in one go - const h = await good.handle // resolves only if the whole backlog drained - expect(h.runId).toBe('run-2') - expect(s.depth).toBe(1) // just the good run, still running - await expect(bad[0].handle).rejects.toThrow(/sync boom/) - await expect(bad[7999].handle).rejects.toThrow(/sync boom/) - }) + const ex = new SyncThrower(); + const s = new Scheduler(ex); + s.enqueue({ scriptPath: "first.jl" }); // holds the queue while the backlog builds + await tick(); + const bad = Array.from({ length: 8000 }, (_, i) => s.enqueue({ scriptPath: `bad-${i}.jl` })); + const good = s.enqueue({ scriptPath: "good.jl" }); + ex.good.handles[0].finish({ status: "completed", exitCode: 0 }); // release → drain the 8000 in one go + const h = await good.handle; // resolves only if the whole backlog drained + expect(h.runId).toBe("run-2"); + expect(s.depth).toBe(1); // just the good run, still running + await expect(bad[0].handle).rejects.toThrow(/sync boom/); + await expect(bad[7999].handle).rejects.toThrow(/sync boom/); + }); - it('an untouched ScheduledRun.handle never surfaces an unhandledRejection (cancel path)', async () => { + it("an untouched ScheduledRun.handle never surfaces an unhandledRejection (cancel path)", async () => { // Pins the internal handle.catch(() => {}) suppression explicitly — callers // that only consume lifecycle events never touch `handle`, and a cancel's // rejection must not trip the process. - const seen: unknown[] = [] - const trap = (r: unknown): void => { seen.push(r) } - process.on('unhandledRejection', trap) + const seen: unknown[] = []; + const trap = (r: unknown): void => { + seen.push(r); + }; + process.on("unhandledRejection", trap); try { - const s = new Scheduler(new FakeExecutor()) - s.enqueue({ scriptPath: 'a.jl' }) - const b = s.enqueue({ scriptPath: 'b.jl' }) - expect(b.cancel()).toBe(true) // rejects b.handle — nobody is listening - await tick(); await tick() - expect(seen).toEqual([]) + const s = new Scheduler(new FakeExecutor()); + s.enqueue({ scriptPath: "a.jl" }); + const b = s.enqueue({ scriptPath: "b.jl" }); + expect(b.cancel()).toBe(true); // rejects b.handle — nobody is listening + await tick(); + await tick(); + expect(seen).toEqual([]); } finally { - process.off('unhandledRejection', trap) + process.off("unhandledRejection", trap); } - }) + }); - it('contract (c): the Scheduler owns no timers (no warming timeout to hard-code)', async () => { + it("contract (c): the Scheduler owns no timers (no warming timeout to hard-code)", async () => { // Structural pin: remote cold-start ≫ local seconds, so ANY scheduler-side // timeout would violate the per-executor warming budget. Assert the source // has no timer calls at all (microtasks are fine — they encode no duration). - const { readFileSync } = await import('node:fs') - const { fileURLToPath } = await import('node:url') - const src = readFileSync(fileURLToPath(new URL('../src/scheduler.ts', import.meta.url)), 'utf8') - expect(src).not.toMatch(/setTimeout|setInterval|setImmediate|Date\.now/) - }) -}) + const { readFileSync } = await import("node:fs"); + const { fileURLToPath } = await import("node:url"); + const src = readFileSync(fileURLToPath(new URL("../src/scheduler.ts", import.meta.url)), "utf8"); + expect(src).not.toMatch(/setTimeout|setInterval|setImmediate|Date\.now/); + }); +}); From 56910aff7af6113ceeae61b3782103853f683b22 Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Tue, 7 Jul 2026 05:41:00 -0400 Subject: [PATCH 121/135] =?UTF-8?q?style:=20prettier=20repo-wide=20(new=20?= =?UTF-8?q?.prettierrc/.prettierignore)=20=E2=80=94=20one-time=20full-repo?= =?UTF-8?q?=20normalization=20so=20the=20formatter=20is=20enforceable=20fr?= =?UTF-8?q?om=20here?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .prettierignore | 8 + packages/.DS_Store | Bin 6148 -> 6148 bytes packages/amico-run/.DS_Store | Bin 6148 -> 6148 bytes packages/amico-run/esbuild.config.mjs | 22 +- packages/amico-run/src/authoring.ts | 60 +- packages/amico-run/src/baseline.ts | 39 +- packages/amico-run/src/catalog.ts | 170 ++- packages/amico-run/src/cli.ts | 227 ++-- packages/amico-run/src/event_queue.ts | 26 +- packages/amico-run/src/gate.ts | 126 +- packages/amico-run/src/import_scan.ts | 77 +- packages/amico-run/src/local_executor.ts | 213 +-- packages/amico-run/src/run_dir.ts | 93 +- packages/amico-run/src/subcommands.ts | 122 +- packages/amico-run/src/telemetry.ts | 20 +- packages/amico-run/src/types.ts | 55 +- packages/amico-run/src/verify.ts | 49 +- packages/amico-run/test/abort.test.ts | 74 +- packages/amico-run/test/authoring.test.ts | 82 +- packages/amico-run/test/baseline.test.ts | 50 +- packages/amico-run/test/catalog.test.ts | 127 +- packages/amico-run/test/cli.test.ts | 324 +++-- packages/amico-run/test/failure_lanes.test.ts | 221 ++-- packages/amico-run/test/gate.test.ts | 154 +-- packages/amico-run/test/helpers.ts | 20 +- packages/amico-run/test/import_scan.test.ts | 42 +- .../amico-run/test/local_executor.test.ts | 124 +- packages/amico-run/test/run_dir.test.ts | 185 +-- packages/amico-run/test/s31.test.ts | 23 +- packages/amico-run/test/schemas.test.ts | 97 +- .../amico-run/test/slow/integration.test.ts | 57 +- packages/amico-run/test/subcommands.test.ts | 166 +-- packages/amico-run/test/telemetry.test.ts | 54 +- packages/amico-run/test/verify.test.ts | 104 +- packages/extension/.DS_Store | Bin 10244 -> 10244 bytes packages/extension/AGENTS.md | 17 +- packages/extension/CONTRACT.md | 20 +- packages/extension/DEMO_CHECKLIST.md | 18 +- packages/extension/DISTILLER.md | 34 +- packages/extension/RUNBOOK.md | 17 +- packages/extension/TESTING.md | 2 +- .../dev/pulseplot_harness/index.html | 100 +- .../extension/dev/pulseplot_harness/main.ts | 70 +- packages/extension/julia/README.md | 2 + packages/extension/media/brand.css | 4 +- packages/extension/media/layout.css | 61 +- packages/extension/media/ui/atoms/button.ts | 18 +- packages/extension/media/ui/atoms/text.ts | 14 +- .../extension/media/ui/components/metric.ts | 7 +- .../media/ui/components/pulseplot.ts | 40 +- .../media/ui/components/sparkline.ts | 43 +- .../media/vendor/katex/katex.min.css | 1163 ++++++++++++++++- .../opencode-plugin/amicode_tools.ts | 73 +- .../opencode-plugin/distill_queue.ts | 11 +- .../extension/opencode-plugin/entities.ts | 8 +- .../extension/opencode-plugin/onboarding.ts | 5 +- .../extension/opencode-plugin/problems.ts | 15 +- .../extension/opencode-plugin/score_guard.ts | 7 +- packages/extension/opencode.lock.json | 10 +- packages/extension/scores/README.md | 31 +- .../scores/memory/confidence-rubric.md | 13 +- packages/extension/scores/overture/SCORE.md | 20 +- .../extension/scripts/build_exemplars.mjs | 101 +- packages/extension/scripts/distill_batch.mjs | 31 +- packages/extension/scripts/fetch_opencode.mjs | 167 ++- packages/extension/scripts/healthcheck.mjs | 115 +- packages/extension/scripts/opencode_probe.mjs | 25 +- packages/extension/scripts/plugin_exercise.ts | 39 +- packages/extension/src/chat_panel.ts | 18 +- packages/extension/src/executor_check.ts | 10 +- packages/extension/src/llm_creds.d.mts | 12 +- packages/extension/src/llm_creds.mjs | 3 +- packages/extension/src/opencode_binary.ts | 5 +- packages/extension/src/opencode_config.ts | 61 +- packages/extension/src/scores/compiler.ts | 10 +- .../extension/src/scores/package_skills.ts | 20 +- packages/extension/src/scores/schema.ts | 13 +- packages/extension/src/server_manager.ts | 27 +- packages/extension/src/sse_client.ts | 19 +- packages/extension/src/substrate/distiller.ts | 10 +- packages/extension/test/agents_md.test.ts | 256 ++-- packages/extension/test/amicode_tools.test.ts | 519 ++++---- packages/extension/test/boot_smoke.mjs | 25 +- packages/extension/test/corpus/fake-julia | 41 +- packages/extension/test/demo_replay.test.ts | 64 +- .../extension/test/fetch_opencode.test.ts | 184 +-- packages/extension/test/hashes.test.ts | 26 +- packages/extension/test/healthcheck.test.ts | 41 +- packages/extension/test/lab_config.test.ts | 44 +- packages/extension/test/llm_creds.test.ts | 179 +-- .../extension/test/opencode_binary.test.ts | 60 +- .../extension/test/opencode_config.test.ts | 313 +++-- .../extension/test/opencode_paths.test.ts | 82 +- packages/extension/test/packaging.test.ts | 86 +- packages/extension/test/problems.test.ts | 430 +++--- .../test/run_dir_reader_stopped.test.ts | 10 +- .../test/scores/allowlist_production.test.ts | 6 +- .../test/scores/entitlements_router.test.ts | 26 +- packages/extension/test/scores/guard.test.ts | 24 +- .../test/scores/overture_routing.test.ts | 24 +- .../test/scores/package_skills.test.ts | 18 +- .../test/scores/prep_integration.test.ts | 23 +- .../test/scores/repertoire_lint.test.ts | 14 +- packages/extension/test/scores/schema.test.ts | 50 +- .../extension/test/slow/interview_e2e.test.ts | 306 +++-- packages/extension/test/slow/template.test.ts | 51 +- .../test/slow/verify_harness.test.ts | 59 +- .../slow/verify_spline_free_phase.test.ts | 64 +- packages/extension/test/sparkline.test.ts | 4 +- .../test/substrate/user_splice.test.ts | 10 +- .../test/substrate/vault_store.test.ts | 5 +- packages/schema/.DS_Store | Bin 8196 -> 8196 bytes packages/schema/esbuild.config.mjs | 18 +- packages/schema/package.json | 8 +- .../schema/schemas/catalog-entry.schema.json | 12 +- packages/schema/schemas/lab.schema.json | 28 +- packages/schema/schemas/result.schema.json | 13 +- packages/schema/schemas/run.schema.json | 21 +- packages/schema/schemas/solvespec.schema.json | 32 +- packages/schema/src/cli.ts | 26 +- packages/schema/src/index.ts | 19 +- packages/schema/test/cli.test.ts | 14 +- packages/schema/test/validate.test.ts | 146 ++- 123 files changed, 5676 insertions(+), 3425 deletions(-) create mode 100644 .prettierignore diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..c01914d8 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,8 @@ +# generated / vendored — never hand-formatted +node_modules +dist +packages/extension/vendor +packages/extension/exemplars +*.vsix +pnpm-lock.yaml +CHANGELOG.md diff --git a/packages/.DS_Store b/packages/.DS_Store index 015dc36fbe238f788cbdde2bead17e26a3037b47..46610f70a413a5f7b0e6ae8a0a6ff16e404b0539 100644 GIT binary patch delta 51 zcmZoMXffDez{HfxI5~z%VzL)g0%OAD)l4ePSN`pu{0AuViYcDy?t;nQ%<@b=jhl0r HXNUj*z_t4S^?m(VN3zD2MGNSJ5v*E diff --git a/packages/amico-run/esbuild.config.mjs b/packages/amico-run/esbuild.config.mjs index eb12aaa7..69a8d9b7 100644 --- a/packages/amico-run/esbuild.config.mjs +++ b/packages/amico-run/esbuild.config.mjs @@ -1,17 +1,17 @@ -import { build } from 'esbuild' -import { chmodSync } from 'node:fs' +import { build } from "esbuild"; +import { chmodSync } from "node:fs"; await build({ - entryPoints: ['src/cli.ts'], + entryPoints: ["src/cli.ts"], bundle: true, - platform: 'node', - target: 'node20', + platform: "node", + target: "node20", // ESM, not CJS: the package is "type": "module", so node executes dist/amico-run.js // as ESM — a CJS bundle would die on `require is not defined in ES module scope`. - format: 'esm', - outfile: 'dist/amico-run.js', - banner: { js: '#!/usr/bin/env node' }, + format: "esm", + outfile: "dist/amico-run.js", + banner: { js: "#!/usr/bin/env node" }, sourcemap: true, - logLevel: 'info', -}) -chmodSync('dist/amico-run.js', 0o755) + logLevel: "info", +}); +chmodSync("dist/amico-run.js", 0o755); diff --git a/packages/amico-run/src/authoring.ts b/packages/amico-run/src/authoring.ts index 30a9e5c8..788046eb 100644 --- a/packages/amico-run/src/authoring.ts +++ b/packages/amico-run/src/authoring.ts @@ -4,9 +4,9 @@ // assets); the gate reads it here. Absent file → conservative built-in // defaults (public base ∪ support set) so a bare-but-spec'd dev invocation // still gates sanely. $AMICO_AUTHORING_FILE overrides the path (tests). -import { existsSync, readFileSync } from 'node:fs' -import { homedir } from 'node:os' -import { join } from 'node:path' +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; // NOTE (spec-20260704-113005 §3): session prep ALSO writes an additive // `skills: [{source: "library"|"package", package?, name, description, path}]` @@ -14,54 +14,54 @@ import { join } from 'node:path' // record for provenance/UI; amico-run does not consume it (unknown fields are // ignored here), so it is intentionally NOT in this interface. export interface AuthoringConfig { - allowlist: string[] // entitlement-resolved Harmoniqs packages - support_set: string[] // fixed support packages the run-dir contract itself needs - registry?: string // abs path to templates/registry.toml - exemplars?: string // abs path to exemplars/index.json - verify_harness?: string // abs path to julia/verify_rollout.jl - verify_tolerance: number // tier-3 re-rollout agreement (absolute) + allowlist: string[]; // entitlement-resolved Harmoniqs packages + support_set: string[]; // fixed support packages the run-dir contract itself needs + registry?: string; // abs path to templates/registry.toml + exemplars?: string; // abs path to exemplars/index.json + verify_harness?: string; // abs path to julia/verify_rollout.jl + verify_tolerance: number; // tier-3 re-rollout agreement (absolute) } -export const DEFAULT_ALLOWLIST = ['Piccolo', 'Legato', 'Intonato', 'NamedTrajectories', 'DirectTrajOpt'] -export const DEFAULT_SUPPORT = ['JLD2', 'CairoMakie', 'Makie', 'TOML', 'Printf'] -const DEFAULT_TOLERANCE = 0.001 +export const DEFAULT_ALLOWLIST = ["Piccolo", "Legato", "Intonato", "NamedTrajectories", "DirectTrajOpt"]; +export const DEFAULT_SUPPORT = ["JLD2", "CairoMakie", "Makie", "TOML", "Printf"]; +const DEFAULT_TOLERANCE = 0.001; function defaults(): AuthoringConfig { return { allowlist: [...DEFAULT_ALLOWLIST], support_set: [...DEFAULT_SUPPORT], verify_tolerance: DEFAULT_TOLERANCE, - } + }; } export function authoringFile(): string { - const env = process.env.AMICO_AUTHORING_FILE - if (env && env.trim() !== '') return env - return join(homedir(), '.amico', 'authoring', 'authoring.json') + const env = process.env.AMICO_AUTHORING_FILE; + if (env && env.trim() !== "") return env; + return join(homedir(), ".amico", "authoring", "authoring.json"); } export function readAuthoring(): { config: AuthoringConfig; warning?: string } { - const file = authoringFile() - if (!existsSync(file)) return { config: defaults() } - let raw: unknown + const file = authoringFile(); + if (!existsSync(file)) return { config: defaults() }; + let raw: unknown; try { - raw = JSON.parse(readFileSync(file, 'utf8')) + raw = JSON.parse(readFileSync(file, "utf8")); } catch { - return { config: defaults(), warning: `malformed authoring.json at ${file} — using built-in defaults` } + return { config: defaults(), warning: `malformed authoring.json at ${file} — using built-in defaults` }; } - if (typeof raw !== 'object' || raw === null) - return { config: defaults(), warning: `authoring.json at ${file} is not an object — using built-in defaults` } - const data = raw as Record + if (typeof raw !== "object" || raw === null) + return { config: defaults(), warning: `authoring.json at ${file} is not an object — using built-in defaults` }; + const data = raw as Record; const strings = (v: unknown): string[] | undefined => - Array.isArray(v) ? v.filter((s): s is string => typeof s === 'string') : undefined + Array.isArray(v) ? v.filter((s): s is string => typeof s === "string") : undefined; return { config: { allowlist: strings(data.allowlist) ?? [...DEFAULT_ALLOWLIST], support_set: strings(data.support_set) ?? [...DEFAULT_SUPPORT], - registry: typeof data.registry === 'string' ? data.registry : undefined, - exemplars: typeof data.exemplars === 'string' ? data.exemplars : undefined, - verify_harness: typeof data.verify_harness === 'string' ? data.verify_harness : undefined, - verify_tolerance: typeof data.verify_tolerance === 'number' ? data.verify_tolerance : DEFAULT_TOLERANCE, + registry: typeof data.registry === "string" ? data.registry : undefined, + exemplars: typeof data.exemplars === "string" ? data.exemplars : undefined, + verify_harness: typeof data.verify_harness === "string" ? data.verify_harness : undefined, + verify_tolerance: typeof data.verify_tolerance === "number" ? data.verify_tolerance : DEFAULT_TOLERANCE, }, - } + }; } diff --git a/packages/amico-run/src/baseline.ts b/packages/amico-run/src/baseline.ts index 2bc10f8e..e3f59ab1 100644 --- a/packages/amico-run/src/baseline.ts +++ b/packages/amico-run/src/baseline.ts @@ -6,32 +6,37 @@ // convention's `# ── FILL IN` / `# ─────` pair; an index entry may override // with fill_begin/fill_end regex sources. Unterminated blocks mask to EOF // (conservative: an attacker deleting the end marker can't unmask anything). -import { createHash } from 'node:crypto' +import { createHash } from "node:crypto"; -const DEFAULT_BEGIN = '^# ── FILL IN' -const DEFAULT_END = '^# ─────' +const DEFAULT_BEGIN = "^# ── FILL IN"; +const DEFAULT_END = "^# ─────"; export function maskFillPoints(text: string, beginSource?: string, endSource?: string): string { - const begin = new RegExp(beginSource ?? DEFAULT_BEGIN) - const end = new RegExp(endSource ?? DEFAULT_END) - const out: string[] = [] - let inside = false - for (const line of text.split('\n')) { + const begin = new RegExp(beginSource ?? DEFAULT_BEGIN); + const end = new RegExp(endSource ?? DEFAULT_END); + const out: string[] = []; + let inside = false; + for (const line of text.split("\n")) { if (!inside && begin.test(line)) { - inside = true - out.push(line) - continue + inside = true; + out.push(line); + continue; } if (inside && end.test(line)) { - inside = false - out.push(line) - continue + inside = false; + out.push(line); + continue; } - out.push(inside ? '#MASKED' : line) + out.push(inside ? "#MASKED" : line); } - return out.join('\n') + return out.join("\n"); } export function maskedHash(text: string, beginSource?: string, endSource?: string): string { - return 'sha256:' + createHash('sha256').update(maskFillPoints(text, beginSource, endSource)).digest('hex') + return ( + "sha256:" + + createHash("sha256") + .update(maskFillPoints(text, beginSource, endSource)) + .digest("hex") + ); } diff --git a/packages/amico-run/src/catalog.ts b/packages/amico-run/src/catalog.ts index 1c47fbeb..855f0a50 100644 --- a/packages/amico-run/src/catalog.ts +++ b/packages/amico-run/src/catalog.ts @@ -5,131 +5,131 @@ // exemplars index (exemplars/index.json, built by build_exemplars.mjs) is // tier 2, with build-time masked baseline_hash per entry. Loaders never // throw: a missing/corrupt catalog degrades to tier 3, not a crash. -import { existsSync, readFileSync } from 'node:fs' -import { parse as parseToml } from 'smol-toml' -import { JULIA_STDLIBS } from './import_scan.js' +import { existsSync, readFileSync } from "node:fs"; +import { parse as parseToml } from "smol-toml"; +import { JULIA_STDLIBS } from "./import_scan.js"; export interface TemplateEntry { - id: string - platform: string - kind: string - size: number - path: string - packages: string[] - status: string // "vetted" | "experimental" | … - entitlement?: string // required entitlement id, when gated - fill_begin?: string - fill_end?: string + id: string; + platform: string; + kind: string; + size: number; + path: string; + packages: string[]; + status: string; // "vetted" | "experimental" | … + entitlement?: string; // required entitlement id, when gated + fill_begin?: string; + fill_end?: string; } export interface ExemplarEntry { - id: string - platform: string - kind: string - size: number - path: string - packages: string[] - baseline_hash: string - notes?: string - fill_begin?: string - fill_end?: string + id: string; + platform: string; + kind: string; + size: number; + path: string; + packages: string[]; + baseline_hash: string; + notes?: string; + fill_begin?: string; + fill_end?: string; } export interface Registry { - templates: TemplateEntry[] - support: string[] - uuids: Record - verifyTolerance: number + templates: TemplateEntry[]; + support: string[]; + uuids: Record; + verifyTolerance: number; } export interface ExemplarsIndex { - exemplars: ExemplarEntry[] + exemplars: ExemplarEntry[]; } export interface Shape { - platform: string - kind: string - size: number + platform: string; + kind: string; + size: number; } export interface ShapeMatch { - tier: 'vetted' | 'composed' | 'free' - template?: TemplateEntry - exemplar?: ExemplarEntry - blockedHigher?: { tier: 'vetted' | 'composed'; requires: string } + tier: "vetted" | "composed" | "free"; + template?: TemplateEntry; + exemplar?: ExemplarEntry; + blockedHigher?: { tier: "vetted" | "composed"; requires: string }; } -const EMPTY_REGISTRY: Registry = { templates: [], support: [], uuids: {}, verifyTolerance: 0.01 } +const EMPTY_REGISTRY: Registry = { templates: [], support: [], uuids: {}, verifyTolerance: 0.01 }; function strings(v: unknown): string[] { - return Array.isArray(v) ? v.filter((s): s is string => typeof s === 'string') : [] + return Array.isArray(v) ? v.filter((s): s is string => typeof s === "string") : []; } export function loadRegistry(file: string): Registry { - if (!existsSync(file)) return EMPTY_REGISTRY - let parsed: Record + if (!existsSync(file)) return EMPTY_REGISTRY; + let parsed: Record; try { - parsed = parseToml(readFileSync(file, 'utf8')) as Record + parsed = parseToml(readFileSync(file, "utf8")) as Record; } catch { - return EMPTY_REGISTRY + return EMPTY_REGISTRY; } const templates = (Array.isArray(parsed.template) ? parsed.template : []) - .filter((t): t is Record => typeof t === 'object' && t !== null) - .filter((t) => typeof t.id === 'string' && typeof t.platform === 'string' && typeof t.kind === 'string') + .filter((t): t is Record => typeof t === "object" && t !== null) + .filter((t) => typeof t.id === "string" && typeof t.platform === "string" && typeof t.kind === "string") .map( (t): TemplateEntry => ({ id: t.id as string, platform: t.platform as string, kind: t.kind as string, - size: typeof t.size === 'number' ? t.size : 1, - path: typeof t.path === 'string' ? t.path : '', + size: typeof t.size === "number" ? t.size : 1, + path: typeof t.path === "string" ? t.path : "", packages: strings(t.packages), - status: typeof t.status === 'string' ? t.status : 'experimental', - entitlement: typeof t.entitlement === 'string' ? t.entitlement : undefined, - fill_begin: typeof t.fill_begin === 'string' ? t.fill_begin : undefined, - fill_end: typeof t.fill_end === 'string' ? t.fill_end : undefined, + status: typeof t.status === "string" ? t.status : "experimental", + entitlement: typeof t.entitlement === "string" ? t.entitlement : undefined, + fill_begin: typeof t.fill_begin === "string" ? t.fill_begin : undefined, + fill_end: typeof t.fill_end === "string" ? t.fill_end : undefined, }), - ) - const support = strings((parsed.support as Record | undefined)?.packages) - const uuids: Record = {} - if (typeof parsed.uuids === 'object' && parsed.uuids !== null) + ); + const support = strings((parsed.support as Record | undefined)?.packages); + const uuids: Record = {}; + if (typeof parsed.uuids === "object" && parsed.uuids !== null) for (const [name, uuid] of Object.entries(parsed.uuids as Record)) - if (typeof uuid === 'string') uuids[name] = uuid + if (typeof uuid === "string") uuids[name] = uuid; return { templates, support, uuids, - verifyTolerance: typeof parsed.verify_tolerance === 'number' ? parsed.verify_tolerance : 0.01, - } + verifyTolerance: typeof parsed.verify_tolerance === "number" ? parsed.verify_tolerance : 0.01, + }; } export function loadExemplarsIndex(file: string): ExemplarsIndex { - if (!existsSync(file)) return { exemplars: [] } - let parsed: unknown + if (!existsSync(file)) return { exemplars: [] }; + let parsed: unknown; try { - parsed = JSON.parse(readFileSync(file, 'utf8')) + parsed = JSON.parse(readFileSync(file, "utf8")); } catch { - return { exemplars: [] } + return { exemplars: [] }; } - const raw = (parsed as Record)?.exemplars + const raw = (parsed as Record)?.exemplars; const exemplars = (Array.isArray(raw) ? raw : []) - .filter((e): e is Record => typeof e === 'object' && e !== null) - .filter((e) => typeof e.id === 'string' && typeof e.baseline_hash === 'string') + .filter((e): e is Record => typeof e === "object" && e !== null) + .filter((e) => typeof e.id === "string" && typeof e.baseline_hash === "string") .map( (e): ExemplarEntry => ({ id: e.id as string, - platform: typeof e.platform === 'string' ? e.platform : '', - kind: typeof e.kind === 'string' ? e.kind : '', - size: typeof e.size === 'number' ? e.size : 1, - path: typeof e.path === 'string' ? e.path : '', + platform: typeof e.platform === "string" ? e.platform : "", + kind: typeof e.kind === "string" ? e.kind : "", + size: typeof e.size === "number" ? e.size : 1, + path: typeof e.path === "string" ? e.path : "", packages: strings(e.packages), baseline_hash: e.baseline_hash as string, - notes: typeof e.notes === 'string' ? e.notes : undefined, - fill_begin: typeof e.fill_begin === 'string' ? e.fill_begin : undefined, - fill_end: typeof e.fill_end === 'string' ? e.fill_end : undefined, + notes: typeof e.notes === "string" ? e.notes : undefined, + fill_begin: typeof e.fill_begin === "string" ? e.fill_begin : undefined, + fill_end: typeof e.fill_end === "string" ? e.fill_end : undefined, }), - ) - return { exemplars } + ); + return { exemplars }; } /** Tier resolution (spec C, locked decision 5): exact vetted template match → @@ -143,27 +143,25 @@ export function matchShape( exemplars: ExemplarsIndex, allowlist: string[], ): ShapeMatch { - const allowed = new Set([...allowlist, ...registry.support, ...JULIA_STDLIBS]) - const packagesOk = (packages: string[]) => packages.every((p) => allowed.has(p)) - let blockedHigher: ShapeMatch['blockedHigher'] + const allowed = new Set([...allowlist, ...registry.support, ...JULIA_STDLIBS]); + const packagesOk = (packages: string[]) => packages.every((p) => allowed.has(p)); + let blockedHigher: ShapeMatch["blockedHigher"]; const templateMatches = registry.templates.filter( - (t) => t.status === 'vetted' && t.platform === shape.platform && t.kind === shape.kind && t.size === shape.size, - ) + (t) => t.status === "vetted" && t.platform === shape.platform && t.kind === shape.kind && t.size === shape.size, + ); for (const template of templateMatches) { - if (packagesOk(template.packages)) return { tier: 'vetted', template } - blockedHigher ??= { tier: 'vetted', requires: template.entitlement ?? 'unknown' } + if (packagesOk(template.packages)) return { tier: "vetted", template }; + blockedHigher ??= { tier: "vetted", requires: template.entitlement ?? "unknown" }; } - const exemplarMatches = exemplars.exemplars.filter( - (e) => e.platform === shape.platform && e.kind === shape.kind, - ) + const exemplarMatches = exemplars.exemplars.filter((e) => e.platform === shape.platform && e.kind === shape.kind); // prefer exact-size, then any - exemplarMatches.sort((a, b) => Number(b.size === shape.size) - Number(a.size === shape.size)) + exemplarMatches.sort((a, b) => Number(b.size === shape.size) - Number(a.size === shape.size)); for (const exemplar of exemplarMatches) { - if (packagesOk(exemplar.packages)) return { tier: 'composed', exemplar, blockedHigher } - blockedHigher ??= { tier: 'composed', requires: 'unknown' } + if (packagesOk(exemplar.packages)) return { tier: "composed", exemplar, blockedHigher }; + blockedHigher ??= { tier: "composed", requires: "unknown" }; } - return { tier: 'free', blockedHigher } + return { tier: "free", blockedHigher }; } diff --git a/packages/amico-run/src/cli.ts b/packages/amico-run/src/cli.ts index e261b4b4..2ef2ddc0 100644 --- a/packages/amico-run/src/cli.ts +++ b/packages/amico-run/src/cli.ts @@ -1,16 +1,19 @@ -import { existsSync, readFileSync } from 'node:fs' -import { join } from 'node:path' -import { parse as parseToml } from 'smol-toml' -import { LocalExecutor } from './local_executor.js' -import { ConfigError, type Finished, type SubmitOpts } from './types.js' -import { readAuthoring } from './authoring.js' -import { runGate } from './gate.js' -import { runVerification } from './verify.js' -import { trySubcommand } from './subcommands.js' +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { parse as parseToml } from "smol-toml"; +import { LocalExecutor } from "./local_executor.js"; +import { ConfigError, type Finished, type SubmitOpts } from "./types.js"; +import { readAuthoring } from "./authoring.js"; +import { runGate } from "./gate.js"; +import { runVerification } from "./verify.js"; +import { trySubcommand } from "./subcommands.js"; function readTomlSafe(fp: string): Record | undefined { - try { return parseToml(readFileSync(fp, 'utf8')) as Record } - catch { return undefined } + try { + return parseToml(readFileSync(fp, "utf8")) as Record; + } catch { + return undefined; + } } const USAGE = `usage: amico-run [--executor local] [--lab ] @@ -18,72 +21,119 @@ const USAGE = `usage: amico-run [--executor local] [--lab ] (spec C: validate + gate before launch) amico-run resolve --platform

--kind --size (tier resolution → JSON) amico-run sandbox --packages A,B,… (generate env/Project.toml) - (a bare script literally named "resolve"/"sandbox" still launches — dispatch checks the file exists)` + (a bare script literally named "resolve"/"sandbox" still launches — dispatch checks the file exists)`; export async function main(argv: string[]): Promise { // spec C subcommands — dispatched before the launch flag loop - const sub = trySubcommand(argv) - if (sub !== undefined) return sub + const sub = trySubcommand(argv); + if (sub !== undefined) return sub; - let script: string | undefined - let executor = 'local' - let specPath: string | undefined - const opts: SubmitOpts = { julia: {} } - let projectExplicit = false + let script: string | undefined; + let executor = "local"; + let specPath: string | undefined; + const opts: SubmitOpts = { julia: {} }; + let projectExplicit = false; for (let i = 0; i < argv.length; i++) { - const a = argv[i] + const a = argv[i]; const next = (): string => { - const v = argv[++i] - if (v === undefined) throw new ConfigError(`flag ${a} requires a value`) - return v - } + const v = argv[++i]; + if (v === undefined) throw new ConfigError(`flag ${a} requires a value`); + return v; + }; try { switch (a) { - case '--help': case '-h': console.log(USAGE); return 0 - case '--executor': executor = next(); break - case '--lab': opts.lab = next(); break - case '--runs-root': opts.runsRoot = next(); break - case '--julia': opts.julia!.julia = next(); break - case '--project': opts.julia!.project = next(); projectExplicit = true; break - case '--sysimage': opts.julia!.sysimage = next(); break - case '--spec': specPath = next(); break + case "--help": + case "-h": + console.log(USAGE); + return 0; + case "--executor": + executor = next(); + break; + case "--lab": + opts.lab = next(); + break; + case "--runs-root": + opts.runsRoot = next(); + break; + case "--julia": + opts.julia!.julia = next(); + break; + case "--project": + opts.julia!.project = next(); + projectExplicit = true; + break; + case "--sysimage": + opts.julia!.sysimage = next(); + break; + case "--spec": + specPath = next(); + break; default: - if (a.startsWith('-')) { console.error(`amico-run: unknown flag ${a}\n${USAGE}`); return 64 } - if (script) { console.error(`amico-run: multiple scripts given`); return 64 } - script = a + if (a.startsWith("-")) { + console.error(`amico-run: unknown flag ${a}\n${USAGE}`); + return 64; + } + if (script) { + console.error(`amico-run: multiple scripts given`); + return 64; + } + script = a; } } catch (e) { - if (e instanceof ConfigError) { console.error(`amico-run: ${e.message}`); return 64 } - throw e + if (e instanceof ConfigError) { + console.error(`amico-run: ${e.message}`); + return 64; + } + throw e; } } - if (!script) { console.error(`amico-run: no script given\n${USAGE}`); return 64 } - if (executor !== 'local') { console.error(`amico-run: only --executor local is supported in β`); return 64 } + if (!script) { + console.error(`amico-run: no script given\n${USAGE}`); + return 64; + } + if (executor !== "local") { + console.error(`amico-run: only --executor local is supported in β`); + return 64; + } // ── spec C: the launch gate. Failures leave NO run dir and exit 64. ── if (specPath) { - let specRaw: unknown - try { specRaw = JSON.parse(readFileSync(specPath, 'utf8')) } - catch (e) { console.error(`amico-run: cannot read --spec ${specPath}: ${(e as Error).message}`); return 64 } - let scriptText: string - try { scriptText = readFileSync(script, 'utf8') } - catch (e) { console.error(`amico-run: cannot read script ${script}: ${(e as Error).message}`); return 64 } - const { config: authoring, warning } = readAuthoring() - if (warning) console.error(`amico-run: ${warning}`) - const gate = runGate(specRaw, scriptText, authoring) - if (!gate.ok) { console.error(`amico-run: gate: ${gate.reason}`); return 64 } + let specRaw: unknown; + try { + specRaw = JSON.parse(readFileSync(specPath, "utf8")); + } catch (e) { + console.error(`amico-run: cannot read --spec ${specPath}: ${(e as Error).message}`); + return 64; + } + let scriptText: string; + try { + scriptText = readFileSync(script, "utf8"); + } catch (e) { + console.error(`amico-run: cannot read script ${script}: ${(e as Error).message}`); + return 64; + } + const { config: authoring, warning } = readAuthoring(); + if (warning) console.error(`amico-run: ${warning}`); + const gate = runGate(specRaw, scriptText, authoring); + if (!gate.ok) { + console.error(`amico-run: gate: ${gate.reason}`); + return 64; + } // env resolution: spec env.project feeds --project unless the flag was explicit - const env = (specRaw as { env?: { kind?: string; project?: string } }).env - if (env?.project && (env.kind === 'project' || env.kind === 'sandbox')) { + const env = (specRaw as { env?: { kind?: string; project?: string } }).env; + if (env?.project && (env.kind === "project" || env.kind === "sandbox")) { if (projectExplicit && opts.julia!.project !== env.project) - console.error(`amico-run: --project ${opts.julia!.project} overrides the spec's env.project ${env.project}`) - else opts.julia!.project = env.project + console.error(`amico-run: --project ${opts.julia!.project} overrides the spec's env.project ${env.project}`); + else opts.julia!.project = env.project; } opts.spec = { - canonical: gate.stamp.specCanonical, tier: gate.stamp.tier, hashes: gate.stamp.hashes, - julia_binary: opts.julia!.julia, env_project: opts.julia!.project, - } + canonical: gate.stamp.specCanonical, + tier: gate.stamp.tier, + hashes: gate.stamp.hashes, + julia_binary: opts.julia!.julia, + env_project: opts.julia!.project, + }; } // NOTE: `--sysimage ` is honored (passed through to the Julia process and @@ -93,53 +143,60 @@ export async function main(argv: string[]): Promise { // (CI build on self-hosted runners → R2 → manifest → download), pointed at via // this flag. Until that exists, solves pay the cold start (inspector warms up). - let handle + let handle; try { - handle = await new LocalExecutor().submit(script, opts) + handle = await new LocalExecutor().submit(script, opts); } catch (e) { - if (e instanceof ConfigError) { console.error(`amico-run: ${e.message}`); return 64 } - throw e + if (e instanceof ConfigError) { + console.error(`amico-run: ${e.message}`); + return 64; + } + throw e; } - const onSignal = (): void => { void handle.abort() } - process.on('SIGINT', onSignal) - process.on('SIGTERM', onSignal) + const onSignal = (): void => { + void handle.abort(); + }; + process.on("SIGINT", onSignal); + process.on("SIGTERM", onSignal); - let fin: Finished | undefined + let fin: Finished | undefined; for await (const ev of handle.events) { - if (ev.kind === 'iter' || ev.kind === 'done') console.log(ev.raw) - else if (ev.kind === 'log') console.log(ev.line) - else fin = { status: ev.status, exitCode: ev.exitCode } + if (ev.kind === "iter" || ev.kind === "done") console.log(ev.raw); + else if (ev.kind === "log") console.log(ev.line); + else fin = { status: ev.status, exitCode: ev.exitCode }; } - const f = fin ?? await handle.finished + const f = fin ?? (await handle.finished); // FINISHED-write failure lane (spec §6 last row): verdict file must exist on disk - if (!existsSync(join(handle.runDir, 'FINISHED'))) { - console.error(`amico-run: FINISHED missing in ${handle.runDir} (write fault)`) - return 64 + if (!existsSync(join(handle.runDir, "FINISHED"))) { + console.error(`amico-run: FINISHED missing in ${handle.runDir} (write fault)`); + return 64; } // spec C: free-tier re-rollout verification runs AFTER FINISHED, BEFORE the // AMICODE_FINISHED line — so consumers see a settled verification state. The // harness (or the fallback) always writes verification.toml; the promote gate // keys off agree==true. - if (opts.spec?.tier === 'free') { - const { config: authoring } = readAuthoring() - await runVerification(handle.runDir, opts.spec, authoring) - const verified = readTomlSafe(join(handle.runDir, 'verification.toml')) - console.log(`AMICODE_VERIFIED agree=${verified?.agree === true}`) + if (opts.spec?.tier === "free") { + const { config: authoring } = readAuthoring(); + await runVerification(handle.runDir, opts.spec, authoring); + const verified = readTomlSafe(join(handle.runDir, "verification.toml")); + console.log(`AMICODE_VERIFIED agree=${verified?.agree === true}`); } // stdout protocol line — camelCase by design (spec §4) - console.log(`AMICODE_FINISHED status=${f.status} exitCode=${f.exitCode} runDir=${handle.runDir}`) - if (f.status === 'aborted') return 130 - if (f.status === 'completed') return 0 - return f.exitCode === 0 ? 1 : f.exitCode + console.log(`AMICODE_FINISHED status=${f.status} exitCode=${f.exitCode} runDir=${handle.runDir}`); + if (f.status === "aborted") return 130; + if (f.status === "completed") return 0; + return f.exitCode === 0 ? 1 : f.exitCode; } main(process.argv.slice(2)).then( - c => { process.exitCode = c }, - e => { + (c) => { + process.exitCode = c; + }, + (e) => { // Any unexpected throw is an orchestrator fault, not a solve failure → 64. - console.error(`amico-run: unexpected error: ${e instanceof Error ? e.stack ?? e.message : e}`) - process.exitCode = 64 + console.error(`amico-run: unexpected error: ${e instanceof Error ? (e.stack ?? e.message) : e}`); + process.exitCode = 64; }, -) +); diff --git a/packages/amico-run/src/event_queue.ts b/packages/amico-run/src/event_queue.ts index 361b42f3..61b1bdd1 100644 --- a/packages/amico-run/src/event_queue.ts +++ b/packages/amico-run/src/event_queue.ts @@ -1,26 +1,26 @@ /** Push-based AsyncIterable: producer pushes, single consumer iterates. */ export class EventQueue implements AsyncIterable { - private buf: T[] = [] - private waiters: Array<(r: IteratorResult) => void> = [] - private ended = false + private buf: T[] = []; + private waiters: Array<(r: IteratorResult) => void> = []; + private ended = false; push(v: T): void { - if (this.ended) return // late producers (post-settle) are dropped, never buffered - const w = this.waiters.shift() - if (w) w({ value: v, done: false }) - else this.buf.push(v) + if (this.ended) return; // late producers (post-settle) are dropped, never buffered + const w = this.waiters.shift(); + if (w) w({ value: v, done: false }); + else this.buf.push(v); } close(): void { - this.ended = true - for (const w of this.waiters.splice(0)) w({ value: undefined as never, done: true }) + this.ended = true; + for (const w of this.waiters.splice(0)) w({ value: undefined as never, done: true }); } [Symbol.asyncIterator](): AsyncIterator { return { next: (): Promise> => { - if (this.buf.length > 0) return Promise.resolve({ value: this.buf.shift()!, done: false }) - if (this.ended) return Promise.resolve({ value: undefined as never, done: true }) - return new Promise(res => this.waiters.push(res)) + if (this.buf.length > 0) return Promise.resolve({ value: this.buf.shift()!, done: false }); + if (this.ended) return Promise.resolve({ value: undefined as never, done: true }); + return new Promise((res) => this.waiters.push(res)); }, - } + }; } } diff --git a/packages/amico-run/src/gate.ts b/packages/amico-run/src/gate.ts index 6e7a799d..ad83bfe9 100644 --- a/packages/amico-run/src/gate.ts +++ b/packages/amico-run/src/gate.ts @@ -6,107 +6,105 @@ // env is validated against its OWN Manifest, not the extension-pinned one); // (4) tier-2 masked-baseline check; (5) stamp assembly (canonical spec + // gate-computed spec_hash). Any failure → no Julia process, one clear line. -import { createHash } from 'node:crypto' -import { existsSync, readFileSync } from 'node:fs' -import { join } from 'node:path' -import { parse as parseToml } from 'smol-toml' -import { validate } from '@amicode/schema' -import type { AuthoringConfig } from './authoring.js' -import { checkImports, scanImports } from './import_scan.js' -import { maskedHash } from './baseline.js' -import { loadExemplarsIndex } from './catalog.js' +import { createHash } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { parse as parseToml } from "smol-toml"; +import { validate } from "@amicode/schema"; +import type { AuthoringConfig } from "./authoring.js"; +import { checkImports, scanImports } from "./import_scan.js"; +import { maskedHash } from "./baseline.js"; +import { loadExemplarsIndex } from "./catalog.js"; export interface GateStamp { - tier?: string - hashes: Record // spec hashes + gate-computed spec_hash - specCanonical: string // stable-key-order JSON, what gets persisted + tier?: string; + hashes: Record; // spec hashes + gate-computed spec_hash + specCanonical: string; // stable-key-order JSON, what gets persisted } -export type GateResult = - | { ok: true; stamp: GateStamp } - | { ok: false; reason: string; demote_to?: 'free' } +export type GateResult = { ok: true; stamp: GateStamp } | { ok: false; reason: string; demote_to?: "free" }; /** Stable key order at every level so spec_hash is insensitive to author key order. */ function canonicalize(value: unknown): unknown { - if (Array.isArray(value)) return value.map(canonicalize) - if (typeof value === 'object' && value !== null) { - const out: Record = {} + if (Array.isArray(value)) return value.map(canonicalize); + if (typeof value === "object" && value !== null) { + const out: Record = {}; for (const key of Object.keys(value as Record).sort()) - out[key] = canonicalize((value as Record)[key]) - return out + out[key] = canonicalize((value as Record)[key]); + return out; } - return value + return value; } /** Julia Manifest v2 keys deps as [[deps.]] — the parsed `deps` object's * keys ARE the package names. Every Project [deps] name must appear. */ function staleEnvCheck(projectDir: string): string | undefined { - const projectFile = join(projectDir, 'Project.toml') - const manifestFile = join(projectDir, 'Manifest.toml') - if (!existsSync(projectFile)) return `env has no Project.toml at ${projectDir}` + const projectFile = join(projectDir, "Project.toml"); + const manifestFile = join(projectDir, "Manifest.toml"); + if (!existsSync(projectFile)) return `env has no Project.toml at ${projectDir}`; if (!existsSync(manifestFile)) - return `env at ${projectDir} has no Manifest.toml — instantiate it first (JULIA_PKG_USE_CLI_GIT=true julia --project=${projectDir} -e 'using Pkg; Pkg.instantiate()')` + return `env at ${projectDir} has no Manifest.toml — instantiate it first (JULIA_PKG_USE_CLI_GIT=true julia --project=${projectDir} -e 'using Pkg; Pkg.instantiate()')`; try { - const project = parseToml(readFileSync(projectFile, 'utf8')) as Record - const manifest = parseToml(readFileSync(manifestFile, 'utf8')) as Record - const wanted = Object.keys((project.deps as Record) ?? {}) - const present = new Set(Object.keys((manifest.deps as Record) ?? {})) - const missing = wanted.filter((name) => !present.has(name)) + const project = parseToml(readFileSync(projectFile, "utf8")) as Record; + const manifest = parseToml(readFileSync(manifestFile, "utf8")) as Record; + const wanted = Object.keys((project.deps as Record) ?? {}); + const present = new Set(Object.keys((manifest.deps as Record) ?? {})); + const missing = wanted.filter((name) => !present.has(name)); if (missing.length > 0) - return `stale env: ${missing.join(', ')} in Project.toml but not its Manifest — re-instantiate` + return `stale env: ${missing.join(", ")} in Project.toml but not its Manifest — re-instantiate`; } catch (e) { - return `env at ${projectDir} has an unparseable Project/Manifest: ${(e as Error).message}` + return `env at ${projectDir} has an unparseable Project/Manifest: ${(e as Error).message}`; } - return undefined + return undefined; } export function runGate(specRaw: unknown, scriptText: string, authoring: AuthoringConfig): GateResult { // ── step 1: schema ── - const validation = validate(specRaw, 'solvespec') - if (!validation.ok) return { ok: false, reason: `solvespec schema: ${validation.errors[0]}` } - const spec = specRaw as Record - const tier = typeof spec.tier === 'string' ? spec.tier : undefined - const env = (typeof spec.env === 'object' && spec.env !== null ? spec.env : undefined) as + const validation = validate(specRaw, "solvespec"); + if (!validation.ok) return { ok: false, reason: `solvespec schema: ${validation.errors[0]}` }; + const spec = specRaw as Record; + const tier = typeof spec.tier === "string" ? spec.tier : undefined; + const env = (typeof spec.env === "object" && spec.env !== null ? spec.env : undefined) as | { kind?: string; project?: string } - | undefined + | undefined; // ── step 2: import scan ── - const scanned = scanImports(scriptText) - if (!scanned.ok) return { ok: false, reason: scanned.reason } - const checked = checkImports(scanned.roots, authoring) - if (!checked.ok) return { ok: false, reason: checked.reason } + const scanned = scanImports(scriptText); + if (!scanned.ok) return { ok: false, reason: scanned.reason }; + const checked = checkImports(scanned.roots, authoring); + if (!checked.ok) return { ok: false, reason: checked.reason }; // ── step 3: tier/env consistency ── - if (tier === 'free' && env?.kind !== 'sandbox') - return { ok: false, reason: 'free tier requires a sandbox env (env.kind = "sandbox")' } - if ((env?.kind === 'project' || env?.kind === 'sandbox') && env.project) { - const stale = staleEnvCheck(env.project) - if (stale) return { ok: false, reason: stale } + if (tier === "free" && env?.kind !== "sandbox") + return { ok: false, reason: 'free tier requires a sandbox env (env.kind = "sandbox")' }; + if ((env?.kind === "project" || env?.kind === "sandbox") && env.project) { + const stale = staleEnvCheck(env.project); + if (stale) return { ok: false, reason: stale }; } // ── step 4: composed → masked baseline vs the exemplar's build-time hash ── - if (tier === 'composed') { - const exemplarId = (spec.source as Record | undefined)?.exemplar_id - if (typeof exemplarId !== 'string') - return { ok: false, reason: 'tier "composed" requires source.exemplar_id' } - const index = loadExemplarsIndex(authoring.exemplars ?? '') - const entry = index.exemplars.find((e) => e.id === exemplarId) - if (!entry) return { ok: false, reason: `unknown exemplar_id "${exemplarId}" (index: ${authoring.exemplars ?? 'absent'})` } + if (tier === "composed") { + const exemplarId = (spec.source as Record | undefined)?.exemplar_id; + if (typeof exemplarId !== "string") return { ok: false, reason: 'tier "composed" requires source.exemplar_id' }; + const index = loadExemplarsIndex(authoring.exemplars ?? ""); + const entry = index.exemplars.find((e) => e.id === exemplarId); + if (!entry) + return { ok: false, reason: `unknown exemplar_id "${exemplarId}" (index: ${authoring.exemplars ?? "absent"})` }; if (maskedHash(scriptText, entry.fill_begin, entry.fill_end) !== entry.baseline_hash) return { ok: false, reason: `script is no longer the exemplar's physics (edits outside the fill points of "${exemplarId}") — re-assemble as tier "free"`, - demote_to: 'free', - } + demote_to: "free", + }; } // ── step 5: stamp — canonical spec + gate-computed spec_hash ── - const specCanonical = JSON.stringify(canonicalize(spec), null, 2) - const specHash = 'sha256:' + createHash('sha256').update(specCanonical).digest('hex') - const hashes: Record = {} - if (typeof spec.hashes === 'object' && spec.hashes !== null) + const specCanonical = JSON.stringify(canonicalize(spec), null, 2); + const specHash = "sha256:" + createHash("sha256").update(specCanonical).digest("hex"); + const hashes: Record = {}; + if (typeof spec.hashes === "object" && spec.hashes !== null) for (const [key, value] of Object.entries(spec.hashes as Record)) - if (typeof value === 'string') hashes[key] = value - hashes.spec_hash = specHash - return { ok: true, stamp: { tier, hashes, specCanonical } } + if (typeof value === "string") hashes[key] = value; + hashes.spec_hash = specHash; + return { ok: true, stamp: { tier, hashes, specCanonical } }; } diff --git a/packages/amico-run/src/import_scan.ts b/packages/amico-run/src/import_scan.ts index a7cc5a3a..41b7801c 100644 --- a/packages/amico-run/src/import_scan.ts +++ b/packages/amico-run/src/import_scan.ts @@ -7,50 +7,63 @@ // the templates/skeletons all use one statement per line. export const JULIA_STDLIBS = new Set([ - 'LinearAlgebra', 'Random', 'Statistics', 'SparseArrays', 'Printf', 'TOML', 'Dates', - 'Test', 'Pkg', 'Serialization', 'SHA', 'Logging', 'Markdown', 'UUIDs', - 'Distributed', 'InteractiveUtils', 'Base64', 'Unicode', 'REPL', -]) + "LinearAlgebra", + "Random", + "Statistics", + "SparseArrays", + "Printf", + "TOML", + "Dates", + "Test", + "Pkg", + "Serialization", + "SHA", + "Logging", + "Markdown", + "UUIDs", + "Distributed", + "InteractiveUtils", + "Base64", + "Unicode", + "REPL", +]); -export type ScanResult = { ok: true; roots: string[] } | { ok: false; reason: string } -export type CheckResult = { ok: true } | { ok: false; reason: string } +export type ScanResult = { ok: true; roots: string[] } | { ok: false; reason: string }; +export type CheckResult = { ok: true } | { ok: false; reason: string }; -const IMPORT_LINE = /^\s*(using|import)\s+(.+)$/ +const IMPORT_LINE = /^\s*(using|import)\s+(.+)$/; /** Strip a trailing comment (naive: templates never put `#` inside strings on import lines). */ function stripComment(line: string): string { - const hash = line.indexOf('#') - return hash === -1 ? line : line.slice(0, hash) + const hash = line.indexOf("#"); + return hash === -1 ? line : line.slice(0, hash); } export function scanImports(script: string): ScanResult { - const roots: string[] = [] - for (const rawLine of script.split('\n')) { - const line = stripComment(rawLine) - const match = IMPORT_LINE.exec(line) - if (!match) continue - const payload = match[2].trim() - if (payload.endsWith(',')) - return { ok: false, reason: 'multi-line using/import not supported — one statement per line' } - for (const item of payload.split(',')) { - const trimmed = item.trim() - if (!trimmed) continue - const root = trimmed.split(/[.:\s]/, 1)[0] - if (root && !roots.includes(root)) roots.push(root) + const roots: string[] = []; + for (const rawLine of script.split("\n")) { + const line = stripComment(rawLine); + const match = IMPORT_LINE.exec(line); + if (!match) continue; + const payload = match[2].trim(); + if (payload.endsWith(",")) + return { ok: false, reason: "multi-line using/import not supported — one statement per line" }; + for (const item of payload.split(",")) { + const trimmed = item.trim(); + if (!trimmed) continue; + const root = trimmed.split(/[.:\s]/, 1)[0]; + if (root && !roots.includes(root)) roots.push(root); } } - return { ok: true, roots } + return { ok: true, roots }; } -export function checkImports( - roots: string[], - allow: { allowlist: string[]; support_set: string[] }, -): CheckResult { - const permitted = new Set([...allow.allowlist, ...allow.support_set, ...JULIA_STDLIBS]) - const blocked = roots.filter((root) => !permitted.has(root)) - if (blocked.length === 0) return { ok: true } +export function checkImports(roots: string[], allow: { allowlist: string[]; support_set: string[] }): CheckResult { + const permitted = new Set([...allow.allowlist, ...allow.support_set, ...JULIA_STDLIBS]); + const blocked = roots.filter((root) => !permitted.has(root)); + if (blocked.length === 0) return { ok: true }; return { ok: false, - reason: `${blocked.join(', ')}: not in the allowed package set (entitlement allowlist ∪ support set ∪ stdlibs)`, - } + reason: `${blocked.join(", ")}: not in the allowed package set (entitlement allowlist ∪ support set ∪ stdlibs)`, + }; } diff --git a/packages/amico-run/src/local_executor.ts b/packages/amico-run/src/local_executor.ts index da7f84d4..39da67e3 100644 --- a/packages/amico-run/src/local_executor.ts +++ b/packages/amico-run/src/local_executor.ts @@ -1,139 +1,176 @@ -import { spawn } from 'node:child_process' -import { accessSync, constants as fsConstants, createWriteStream, existsSync, mkdirSync } from 'node:fs' -import { constants as osConstants } from 'node:os' -import { delimiter, join, resolve } from 'node:path' -import * as readline from 'node:readline' -import { EventQueue } from './event_queue.js' -import { classifyLine } from './telemetry.js' +import { spawn } from "node:child_process"; +import { accessSync, constants as fsConstants, createWriteStream, existsSync, mkdirSync } from "node:fs"; +import { constants as osConstants } from "node:os"; +import { delimiter, join, resolve } from "node:path"; +import * as readline from "node:readline"; +import { EventQueue } from "./event_queue.js"; +import { classifyLine } from "./telemetry.js"; import { - appendIndex, atomicWriteFile, defaultRunsRoot, deriveLabId, generateRunId, - updateLatest, writeFinished, writeManifest, -} from './run_dir.js' + appendIndex, + atomicWriteFile, + defaultRunsRoot, + deriveLabId, + generateRunId, + updateLatest, + writeFinished, + writeManifest, +} from "./run_dir.js"; import { - ConfigError, type Executor, type Finished, type RunEvent, type RunHandle, - type RunStatus, type SubmitOpts, -} from './types.js' + ConfigError, + type Executor, + type Finished, + type RunEvent, + type RunHandle, + type RunStatus, + type SubmitOpts, +} from "./types.js"; -import pkg from '../package.json' with { type: 'json' } -const ORCHESTRATOR_VERSION = pkg.version // single source of truth (esbuild inlines the JSON) +import pkg from "../package.json" with { type: "json" }; +const ORCHESTRATOR_VERSION = pkg.version; // single source of truth (esbuild inlines the JSON) function resolveExecutable(bin: string): void { - const candidates = bin.includes('/') + const candidates = bin.includes("/") ? [resolve(bin)] - : (process.env.PATH ?? '').split(delimiter).filter(Boolean).map(d => join(d, bin)) + : (process.env.PATH ?? "") + .split(delimiter) + .filter(Boolean) + .map((d) => join(d, bin)); for (const c of candidates) { - try { accessSync(c, fsConstants.X_OK); return } catch { /* keep looking */ } + try { + accessSync(c, fsConstants.X_OK); + return; + } catch { + /* keep looking */ + } } - throw new ConfigError(`julia binary not found or not executable: ${bin}`) + throw new ConfigError(`julia binary not found or not executable: ${bin}`); } function signalCode(signal: NodeJS.Signals | null): number { - const n = signal ? (osConstants.signals as Record)[signal] : undefined - return 128 + (n ?? 1) + const n = signal ? (osConstants.signals as Record)[signal] : undefined; + return 128 + (n ?? 1); } export class LocalExecutor implements Executor { async submit(scriptPath: string, opts: SubmitOpts = {}): Promise { // ---- step 1 (spec §5): validate config; failures here create NO run dir ---- - const script = resolve(scriptPath) - if (!existsSync(script)) throw new ConfigError(`script not found: ${script}`) - const juliaBin = opts.julia?.julia ?? 'julia' - resolveExecutable(juliaBin) - const lab = opts.lab ?? 'default' - const labId = deriveLabId(lab) - const runsRoot = opts.runsRoot ?? defaultRunsRoot(labId) - try { mkdirSync(runsRoot, { recursive: true }) } catch (e) { - throw new ConfigError(`runs root not writable: ${runsRoot} (${(e as Error).message})`) + const script = resolve(scriptPath); + if (!existsSync(script)) throw new ConfigError(`script not found: ${script}`); + const juliaBin = opts.julia?.julia ?? "julia"; + resolveExecutable(juliaBin); + const lab = opts.lab ?? "default"; + const labId = deriveLabId(lab); + const runsRoot = opts.runsRoot ?? defaultRunsRoot(labId); + try { + mkdirSync(runsRoot, { recursive: true }); + } catch (e) { + throw new ConfigError(`runs root not writable: ${runsRoot} (${(e as Error).message})`); } // ---- steps 2–5: run dir, manifest FIRST, index, latest ---- - const runId = generateRunId(runsRoot) - const runDir = join(runsRoot, runId) - mkdirSync(runDir) - const createdAt = new Date().toISOString() + const runId = generateRunId(runsRoot); + const runDir = join(runsRoot, runId); + mkdirSync(runDir); + const createdAt = new Date().toISOString(); writeManifest(runDir, { // spec C: --spec launches stamp tier + hashes and bump to v2; bare runs stay v1 - schema_version: opts.spec ? '2' : '1', run_id: runId, script_path: script, - lab, lab_id: labId, created_at: createdAt, + schema_version: opts.spec ? "2" : "1", + run_id: runId, + script_path: script, + lab, + lab_id: labId, + created_at: createdAt, orchestrator_version: ORCHESTRATOR_VERSION, julia: { binary: juliaBin, project: opts.julia?.project, sysimage: opts.julia?.sysimage }, - tier: opts.spec?.tier, hashes: opts.spec?.hashes, - }) - if (opts.spec) atomicWriteFile(runDir, 'solvespec.json', opts.spec.canonical + '\n') - appendIndex(runsRoot, runId, createdAt, script) - updateLatest(runsRoot, runId) + tier: opts.spec?.tier, + hashes: opts.spec?.hashes, + }); + if (opts.spec) atomicWriteFile(runDir, "solvespec.json", opts.spec.canonical + "\n"); + appendIndex(runsRoot, runId, createdAt, script); + updateLatest(runsRoot, runId); // ---- step 6: spawn julia, own process group, cwd = runDir ---- - const args: string[] = [] - if (opts.julia?.project) args.push(`--project=${opts.julia.project}`) - if (opts.julia?.sysimage) args.push(`--sysimage=${opts.julia.sysimage}`) - args.push(script) + const args: string[] = []; + if (opts.julia?.project) args.push(`--project=${opts.julia.project}`); + if (opts.julia?.sysimage) args.push(`--sysimage=${opts.julia.sysimage}`); + args.push(script); - const events = new EventQueue() - const logStream = createWriteStream(join(runDir, 'run.log'), { flags: 'a' }) - let resolveFinished!: (f: Finished) => void - const finished = new Promise(r => { resolveFinished = r }) + const events = new EventQueue(); + const logStream = createWriteStream(join(runDir, "run.log"), { flags: "a" }); + let resolveFinished!: (f: Finished) => void; + const finished = new Promise((r) => { + resolveFinished = r; + }); - let settled = false - let aborting = false + let settled = false; + let aborting = false; const settle = (status: RunStatus, exitCode: number): void => { - if (settled) return - settled = true + if (settled) return; + settled = true; try { - writeFinished(runDir, status, exitCode) // orchestrator verdict, atomic — overwrites - } catch (e) { // any FINISHED a script faked (spec §5 step 8) - process.stderr.write(`amico-run: failed to write FINISHED: ${(e as Error).message}\n`) + writeFinished(runDir, status, exitCode); // orchestrator verdict, atomic — overwrites + } catch (e) { + // any FINISHED a script faked (spec §5 step 8) + process.stderr.write(`amico-run: failed to write FINISHED: ${(e as Error).message}\n`); } - logStream.end() - events.push({ kind: 'finished', status, exitCode }) - events.close() - resolveFinished({ status, exitCode }) - } + logStream.end(); + events.push({ kind: "finished", status, exitCode }); + events.close(); + resolveFinished({ status, exitCode }); + }; // stdbuf (spec §5 "where available") is deliberately omitted in β.1: the β.3 script // convention prints with flush, and the fake-julia fixtures are node (line-flushed). // If live ITER streaming degrades on a real lab machine, β.6's dry-run catches it. const child = spawn(juliaBin, args, { - cwd: runDir, detached: true, stdio: ['ignore', 'pipe', 'pipe'], - }) + cwd: runDir, + detached: true, + stdio: ["ignore", "pipe", "pipe"], + }); // spawn failure AFTER manifest exists → FINISHED{failed, 127} (spec §6) - child.on('error', () => settle('failed', 127)) + child.on("error", () => settle("failed", 127)); // 'close', NOT 'exit': close waits for stdout/stderr to drain, so every line event // lands before settle() — the events stream must terminate ON the finished event (§3). - child.on('close', (code, signal) => { - const rc = code ?? signalCode(signal) - settle(aborting ? 'aborted' : rc === 0 ? 'completed' : 'failed', rc) - }) + child.on("close", (code, signal) => { + const rc = code ?? signalCode(signal); + settle(aborting ? "aborted" : rc === 0 ? "completed" : "failed", rc); + }); - const onLine = (stream: 'stdout' | 'stderr') => (line: string): void => { - if (settled) return // belt-and-braces; 'close' ordering makes this rare - logStream.write(line + '\n') - events.push(classifyLine(line, stream)) - } - readline.createInterface({ input: child.stdout! }).on('line', onLine('stdout')) - readline.createInterface({ input: child.stderr! }).on('line', onLine('stderr')) + const onLine = + (stream: "stdout" | "stderr") => + (line: string): void => { + if (settled) return; // belt-and-braces; 'close' ordering makes this rare + logStream.write(line + "\n"); + events.push(classifyLine(line, stream)); + }; + readline.createInterface({ input: child.stdout! }).on("line", onLine("stdout")); + readline.createInterface({ input: child.stderr! }).on("line", onLine("stderr")); - const graceMs = opts.graceMs ?? 5000 + const graceMs = opts.graceMs ?? 5000; const abort = async (): Promise => { - if (settled) return - aborting = true + if (settled) return; + aborting = true; const killGroup = (sig: NodeJS.Signals): void => { - try { process.kill(-child.pid!, sig) } catch { /* already gone */ } - } - killGroup('SIGTERM') - const killer = setTimeout(() => killGroup('SIGKILL'), graceMs) - killer.unref() - await finished - clearTimeout(killer) - } + try { + process.kill(-child.pid!, sig); + } catch { + /* already gone */ + } + }; + killGroup("SIGTERM"); + const killer = setTimeout(() => killGroup("SIGKILL"), graceMs); + killer.unref(); + await finished; + clearTimeout(killer); + }; - return { runId, runDir, events, finished, abort } + return { runId, runDir, events, finished, abort }; } } /** Spec §3: interface seam only — implementation is post-β. */ export class RemoteExecutor implements Executor { submit(): Promise { - return Promise.reject(new Error('RemoteExecutor: not implemented in β (D9 plan, Phase 2+)')) + return Promise.reject(new Error("RemoteExecutor: not implemented in β (D9 plan, Phase 2+)")); } } diff --git a/packages/amico-run/src/run_dir.ts b/packages/amico-run/src/run_dir.ts index e799e25b..5c5db6f1 100644 --- a/packages/amico-run/src/run_dir.ts +++ b/packages/amico-run/src/run_dir.ts @@ -1,62 +1,63 @@ -import { existsSync, writeFileSync, renameSync, appendFileSync, symlinkSync, rmSync } from 'node:fs' -import { randomBytes } from 'node:crypto' -import { homedir } from 'node:os' -import { join, dirname, basename, resolve } from 'node:path' -import { ConfigError, type RunStatus } from './types.js' +import { existsSync, writeFileSync, renameSync, appendFileSync, symlinkSync, rmSync } from "node:fs"; +import { randomBytes } from "node:crypto"; +import { homedir } from "node:os"; +import { join, dirname, basename, resolve } from "node:path"; +import { ConfigError, type RunStatus } from "./types.js"; -const ID_RE = /^[a-z0-9][a-z0-9_-]*$/ +const ID_RE = /^[a-z0-9][a-z0-9_-]*$/; /** Spec §3: id pointers verbatim; path pointers (contain "/" or end ".toml") * derive the id from the parent directory name of the lab.toml. */ export function deriveLabId(lab: string): string { - if (ID_RE.test(lab)) return lab - if (lab.includes('/') || lab.endsWith('.toml')) { - const id = basename(dirname(resolve(lab))) - if (ID_RE.test(id)) return id - throw new ConfigError(`cannot derive lab id from "${lab}": parent dir "${id}" is not a valid id`) + if (ID_RE.test(lab)) return lab; + if (lab.includes("/") || lab.endsWith(".toml")) { + const id = basename(dirname(resolve(lab))); + if (ID_RE.test(id)) return id; + throw new ConfigError(`cannot derive lab id from "${lab}": parent dir "${id}" is not a valid id`); } - throw new ConfigError(`invalid lab pointer "${lab}" (want [a-z0-9][a-z0-9_-]* or a lab.toml path)`) + throw new ConfigError(`invalid lab pointer "${lab}" (want [a-z0-9][a-z0-9_-]* or a lab.toml path)`); } export function defaultRunsRoot(labId: string): string { - return join(homedir(), '.amico', 'runs', labId) + return join(homedir(), ".amico", "runs", labId); } export function generateRunId(runsRoot: string, now = new Date()): string { - const p = (n: number, w = 2) => String(n).padStart(w, '0') - const ts = `${now.getUTCFullYear()}${p(now.getUTCMonth() + 1)}${p(now.getUTCDate())}` + - `-${p(now.getUTCHours())}${p(now.getUTCMinutes())}${p(now.getUTCSeconds())}Z` + const p = (n: number, w = 2) => String(n).padStart(w, "0"); + const ts = + `${now.getUTCFullYear()}${p(now.getUTCMonth() + 1)}${p(now.getUTCDate())}` + + `-${p(now.getUTCHours())}${p(now.getUTCMinutes())}${p(now.getUTCSeconds())}Z`; for (;;) { - const id = `r${ts}-${randomBytes(2).toString('hex')}` - if (!existsSync(join(runsRoot, id))) return id + const id = `r${ts}-${randomBytes(2).toString("hex")}`; + if (!existsSync(join(runsRoot, id))) return id; } } /** Write-temp-then-rename in the same dir: a watcher can never observe a partial file. */ export function atomicWriteFile(dir: string, name: string, content: string): void { - const tmp = join(dir, `.${name}.tmp-${process.pid}`) - writeFileSync(tmp, content) - renameSync(tmp, join(dir, name)) + const tmp = join(dir, `.${name}.tmp-${process.pid}`); + writeFileSync(tmp, content); + renameSync(tmp, join(dir, name)); } -const ts = (s: string) => JSON.stringify(s) // JSON escaping is valid TOML basic-string +const ts = (s: string) => JSON.stringify(s); // JSON escaping is valid TOML basic-string export interface Manifest { - schema_version: '1' | '2' - run_id: string - script_path: string - lab: string - lab_id: string - created_at: string - orchestrator_version: string - julia: { binary: string; project?: string; sysimage?: string } + schema_version: "1" | "2"; + run_id: string; + script_path: string; + lab: string; + lab_id: string; + created_at: string; + orchestrator_version: string; + julia: { binary: string; project?: string; sysimage?: string }; // v2 (spec C, --spec launches only) — bare runs stay byte-identical v1 - tier?: string - hashes?: Record + tier?: string; + hashes?: Record; } export function writeManifest(runDir: string, m: Manifest): void { - const hashEntries = Object.entries(m.hashes ?? {}) + const hashEntries = Object.entries(m.hashes ?? {}); const lines = [ `schema_version = ${ts(m.schema_version)}`, ...(m.tier ? [`tier = ${ts(m.tier)}`] : []), @@ -66,35 +67,33 @@ export function writeManifest(runDir: string, m: Manifest): void { `lab_id = ${ts(m.lab_id)}`, `created_at = ${ts(m.created_at)}`, `orchestrator_version = ${ts(m.orchestrator_version)}`, - '', - '[julia]', + "", + "[julia]", `binary = ${ts(m.julia.binary)}`, ...(m.julia.project ? [`project = ${ts(m.julia.project)}`] : []), ...(m.julia.sysimage ? [`sysimage = ${ts(m.julia.sysimage)}`] : []), - ...(hashEntries.length > 0 - ? ['', '[hashes]', ...hashEntries.map(([key, value]) => `${key} = ${ts(value)}`)] - : []), - ] - atomicWriteFile(runDir, 'run.toml', lines.join('\n') + '\n') + ...(hashEntries.length > 0 ? ["", "[hashes]", ...hashEntries.map(([key, value]) => `${key} = ${ts(value)}`)] : []), + ]; + atomicWriteFile(runDir, "run.toml", lines.join("\n") + "\n"); } export function writeFinished(runDir: string, status: RunStatus, exitCode: number): void { - atomicWriteFile(runDir, 'FINISHED', `status = ${ts(status)}\nexit_code = ${exitCode}\n`) + atomicWriteFile(runDir, "FINISHED", `status = ${ts(status)}\nexit_code = ${exitCode}\n`); } export function appendIndex(runsRoot: string, runId: string, createdAt: string, scriptPath: string): void { // The index is a tab-separated, one-line-per-run log; a tab/newline in the // (last-field) script path would corrupt it. Sanitize control chars to a // space — run.toml holds the canonical, TOML-escaped script_path. - const safePath = scriptPath.replace(/[\t\r\n]/g, ' ') - appendFileSync(join(runsRoot, 'index'), `${runId}\t${createdAt}\t${safePath}\n`) + const safePath = scriptPath.replace(/[\t\r\n]/g, " "); + appendFileSync(join(runsRoot, "index"), `${runId}\t${createdAt}\t${safePath}\n`); } export function updateLatest(runsRoot: string, runId: string): void { // Scope the temp name to runId so concurrent same-lab submits don't race on // a shared `.latest.tmp` (one would unlink the other's in-flight temp). - const tmp = join(runsRoot, `.latest.${runId}.tmp`) - rmSync(tmp, { force: true }) - symlinkSync(runId, tmp) - renameSync(tmp, join(runsRoot, 'latest')) + const tmp = join(runsRoot, `.latest.${runId}.tmp`); + rmSync(tmp, { force: true }); + symlinkSync(runId, tmp); + renameSync(tmp, join(runsRoot, "latest")); } diff --git a/packages/amico-run/src/subcommands.ts b/packages/amico-run/src/subcommands.ts index b0cc66a0..c01b1263 100644 --- a/packages/amico-run/src/subcommands.ts +++ b/packages/amico-run/src/subcommands.ts @@ -4,65 +4,77 @@ // the Amicode workflow. Dispatch only fires when argv[0] is the literal // subcommand AND is not an existing file (a bare script named `resolve` keeps // the launch contract). -import { existsSync, mkdirSync, writeFileSync } from 'node:fs' -import { dirname, join, resolve } from 'node:path' -import { readAuthoring } from './authoring.js' -import { loadExemplarsIndex, loadRegistry, matchShape } from './catalog.js' -import { JULIA_STDLIBS } from './import_scan.js' +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { readAuthoring } from "./authoring.js"; +import { loadExemplarsIndex, loadRegistry, matchShape } from "./catalog.js"; +import { JULIA_STDLIBS } from "./import_scan.js"; /** Tier-3 minimum package set — the free skeleton's `using` block AND the * re-rollout harness both need these in the sandbox env, so `resolve` returns * them for tier free (an empty set would generate an uninstantiable env). */ -const TIER3_MIN_PACKAGES = ['Piccolo', 'CairoMakie', 'JLD2', 'TOML', 'Printf'] +const TIER3_MIN_PACKAGES = ["Piccolo", "CairoMakie", "JLD2", "TOML", "Printf"]; function flagValue(argv: string[], name: string): string | undefined { - const i = argv.indexOf(name) - return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined + const i = argv.indexOf(name); + return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined; } export function resolveCommand(argv: string[]): number { - const platform = flagValue(argv, '--platform') - const kind = flagValue(argv, '--kind') - const sizeRaw = flagValue(argv, '--size') + const platform = flagValue(argv, "--platform"); + const kind = flagValue(argv, "--kind"); + const sizeRaw = flagValue(argv, "--size"); if (!platform || !kind || !sizeRaw) { - console.error('amico-run resolve: --platform, --kind, --size are all required') - return 64 + console.error("amico-run resolve: --platform, --kind, --size are all required"); + return 64; + } + const size = Number(sizeRaw); + if (!Number.isFinite(size)) { + console.error(`amico-run resolve: --size must be a number (got ${sizeRaw})`); + return 64; } - const size = Number(sizeRaw) - if (!Number.isFinite(size)) { console.error(`amico-run resolve: --size must be a number (got ${sizeRaw})`); return 64 } - const { config } = readAuthoring() - const registry = loadRegistry(config.registry ?? '') - const exemplars = loadExemplarsIndex(config.exemplars ?? '') - const match = matchShape({ platform, kind, size }, registry, exemplars, config.allowlist) + const { config } = readAuthoring(); + const registry = loadRegistry(config.registry ?? ""); + const exemplars = loadExemplarsIndex(config.exemplars ?? ""); + const match = matchShape({ platform, kind, size }, registry, exemplars, config.allowlist); // template/exemplar paths in the catalog are relative to their manifest file; // resolve to absolute so the agent can copy the script directly. - const registryDir = config.registry ? dirname(config.registry) : process.cwd() - const exemplarsDir = config.exemplars ? dirname(config.exemplars) : process.cwd() - const out: Record = { tier: match.tier } + const registryDir = config.registry ? dirname(config.registry) : process.cwd(); + const exemplarsDir = config.exemplars ? dirname(config.exemplars) : process.cwd(); + const out: Record = { tier: match.tier }; if (match.template) { - out.source = { template_id: match.template.id } - out.template_path = resolve(registryDir, match.template.path) - out.packages = match.template.packages + out.source = { template_id: match.template.id }; + out.template_path = resolve(registryDir, match.template.path); + out.packages = match.template.packages; } else if (match.exemplar) { - out.source = { exemplar_id: match.exemplar.id } - out.exemplar_path = resolve(exemplarsDir, match.exemplar.path) - out.packages = match.exemplar.packages + out.source = { exemplar_id: match.exemplar.id }; + out.exemplar_path = resolve(exemplarsDir, match.exemplar.path); + out.packages = match.exemplar.packages; } else { - out.packages = TIER3_MIN_PACKAGES + out.packages = TIER3_MIN_PACKAGES; } - if (match.blockedHigher) out.blocked_higher = match.blockedHigher - console.log(JSON.stringify(out)) - return 0 + if (match.blockedHigher) out.blocked_higher = match.blockedHigher; + console.log(JSON.stringify(out)); + return 0; } export function sandboxCommand(argv: string[]): number { - const target = argv[0] - if (!target || target.startsWith('-')) { console.error('amico-run sandbox: required'); return 64 } - const packagesRaw = flagValue(argv, '--packages') - if (!packagesRaw) { console.error('amico-run sandbox: --packages A,B,… required'); return 64 } - const packages = packagesRaw.split(',').map((p) => p.trim()).filter(Boolean) + const target = argv[0]; + if (!target || target.startsWith("-")) { + console.error("amico-run sandbox: required"); + return 64; + } + const packagesRaw = flagValue(argv, "--packages"); + if (!packagesRaw) { + console.error("amico-run sandbox: --packages A,B,… required"); + return 64; + } + const packages = packagesRaw + .split(",") + .map((p) => p.trim()) + .filter(Boolean); // Julia stdlibs load from @stdlib in LOAD_PATH regardless of a project's // [deps] — they need no uuid and no [deps] entry. Filter them so the sandbox @@ -70,34 +82,34 @@ export function sandboxCommand(argv: string[]): number { // defect #2: TIER3_MIN_PACKAGES ships Printf+TOML, both stdlibs with no // [uuids] entry, which exit-64'd every tier-free launch at env generation). // Non-stdlib packages still require a uuid — the unknown-package guard holds. - const depsNeeded = packages.filter((p) => !JULIA_STDLIBS.has(p)) + const depsNeeded = packages.filter((p) => !JULIA_STDLIBS.has(p)); - const { config } = readAuthoring() - const registry = loadRegistry(config.registry ?? '') - const missing = depsNeeded.filter((p) => !registry.uuids[p]) + const { config } = readAuthoring(); + const registry = loadRegistry(config.registry ?? ""); + const missing = depsNeeded.filter((p) => !registry.uuids[p]); if (missing.length > 0) { - console.error(`amico-run sandbox: no uuid in the registry for: ${missing.join(', ')}`) - return 64 + console.error(`amico-run sandbox: no uuid in the registry for: ${missing.join(", ")}`); + return 64; } const deps = depsNeeded .slice() .sort() .map((p) => `${p} = ${JSON.stringify(registry.uuids[p])}`) - .join('\n') - const envDir = join(target, 'env') - mkdirSync(envDir, { recursive: true }) - writeFileSync(join(envDir, 'Project.toml'), `[deps]\n${deps}\n`) - console.log(`amico-run: wrote ${join(envDir, 'Project.toml')}`) - console.log(`instantiate it (private git deps need CLI git):`) - console.log(` JULIA_PKG_USE_CLI_GIT=true julia --project=${envDir} -e 'using Pkg; Pkg.instantiate()'`) - return 0 + .join("\n"); + const envDir = join(target, "env"); + mkdirSync(envDir, { recursive: true }); + writeFileSync(join(envDir, "Project.toml"), `[deps]\n${deps}\n`); + console.log(`amico-run: wrote ${join(envDir, "Project.toml")}`); + console.log(`instantiate it (private git deps need CLI git):`); + console.log(` JULIA_PKG_USE_CLI_GIT=true julia --project=${envDir} -e 'using Pkg; Pkg.instantiate()'`); + return 0; } /** Dispatch a subcommand if argv[0] names one and is not an existing file. */ export function trySubcommand(argv: string[]): number | undefined { - const head = argv[0] - if (head === 'resolve' && !existsSync(head)) return resolveCommand(argv.slice(1)) - if (head === 'sandbox' && !existsSync(head)) return sandboxCommand(argv.slice(1)) - return undefined + const head = argv[0]; + if (head === "resolve" && !existsSync(head)) return resolveCommand(argv.slice(1)); + if (head === "sandbox" && !existsSync(head)) return sandboxCommand(argv.slice(1)); + return undefined; } diff --git a/packages/amico-run/src/telemetry.ts b/packages/amico-run/src/telemetry.ts index b88159f9..a3f8d72a 100644 --- a/packages/amico-run/src/telemetry.ts +++ b/packages/amico-run/src/telemetry.ts @@ -1,14 +1,14 @@ -import type { RunEvent } from './types.js' +import type { RunEvent } from "./types.js"; -export function classifyLine(line: string, stream: 'stdout' | 'stderr'): RunEvent { - if (stream === 'stdout' && line.startsWith('AMICODE_ITER')) { - const fields: Record = {} - for (const tok of line.slice('AMICODE_ITER'.length).trim().split(/\s+/)) { - const eq = tok.indexOf('=') - if (eq > 0) fields[tok.slice(0, eq)] = tok.slice(eq + 1) +export function classifyLine(line: string, stream: "stdout" | "stderr"): RunEvent { + if (stream === "stdout" && line.startsWith("AMICODE_ITER")) { + const fields: Record = {}; + for (const tok of line.slice("AMICODE_ITER".length).trim().split(/\s+/)) { + const eq = tok.indexOf("="); + if (eq > 0) fields[tok.slice(0, eq)] = tok.slice(eq + 1); } - return { kind: 'iter', raw: line, fields } + return { kind: "iter", raw: line, fields }; } - if (stream === 'stdout' && /^DONE(\s|$)/.test(line)) return { kind: 'done', raw: line } - return { kind: 'log', stream, line } + if (stream === "stdout" && /^DONE(\s|$)/.test(line)) return { kind: "done", raw: line }; + return { kind: "log", stream, line }; } diff --git a/packages/amico-run/src/types.ts b/packages/amico-run/src/types.ts index 67625612..86550fa6 100644 --- a/packages/amico-run/src/types.ts +++ b/packages/amico-run/src/types.ts @@ -1,46 +1,49 @@ -export type RunStatus = 'completed' | 'failed' | 'aborted' +export type RunStatus = "completed" | "failed" | "aborted"; export interface JuliaOpts { - julia?: string // julia binary path; default "julia" from PATH - project?: string // --project= - sysimage?: string // --sysimage= + julia?: string; // julia binary path; default "julia" from PATH + project?: string; // --project= + sysimage?: string; // --sysimage= } export interface SubmitOpts { - lab?: string // lab POINTER (id or lab.toml path), passed through verbatim; default "default" - runsRoot?: string // default: ~/.amico/runs// - julia?: JuliaOpts - graceMs?: number // abort SIGTERM→SIGKILL grace; default 5000. Test knob, NOT exposed in the CLI. - spec?: SpecStamp // spec C: gate-passed SolveSpec → solvespec.json persisted + run.toml v2 stamped + lab?: string; // lab POINTER (id or lab.toml path), passed through verbatim; default "default" + runsRoot?: string; // default: ~/.amico/runs// + julia?: JuliaOpts; + graceMs?: number; // abort SIGTERM→SIGKILL grace; default 5000. Test knob, NOT exposed in the CLI. + spec?: SpecStamp; // spec C: gate-passed SolveSpec → solvespec.json persisted + run.toml v2 stamped } /** What a gate-passed --spec launch carries into the run dir (spec C). */ export interface SpecStamp { - canonical: string // stable-key-order solvespec.json body - tier?: string - hashes?: Record // incl. gate-computed spec_hash - julia_binary?: string // resolved julia bin — the free-tier verify harness runs under it - env_project?: string // resolved env project — --project for the harness + canonical: string; // stable-key-order solvespec.json body + tier?: string; + hashes?: Record; // incl. gate-computed spec_hash + julia_binary?: string; // resolved julia bin — the free-tier verify harness runs under it + env_project?: string; // resolved env project — --project for the harness } export type RunEvent = - | { kind: 'iter'; raw: string; fields: Record } - | { kind: 'done'; raw: string } - | { kind: 'log'; stream: 'stdout' | 'stderr'; line: string } - | { kind: 'finished'; status: RunStatus; exitCode: number } - -export interface Finished { status: RunStatus; exitCode: number } + | { kind: "iter"; raw: string; fields: Record } + | { kind: "done"; raw: string } + | { kind: "log"; stream: "stdout" | "stderr"; line: string } + | { kind: "finished"; status: RunStatus; exitCode: number }; + +export interface Finished { + status: RunStatus; + exitCode: number; +} export interface RunHandle { - runId: string - runDir: string - events: AsyncIterable // terminates after the 'finished' event - finished: Promise // never rejects - abort(): Promise // idempotent + runId: string; + runDir: string; + events: AsyncIterable; // terminates after the 'finished' event + finished: Promise; // never rejects + abort(): Promise; // idempotent } export interface Executor { - submit(scriptPath: string, opts?: SubmitOpts): Promise + submit(scriptPath: string, opts?: SubmitOpts): Promise; } /** Exit-64-class fault: bad config, nothing solver-related ran. */ diff --git a/packages/amico-run/src/verify.ts b/packages/amico-run/src/verify.ts index eb61fa51..e168bd83 100644 --- a/packages/amico-run/src/verify.ts +++ b/packages/amico-run/src/verify.ts @@ -6,14 +6,14 @@ // writing, we write a fallback verification.toml with agree=false + a reason — // a free run must NEVER end verification-less (absence would read as "pending" // forever and mask a failure, and the auto-promote gate keys off agree==true). -import { spawn } from 'node:child_process' -import { existsSync, renameSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' -import type { AuthoringConfig } from './authoring.js' -import type { SpecStamp } from './types.js' +import { spawn } from "node:child_process"; +import { existsSync, renameSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import type { AuthoringConfig } from "./authoring.js"; +import type { SpecStamp } from "./types.js"; function tomlEscape(s: string): string { - return JSON.stringify(s) + return JSON.stringify(s); } function writeFallback(runDir: string, reason: string, tolerance: number): void { @@ -24,34 +24,35 @@ function writeFallback(runDir: string, reason: string, tolerance: number): void `fidelity_reported = "nan"\n` + `tolerance = ${tolerance}\n` + `integrator = "none"\n` + - `error = ${tomlEscape(reason)}\n` - const tmp = join(runDir, `.verification.toml.tmp-${process.pid}`) - writeFileSync(tmp, body) - renameSync(tmp, join(runDir, 'verification.toml')) + `error = ${tomlEscape(reason)}\n`; + const tmp = join(runDir, `.verification.toml.tmp-${process.pid}`); + writeFileSync(tmp, body); + renameSync(tmp, join(runDir, "verification.toml")); } /** Run the harness; guarantee a verification.toml exists afterward. Never rejects. */ export async function runVerification(runDir: string, spec: SpecStamp, authoring: AuthoringConfig): Promise { - const tolerance = authoring.verify_tolerance - const harness = authoring.verify_harness + const tolerance = authoring.verify_tolerance; + const harness = authoring.verify_harness; if (!harness || !existsSync(harness)) { - writeFallback(runDir, `verification harness not found (${harness ?? 'unset'})`, tolerance) - return + writeFallback(runDir, `verification harness not found (${harness ?? "unset"})`, tolerance); + return; } // The harness interpreter is julia in production; AMICO_VERIFY_RUNNER overrides // it for tests (node fake-harness). The env's project comes from the spec. - const runner = process.env.AMICO_VERIFY_RUNNER ?? spec.julia_binary ?? 'julia' - const args = runner === 'julia' && spec.env_project - ? [`--project=${spec.env_project}`, harness, runDir, String(tolerance)] - : [harness, runDir, String(tolerance)] + const runner = process.env.AMICO_VERIFY_RUNNER ?? spec.julia_binary ?? "julia"; + const args = + runner === "julia" && spec.env_project + ? [`--project=${spec.env_project}`, harness, runDir, String(tolerance)] + : [harness, runDir, String(tolerance)]; const exitCode: number = await new Promise((resolvePromise) => { - const child = spawn(runner, args, { stdio: ['ignore', 'inherit', 'inherit'] }) - child.on('error', () => resolvePromise(127)) - child.on('close', (code) => resolvePromise(code ?? 1)) - }) + const child = spawn(runner, args, { stdio: ["ignore", "inherit", "inherit"] }); + child.on("error", () => resolvePromise(127)); + child.on("close", (code) => resolvePromise(code ?? 1)); + }); - if (!existsSync(join(runDir, 'verification.toml'))) { - writeFallback(runDir, `verification harness exited ${exitCode} without writing verification.toml`, tolerance) + if (!existsSync(join(runDir, "verification.toml"))) { + writeFallback(runDir, `verification harness exited ${exitCode} without writing verification.toml`, tolerance); } } diff --git a/packages/amico-run/test/abort.test.ts b/packages/amico-run/test/abort.test.ts index 8946c0b1..fefcd12b 100644 --- a/packages/amico-run/test/abort.test.ts +++ b/packages/amico-run/test/abort.test.ts @@ -1,44 +1,46 @@ -import { describe, it, expect } from 'vitest' -import { join } from 'node:path' -import { tmpRoot, fakeJulia, readToml } from './helpers.js' -import { LocalExecutor } from '../src/local_executor.js' +import { describe, it, expect } from "vitest"; +import { join } from "node:path"; +import { tmpRoot, fakeJulia, readToml } from "./helpers.js"; +import { LocalExecutor } from "../src/local_executor.js"; -const HANG = `setInterval(() => {}, 1000)` // dies on SIGTERM → 143 +const HANG = `setInterval(() => {}, 1000)`; // dies on SIGTERM → 143 // prints READY only after the SIGTERM handler is installed — the test must not abort // before then, or the signal hits node's default disposition during interpreter boot (→ 143) -const HANG_IGNORE = `process.on('SIGTERM', () => {}); console.log('READY'); setInterval(() => {}, 1000)` +const HANG_IGNORE = `process.on('SIGTERM', () => {}); console.log('READY'); setInterval(() => {}, 1000)`; -describe('abort lane (spec §3/§6)', () => { - it('abort() on a hanging run → FINISHED{aborted, 143} (SIGTERM)', async () => { - const root = tmpRoot() - const h = await new LocalExecutor().submit(fakeJulia(root, 's.jl', ''), { - runsRoot: join(root, 'runs'), julia: { julia: fakeJulia(root, 'j', HANG) }, - }) - await h.abort() - expect(await h.finished).toEqual({ status: 'aborted', exitCode: 143 }) - expect(readToml(join(h.runDir, 'FINISHED'))).toEqual({ status: 'aborted', exit_code: 143 }) - }) +describe("abort lane (spec §3/§6)", () => { + it("abort() on a hanging run → FINISHED{aborted, 143} (SIGTERM)", async () => { + const root = tmpRoot(); + const h = await new LocalExecutor().submit(fakeJulia(root, "s.jl", ""), { + runsRoot: join(root, "runs"), + julia: { julia: fakeJulia(root, "j", HANG) }, + }); + await h.abort(); + expect(await h.finished).toEqual({ status: "aborted", exitCode: 143 }); + expect(readToml(join(h.runDir, "FINISHED"))).toEqual({ status: "aborted", exit_code: 143 }); + }); - it('SIGTERM-ignoring script is SIGKILLed after grace → FINISHED{aborted, 137}', async () => { - const root = tmpRoot() - const h = await new LocalExecutor().submit(fakeJulia(root, 's.jl', ''), { - runsRoot: join(root, 'runs'), - julia: { julia: fakeJulia(root, 'j', HANG_IGNORE) }, - graceMs: 200, // test knob — spec default is 5000 - }) + it("SIGTERM-ignoring script is SIGKILLed after grace → FINISHED{aborted, 137}", async () => { + const root = tmpRoot(); + const h = await new LocalExecutor().submit(fakeJulia(root, "s.jl", ""), { + runsRoot: join(root, "runs"), + julia: { julia: fakeJulia(root, "j", HANG_IGNORE) }, + graceMs: 200, // test knob — spec default is 5000 + }); for await (const e of h.events) { - if (e.kind === 'log' && e.line === 'READY') void h.abort() // handler installed — now abort + if (e.kind === "log" && e.line === "READY") void h.abort(); // handler installed — now abort } - expect(await h.finished).toEqual({ status: 'aborted', exitCode: 137 }) - }, 15000) + expect(await h.finished).toEqual({ status: "aborted", exitCode: 137 }); + }, 15000); - it('abort() is idempotent and a no-op after completion', async () => { - const root = tmpRoot() - const h = await new LocalExecutor().submit(fakeJulia(root, 's.jl', ''), { - runsRoot: join(root, 'runs'), julia: { julia: fakeJulia(root, 'j', 'process.exit(0)') }, - }) - await h.finished - await expect(h.abort()).resolves.toBeUndefined() - expect(readToml(join(h.runDir, 'FINISHED')).status).toBe('completed') - }) -}) + it("abort() is idempotent and a no-op after completion", async () => { + const root = tmpRoot(); + const h = await new LocalExecutor().submit(fakeJulia(root, "s.jl", ""), { + runsRoot: join(root, "runs"), + julia: { julia: fakeJulia(root, "j", "process.exit(0)") }, + }); + await h.finished; + await expect(h.abort()).resolves.toBeUndefined(); + expect(readToml(join(h.runDir, "FINISHED")).status).toBe("completed"); + }); +}); diff --git a/packages/amico-run/test/authoring.test.ts b/packages/amico-run/test/authoring.test.ts index 27903517..9060e22e 100644 --- a/packages/amico-run/test/authoring.test.ts +++ b/packages/amico-run/test/authoring.test.ts @@ -1,20 +1,20 @@ -import { describe, it, expect, afterEach } from "vitest" -import { mkdtempSync, writeFileSync, rmSync } from "node:fs" -import { tmpdir } from "node:os" -import { join } from "node:path" -import { readAuthoring, DEFAULT_ALLOWLIST, DEFAULT_SUPPORT } from "../src/authoring.js" +import { describe, it, expect, afterEach } from "vitest"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { readAuthoring, DEFAULT_ALLOWLIST, DEFAULT_SUPPORT } from "../src/authoring.js"; -let dir: string | undefined +let dir: string | undefined; afterEach(() => { - delete process.env.AMICO_AUTHORING_FILE - if (dir) rmSync(dir, { recursive: true, force: true }) - dir = undefined -}) + delete process.env.AMICO_AUTHORING_FILE; + if (dir) rmSync(dir, { recursive: true, force: true }); + dir = undefined; +}); describe("readAuthoring", () => { it("reads the file named by $AMICO_AUTHORING_FILE, fields round-trip", () => { - dir = mkdtempSync(join(tmpdir(), "amico-authoring-")) - const file = join(dir, "authoring.json") + dir = mkdtempSync(join(tmpdir(), "amico-authoring-")); + const file = join(dir, "authoring.json"); writeFileSync( file, JSON.stringify({ @@ -26,36 +26,36 @@ describe("readAuthoring", () => { verify_harness: "/abs/verify_rollout.jl", verify_tolerance: 0.02, }), - ) - process.env.AMICO_AUTHORING_FILE = file - const { config, warning } = readAuthoring() - expect(warning).toBeUndefined() - expect(config.allowlist).toEqual(["Piccolo", "Piccolissimo"]) - expect(config.support_set).toEqual(["JLD2"]) - expect(config.registry).toBe("/abs/registry.toml") - expect(config.exemplars).toBe("/abs/index.json") - expect(config.verify_harness).toBe("/abs/verify_rollout.jl") - expect(config.verify_tolerance).toBe(0.02) - }) + ); + process.env.AMICO_AUTHORING_FILE = file; + const { config, warning } = readAuthoring(); + expect(warning).toBeUndefined(); + expect(config.allowlist).toEqual(["Piccolo", "Piccolissimo"]); + expect(config.support_set).toEqual(["JLD2"]); + expect(config.registry).toBe("/abs/registry.toml"); + expect(config.exemplars).toBe("/abs/index.json"); + expect(config.verify_harness).toBe("/abs/verify_rollout.jl"); + expect(config.verify_tolerance).toBe(0.02); + }); it("missing file → conservative built-in defaults, no warning", () => { - process.env.AMICO_AUTHORING_FILE = "/nonexistent/authoring.json" - const { config, warning } = readAuthoring() - expect(warning).toBeUndefined() - expect(config.allowlist).toEqual(DEFAULT_ALLOWLIST) - expect(config.allowlist).toEqual(["Piccolo", "Legato", "Intonato", "NamedTrajectories", "DirectTrajOpt"]) - expect(config.support_set).toEqual(DEFAULT_SUPPORT) - expect(config.support_set).toEqual(expect.arrayContaining(["JLD2", "CairoMakie", "TOML"])) - expect(config.verify_tolerance).toBe(0.001) // spec-20260704-113005 §6 (resolves spec-C open q1) - }) + process.env.AMICO_AUTHORING_FILE = "/nonexistent/authoring.json"; + const { config, warning } = readAuthoring(); + expect(warning).toBeUndefined(); + expect(config.allowlist).toEqual(DEFAULT_ALLOWLIST); + expect(config.allowlist).toEqual(["Piccolo", "Legato", "Intonato", "NamedTrajectories", "DirectTrajOpt"]); + expect(config.support_set).toEqual(DEFAULT_SUPPORT); + expect(config.support_set).toEqual(expect.arrayContaining(["JLD2", "CairoMakie", "TOML"])); + expect(config.verify_tolerance).toBe(0.001); // spec-20260704-113005 §6 (resolves spec-C open q1) + }); it("malformed JSON → defaults + a warning naming the file", () => { - dir = mkdtempSync(join(tmpdir(), "amico-authoring-")) - const file = join(dir, "authoring.json") - writeFileSync(file, "{nope") - process.env.AMICO_AUTHORING_FILE = file - const { config, warning } = readAuthoring() - expect(config.allowlist).toEqual(DEFAULT_ALLOWLIST) - expect(warning).toContain("authoring.json") - }) -}) + dir = mkdtempSync(join(tmpdir(), "amico-authoring-")); + const file = join(dir, "authoring.json"); + writeFileSync(file, "{nope"); + process.env.AMICO_AUTHORING_FILE = file; + const { config, warning } = readAuthoring(); + expect(config.allowlist).toEqual(DEFAULT_ALLOWLIST); + expect(warning).toContain("authoring.json"); + }); +}); diff --git a/packages/amico-run/test/baseline.test.ts b/packages/amico-run/test/baseline.test.ts index b49e6f8e..a0b06a87 100644 --- a/packages/amico-run/test/baseline.test.ts +++ b/packages/amico-run/test/baseline.test.ts @@ -1,36 +1,36 @@ -import { describe, it, expect } from "vitest" -import { maskFillPoints, maskedHash } from "../src/baseline.js" +import { describe, it, expect } from "vitest"; +import { maskFillPoints, maskedHash } from "../src/baseline.js"; -const SCRIPT = `using Piccolo\n# ── FILL IN ──────\nT = 10.0\nN = 50\n# ─────────────────\nsolve()\n` +const SCRIPT = `using Piccolo\n# ── FILL IN ──────\nT = 10.0\nN = 50\n# ─────────────────\nsolve()\n`; describe("maskedHash", () => { it("is edit-invariant inside fill points, sensitive outside", () => { - const edited = SCRIPT.replace("T = 10.0", "T = 25.0") - expect(maskedHash(SCRIPT)).toBe(maskedHash(edited)) - const physics = SCRIPT.replace("solve()", "solve!(hacked)") - expect(maskedHash(SCRIPT)).not.toBe(maskedHash(physics)) - }) + const edited = SCRIPT.replace("T = 10.0", "T = 25.0"); + expect(maskedHash(SCRIPT)).toBe(maskedHash(edited)); + const physics = SCRIPT.replace("solve()", "solve!(hacked)"); + expect(maskedHash(SCRIPT)).not.toBe(maskedHash(physics)); + }); it("custom markers override the defaults", () => { - const custom = `a\n# BEGIN-KNOBS\nx = 1\n# END-KNOBS\nb\n` - const edited = custom.replace("x = 1", "x = 999") + const custom = `a\n# BEGIN-KNOBS\nx = 1\n# END-KNOBS\nb\n`; + const edited = custom.replace("x = 1", "x = 999"); expect(maskedHash(custom, "^# BEGIN-KNOBS", "^# END-KNOBS")).toBe( maskedHash(edited, "^# BEGIN-KNOBS", "^# END-KNOBS"), - ) + ); // default markers don't match this file → edits are visible - expect(maskedHash(custom)).not.toBe(maskedHash(edited)) - }) + expect(maskedHash(custom)).not.toBe(maskedHash(edited)); + }); it("an unterminated block masks to EOF", () => { - const open = `head\n# ── FILL IN ──\nx = 1\ny = 2\n` - const edited = open.replace("y = 2", "y = 3") - expect(maskedHash(open)).toBe(maskedHash(edited)) + const open = `head\n# ── FILL IN ──\nx = 1\ny = 2\n`; + const edited = open.replace("y = 2", "y = 3"); + expect(maskedHash(open)).toBe(maskedHash(edited)); // but the head is still sensitive - expect(maskedHash(open)).not.toBe(maskedHash(open.replace("head", "HEAD"))) - }) + expect(maskedHash(open)).not.toBe(maskedHash(open.replace("head", "HEAD"))); + }); it("the masked text keeps the marker lines and replaces interior lines", () => { - const masked = maskFillPoints(SCRIPT) - expect(masked).toContain("# ── FILL IN") - expect(masked).toContain("# ─────") - expect(masked).not.toContain("T = 10.0") - expect(masked).toContain("#MASKED") - }) -}) + const masked = maskFillPoints(SCRIPT); + expect(masked).toContain("# ── FILL IN"); + expect(masked).toContain("# ─────"); + expect(masked).not.toContain("T = 10.0"); + expect(masked).toContain("#MASKED"); + }); +}); diff --git a/packages/amico-run/test/catalog.test.ts b/packages/amico-run/test/catalog.test.ts index 98ab8331..737022af 100644 --- a/packages/amico-run/test/catalog.test.ts +++ b/packages/amico-run/test/catalog.test.ts @@ -1,14 +1,14 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest" -import { mkdtempSync, writeFileSync, rmSync } from "node:fs" -import { tmpdir } from "node:os" -import { join } from "node:path" -import { loadRegistry, loadExemplarsIndex, matchShape } from "../src/catalog.js" +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadRegistry, loadExemplarsIndex, matchShape } from "../src/catalog.js"; -let dir: string +let dir: string; beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), "amico-catalog-")) -}) -afterEach(() => rmSync(dir, { recursive: true, force: true })) + dir = mkdtempSync(join(tmpdir(), "amico-catalog-")); +}); +afterEach(() => rmSync(dir, { recursive: true, force: true })); const REGISTRY = ` verify_tolerance = 0.01 @@ -47,7 +47,7 @@ packages = ["JLD2", "CairoMakie", "TOML", "Printf"] [uuids] Piccolo = "c4671d76-df94-11ed-2057-43d4fd632fad" JLD2 = "033835bb-8acc-5ee8-8aae-3f567f8a3819" -` +`; const INDEX = JSON.stringify({ schema_version: 1, @@ -62,68 +62,85 @@ const INDEX = JSON.stringify({ baseline_hash: "sha256:deadbeef", }, ], -}) +}); function seed() { - writeFileSync(join(dir, "registry.toml"), REGISTRY) - writeFileSync(join(dir, "index.json"), INDEX) + writeFileSync(join(dir, "registry.toml"), REGISTRY); + writeFileSync(join(dir, "index.json"), INDEX); return { registry: loadRegistry(join(dir, "registry.toml")), exemplars: loadExemplarsIndex(join(dir, "index.json")), - } + }; } -const PUBLIC_ALLOW = ["Piccolo", "Legato", "Intonato", "NamedTrajectories", "DirectTrajOpt"] +const PUBLIC_ALLOW = ["Piccolo", "Legato", "Intonato", "NamedTrajectories", "DirectTrajOpt"]; describe("loaders", () => { it("registry parses templates, support set, uuids, tolerance", () => { - const { registry } = seed() - expect(registry.templates).toHaveLength(3) - expect(registry.support).toEqual(["JLD2", "CairoMakie", "TOML", "Printf"]) - expect(registry.uuids.Piccolo).toBe("c4671d76-df94-11ed-2057-43d4fd632fad") - expect(registry.verifyTolerance).toBe(0.01) - }) + const { registry } = seed(); + expect(registry.templates).toHaveLength(3); + expect(registry.support).toEqual(["JLD2", "CairoMakie", "TOML", "Printf"]); + expect(registry.uuids.Piccolo).toBe("c4671d76-df94-11ed-2057-43d4fd632fad"); + expect(registry.verifyTolerance).toBe(0.01); + }); it("missing files → empty catalog, never throws", () => { - expect(loadRegistry(join(dir, "nope.toml")).templates).toEqual([]) - expect(loadExemplarsIndex(join(dir, "nope.json")).exemplars).toEqual([]) - }) -}) + expect(loadRegistry(join(dir, "nope.toml")).templates).toEqual([]); + expect(loadExemplarsIndex(join(dir, "nope.json")).exemplars).toEqual([]); + }); +}); describe("matchShape", () => { it("exact vetted template match → tier 1", () => { - const { registry, exemplars } = seed() - const match = matchShape({ platform: "transmon", kind: "gate_synthesis", size: 1 }, registry, exemplars, PUBLIC_ALLOW) - expect(match.tier).toBe("vetted") - expect(match.template?.id).toBe("transmon-gate-1q") - }) + const { registry, exemplars } = seed(); + const match = matchShape( + { platform: "transmon", kind: "gate_synthesis", size: 1 }, + registry, + exemplars, + PUBLIC_ALLOW, + ); + expect(match.tier).toBe("vetted"); + expect(match.template?.id).toBe("transmon-gate-1q"); + }); it("experimental templates are NEVER tier 1 — falls through to the exemplar", () => { - const { registry, exemplars } = seed() - const match = matchShape({ platform: "rydberg", kind: "gate_synthesis", size: 2 }, registry, exemplars, PUBLIC_ALLOW) - expect(match.tier).toBe("composed") - expect(match.exemplar?.id).toBe("rydberg-cz") - }) + const { registry, exemplars } = seed(); + const match = matchShape( + { platform: "rydberg", kind: "gate_synthesis", size: 2 }, + registry, + exemplars, + PUBLIC_ALLOW, + ); + expect(match.tier).toBe("composed"); + expect(match.exemplar?.id).toBe("rydberg-cz"); + }); it("no template and no exemplar → tier 3 (free)", () => { - const { registry, exemplars } = seed() - expect(matchShape({ platform: "ions", kind: "gate_synthesis", size: 1 }, registry, exemplars, PUBLIC_ALLOW).tier).toBe("free") - }) + const { registry, exemplars } = seed(); + expect( + matchShape({ platform: "ions", kind: "gate_synthesis", size: 1 }, registry, exemplars, PUBLIC_ALLOW).tier, + ).toBe("free"); + }); it("entitlement-blocked vetted match is excluded AND reported as blocked_higher", () => { - const { registry, exemplars } = seed() - const match = matchShape({ platform: "transmon", kind: "state_prep", size: 1 }, registry, exemplars, PUBLIC_ALLOW) - expect(match.tier).toBe("free") - expect(match.blockedHigher).toEqual({ tier: "vetted", requires: "issimo" }) + const { registry, exemplars } = seed(); + const match = matchShape({ platform: "transmon", kind: "state_prep", size: 1 }, registry, exemplars, PUBLIC_ALLOW); + expect(match.tier).toBe("free"); + expect(match.blockedHigher).toEqual({ tier: "vetted", requires: "issimo" }); // with the issimo packages allowed, the same shape resolves tier 1 - const withIssimo = matchShape( - { platform: "transmon", kind: "state_prep", size: 1 }, + const withIssimo = matchShape({ platform: "transmon", kind: "state_prep", size: 1 }, registry, exemplars, [ + ...PUBLIC_ALLOW, + "Piccolissimo", + "Strettissimo", + "Intonatissimo", + ]); + expect(withIssimo.tier).toBe("vetted"); + expect(withIssimo.template?.id).toBe("issimo-special-1q"); + }); + it("exemplar match on platform+kind tolerates a size mismatch (near match)", () => { + const { registry, exemplars } = seed(); + const match = matchShape( + { platform: "rydberg", kind: "gate_synthesis", size: 3 }, registry, exemplars, - [...PUBLIC_ALLOW, "Piccolissimo", "Strettissimo", "Intonatissimo"], - ) - expect(withIssimo.tier).toBe("vetted") - expect(withIssimo.template?.id).toBe("issimo-special-1q") - }) - it("exemplar match on platform+kind tolerates a size mismatch (near match)", () => { - const { registry, exemplars } = seed() - const match = matchShape({ platform: "rydberg", kind: "gate_synthesis", size: 3 }, registry, exemplars, PUBLIC_ALLOW) - expect(match.tier).toBe("composed") - }) -}) + PUBLIC_ALLOW, + ); + expect(match.tier).toBe("composed"); + }); +}); diff --git a/packages/amico-run/test/cli.test.ts b/packages/amico-run/test/cli.test.ts index bf4368db..b814297f 100644 --- a/packages/amico-run/test/cli.test.ts +++ b/packages/amico-run/test/cli.test.ts @@ -1,150 +1,204 @@ -import { describe, it, expect, beforeAll } from 'vitest' -import { execFileSync, execFile } from 'node:child_process' -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' -import { tmpRoot, fakeJulia, readToml } from './helpers.js' +import { describe, it, expect, beforeAll } from "vitest"; +import { execFileSync, execFile } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpRoot, fakeJulia, readToml } from "./helpers.js"; -const BUNDLE = join(__dirname, '..', 'dist', 'amico-run.js') +const BUNDLE = join(__dirname, "..", "dist", "amico-run.js"); beforeAll(() => { - execFileSync('node', [join(__dirname, '..', 'esbuild.config.mjs')], { cwd: join(__dirname, '..') }) -}) + execFileSync("node", [join(__dirname, "..", "esbuild.config.mjs")], { cwd: join(__dirname, "..") }); +}); function run(args: string[], env: Record = {}): { code: number; stdout: string; stderr: string } { try { - const stdout = execFileSync('node', [BUNDLE, ...args], { encoding: 'utf8', env: { ...process.env, ...env } }) - return { code: 0, stdout, stderr: '' } + const stdout = execFileSync("node", [BUNDLE, ...args], { encoding: "utf8", env: { ...process.env, ...env } }); + return { code: 0, stdout, stderr: "" }; } catch (e) { - const err = e as { status?: number; stdout?: string; stderr?: string } - return { code: err.status ?? -1, stdout: err.stdout ?? '', stderr: err.stderr ?? '' } + const err = e as { status?: number; stdout?: string; stderr?: string }; + return { code: err.status ?? -1, stdout: err.stdout ?? "", stderr: err.stderr ?? "" }; } } -describe('amico-run CLI', () => { - it('clean solve: relays iter lines, prints AMICODE_FINISHED, exits 0', () => { - const root = tmpRoot() - const julia = fakeJulia(root, 'j', `console.log('AMICODE_ITER iter=1 f=0.5'); console.log('DONE f=0.99')`) - const script = fakeJulia(root, 's.jl', '') - const r = run([script, '--runs-root', join(root, 'runs'), '--julia', julia]) - expect(r.code).toBe(0) - expect(r.stdout).toContain('AMICODE_ITER iter=1 f=0.5') - expect(r.stdout).toMatch(/AMICODE_FINISHED status=completed exitCode=0 runDir=.+/) - }) - it('julia rc 7 passes through as exit 7', () => { - const root = tmpRoot() - const r = run([fakeJulia(root, 's.jl', ''), '--runs-root', join(root, 'runs'), - '--julia', fakeJulia(root, 'j', 'process.exit(7)')]) - expect(r.code).toBe(7) - expect(r.stdout).toContain('status=failed exitCode=7') - }) - it('missing script → 64, stderr one-liner, no run dir', () => { - const root = tmpRoot() - const r = run([join(root, 'nope.jl'), '--runs-root', join(root, 'runs')]) - expect(r.code).toBe(64) - expect(r.stderr).toMatch(/script not found/) - }) - it('unknown flag → 64 (never silently swallowed, spec Q68)', () => { - const root = tmpRoot() - const r = run([fakeJulia(root, 's.jl', ''), '--gates', 'X']) - expect(r.code).toBe(64) - expect(r.stderr).toMatch(/unknown flag/) - }) - it('--executor remote → 64 (only local in β)', () => { - const root = tmpRoot() - const r = run([fakeJulia(root, 's.jl', ''), '--executor', 'remote']) - expect(r.code).toBe(64) - }) - it('--spec: gate failure → 64, one-line stderr reason, NO run dir (spec C)', () => { - const root = tmpRoot() - const script = fakeJulia(root, 's.jl', '') - writeFileSync(join(root, 'bad.json'), JSON.stringify({ nope: true })) - const r = run([script, '--runs-root', join(root, 'runs'), '--spec', join(root, 'bad.json'), - '--julia', fakeJulia(root, 'j', '')]) - expect(r.code).toBe(64) - expect(r.stderr).toMatch(/solvespec schema/) - expect(existsSync(join(root, 'runs'))).toBe(false) - }) - it('--spec pass: solvespec.json persisted canonical + run.toml v2 stamped (spec C)', () => { - const root = tmpRoot() - const script = fakeJulia(root, 's.jl', '') +describe("amico-run CLI", () => { + it("clean solve: relays iter lines, prints AMICODE_FINISHED, exits 0", () => { + const root = tmpRoot(); + const julia = fakeJulia(root, "j", `console.log('AMICODE_ITER iter=1 f=0.5'); console.log('DONE f=0.99')`); + const script = fakeJulia(root, "s.jl", ""); + const r = run([script, "--runs-root", join(root, "runs"), "--julia", julia]); + expect(r.code).toBe(0); + expect(r.stdout).toContain("AMICODE_ITER iter=1 f=0.5"); + expect(r.stdout).toMatch(/AMICODE_FINISHED status=completed exitCode=0 runDir=.+/); + }); + it("julia rc 7 passes through as exit 7", () => { + const root = tmpRoot(); + const r = run([ + fakeJulia(root, "s.jl", ""), + "--runs-root", + join(root, "runs"), + "--julia", + fakeJulia(root, "j", "process.exit(7)"), + ]); + expect(r.code).toBe(7); + expect(r.stdout).toContain("status=failed exitCode=7"); + }); + it("missing script → 64, stderr one-liner, no run dir", () => { + const root = tmpRoot(); + const r = run([join(root, "nope.jl"), "--runs-root", join(root, "runs")]); + expect(r.code).toBe(64); + expect(r.stderr).toMatch(/script not found/); + }); + it("unknown flag → 64 (never silently swallowed, spec Q68)", () => { + const root = tmpRoot(); + const r = run([fakeJulia(root, "s.jl", ""), "--gates", "X"]); + expect(r.code).toBe(64); + expect(r.stderr).toMatch(/unknown flag/); + }); + it("--executor remote → 64 (only local in β)", () => { + const root = tmpRoot(); + const r = run([fakeJulia(root, "s.jl", ""), "--executor", "remote"]); + expect(r.code).toBe(64); + }); + it("--spec: gate failure → 64, one-line stderr reason, NO run dir (spec C)", () => { + const root = tmpRoot(); + const script = fakeJulia(root, "s.jl", ""); + writeFileSync(join(root, "bad.json"), JSON.stringify({ nope: true })); + const r = run([ + script, + "--runs-root", + join(root, "runs"), + "--spec", + join(root, "bad.json"), + "--julia", + fakeJulia(root, "j", ""), + ]); + expect(r.code).toBe(64); + expect(r.stderr).toMatch(/solvespec schema/); + expect(existsSync(join(root, "runs"))).toBe(false); + }); + it("--spec pass: solvespec.json persisted canonical + run.toml v2 stamped (spec C)", () => { + const root = tmpRoot(); + const script = fakeJulia(root, "s.jl", ""); const spec = { - schema_version: '2', script_path: script, lab_id: 'default', - executor: 'local', tier: 'vetted', - hashes: { system_hash: 'sha256:ab' }, - } - writeFileSync(join(root, 'spec.json'), JSON.stringify(spec)) - const r = run([script, '--runs-root', join(root, 'runs'), '--spec', join(root, 'spec.json'), - '--julia', fakeJulia(root, 'j', `console.log('DONE f=0.99')`)]) - expect(r.code).toBe(0) - const match = /runDir=(\S+)/.exec(r.stdout) - expect(match).toBeTruthy() - const runDir = match![1] - const persisted = JSON.parse(readFileSync(join(runDir, 'solvespec.json'), 'utf8')) - expect(persisted).toMatchObject({ tier: 'vetted', lab_id: 'default' }) - const manifest = readToml(join(runDir, 'run.toml')) - expect(manifest.schema_version).toBe('2') - expect(manifest.tier).toBe('vetted') - expect((manifest.hashes as Record).system_hash).toBe('sha256:ab') - expect((manifest.hashes as Record).spec_hash).toMatch(/^sha256:/) - }) - it('--spec env.kind=project sets the julia --project arg from env.project (spec C)', () => { - const root = tmpRoot() - const script = fakeJulia(root, 's.jl', '') - const env = join(root, 'env') - mkdirSync(env, { recursive: true }) - writeFileSync(join(env, 'Project.toml'), `[deps]\n`) - writeFileSync(join(env, 'Manifest.toml'), `julia_version = "1.11.0"\n`) + schema_version: "2", + script_path: script, + lab_id: "default", + executor: "local", + tier: "vetted", + hashes: { system_hash: "sha256:ab" }, + }; + writeFileSync(join(root, "spec.json"), JSON.stringify(spec)); + const r = run([ + script, + "--runs-root", + join(root, "runs"), + "--spec", + join(root, "spec.json"), + "--julia", + fakeJulia(root, "j", `console.log('DONE f=0.99')`), + ]); + expect(r.code).toBe(0); + const match = /runDir=(\S+)/.exec(r.stdout); + expect(match).toBeTruthy(); + const runDir = match![1]; + const persisted = JSON.parse(readFileSync(join(runDir, "solvespec.json"), "utf8")); + expect(persisted).toMatchObject({ tier: "vetted", lab_id: "default" }); + const manifest = readToml(join(runDir, "run.toml")); + expect(manifest.schema_version).toBe("2"); + expect(manifest.tier).toBe("vetted"); + expect((manifest.hashes as Record).system_hash).toBe("sha256:ab"); + expect((manifest.hashes as Record).spec_hash).toMatch(/^sha256:/); + }); + it("--spec env.kind=project sets the julia --project arg from env.project (spec C)", () => { + const root = tmpRoot(); + const script = fakeJulia(root, "s.jl", ""); + const env = join(root, "env"); + mkdirSync(env, { recursive: true }); + writeFileSync(join(env, "Project.toml"), `[deps]\n`); + writeFileSync(join(env, "Manifest.toml"), `julia_version = "1.11.0"\n`); const spec = { - schema_version: '2', script_path: script, lab_id: 'default', - tier: 'vetted', env: { kind: 'project', project: env }, - } - writeFileSync(join(root, 'spec.json'), JSON.stringify(spec)) - const julia = fakeJulia(root, 'j', `console.log('ARGS ' + process.argv.slice(2).join(' '))`) - const r = run([script, '--runs-root', join(root, 'runs'), '--spec', join(root, 'spec.json'), '--julia', julia]) - expect(r.code).toBe(0) - expect(r.stdout).toContain(`--project=${env}`) - }) - it('--spec tier=free: verification runs after FINISHED (AMICODE_VERIFIED + verification.toml); vetted: neither (spec C)', () => { - const root = tmpRoot() - const script = fakeJulia(root, 's.jl', '') - const env = join(root, 'env') - mkdirSync(env, { recursive: true }) - writeFileSync(join(env, 'Project.toml'), `[deps]\n`) - writeFileSync(join(env, 'Manifest.toml'), `julia_version = "1.11.0"\n`) + schema_version: "2", + script_path: script, + lab_id: "default", + tier: "vetted", + env: { kind: "project", project: env }, + }; + writeFileSync(join(root, "spec.json"), JSON.stringify(spec)); + const julia = fakeJulia(root, "j", `console.log('ARGS ' + process.argv.slice(2).join(' '))`); + const r = run([script, "--runs-root", join(root, "runs"), "--spec", join(root, "spec.json"), "--julia", julia]); + expect(r.code).toBe(0); + expect(r.stdout).toContain(`--project=${env}`); + }); + it("--spec tier=free: verification runs after FINISHED (AMICODE_VERIFIED + verification.toml); vetted: neither (spec C)", () => { + const root = tmpRoot(); + const script = fakeJulia(root, "s.jl", ""); + const env = join(root, "env"); + mkdirSync(env, { recursive: true }); + writeFileSync(join(env, "Project.toml"), `[deps]\n`); + writeFileSync(join(env, "Manifest.toml"), `julia_version = "1.11.0"\n`); // fake harness (node) that writes agree=true; wired as the julia binary so // runVerification spawns it (AMICO_VERIFY_RUNNER unset → spec.julia_binary) - const harness = fakeJulia(root, 'h.js', - `const fs=require('fs'),p=require('path');fs.writeFileSync(p.join(process.argv[process.argv.length-2],'verification.toml'),'schema_version = "1"\\nagree = true\\n')`) - writeFileSync(join(root, 'authoring.json'), JSON.stringify({ - schema_version: 1, allowlist: ['Piccolo'], support_set: ['JLD2', 'TOML'], - verify_harness: harness, verify_tolerance: 0.01, - })) - const julia = fakeJulia(root, 'j', `console.log('DONE f=0.99')`) - const AUTH = { AMICO_AUTHORING_FILE: join(root, 'authoring.json'), AMICO_VERIFY_RUNNER: harness } + const harness = fakeJulia( + root, + "h.js", + `const fs=require('fs'),p=require('path');fs.writeFileSync(p.join(process.argv[process.argv.length-2],'verification.toml'),'schema_version = "1"\\nagree = true\\n')`, + ); + writeFileSync( + join(root, "authoring.json"), + JSON.stringify({ + schema_version: 1, + allowlist: ["Piccolo"], + support_set: ["JLD2", "TOML"], + verify_harness: harness, + verify_tolerance: 0.01, + }), + ); + const julia = fakeJulia(root, "j", `console.log('DONE f=0.99')`); + const AUTH = { AMICO_AUTHORING_FILE: join(root, "authoring.json"), AMICO_VERIFY_RUNNER: harness }; - const freeSpec = { schema_version: '2', script_path: script, lab_id: 'default', tier: 'free', env: { kind: 'sandbox', project: env } } - writeFileSync(join(root, 'free.json'), JSON.stringify(freeSpec)) - const rFree = run([script, '--runs-root', join(root, 'runs'), '--spec', join(root, 'free.json'), '--julia', julia], AUTH) - expect(rFree.code).toBe(0) - expect(rFree.stdout).toMatch(/AMICODE_VERIFIED agree=true/) - const freeDir = /runDir=(\S+)/.exec(rFree.stdout)![1] - expect(existsSync(join(freeDir, 'verification.toml'))).toBe(true) + const freeSpec = { + schema_version: "2", + script_path: script, + lab_id: "default", + tier: "free", + env: { kind: "sandbox", project: env }, + }; + writeFileSync(join(root, "free.json"), JSON.stringify(freeSpec)); + const rFree = run( + [script, "--runs-root", join(root, "runs"), "--spec", join(root, "free.json"), "--julia", julia], + AUTH, + ); + expect(rFree.code).toBe(0); + expect(rFree.stdout).toMatch(/AMICODE_VERIFIED agree=true/); + const freeDir = /runDir=(\S+)/.exec(rFree.stdout)![1]; + expect(existsSync(join(freeDir, "verification.toml"))).toBe(true); - const vetSpec = { schema_version: '2', script_path: script, lab_id: 'default', tier: 'vetted', env: { kind: 'provisioned' } } - writeFileSync(join(root, 'vet.json'), JSON.stringify(vetSpec)) - const rVet = run([script, '--runs-root', join(root, 'runs2'), '--spec', join(root, 'vet.json'), '--julia', julia], AUTH) - expect(rVet.stdout).not.toMatch(/AMICODE_VERIFIED/) - const vetDir = /runDir=(\S+)/.exec(rVet.stdout)![1] - expect(existsSync(join(vetDir, 'verification.toml'))).toBe(false) - }) - it('SIGTERM to the CLI → abort lane, exit 130', async () => { - const root = tmpRoot() - const julia = fakeJulia(root, 'j', `console.log('READY'); setInterval(() => {}, 1000)`) - const script = fakeJulia(root, 's.jl', '') - const code: number = await new Promise(resolveP => { - const child = execFile('node', [BUNDLE, script, '--runs-root', join(root, 'runs'), '--julia', julia]) - child.stdout!.on('data', (d: string) => { if (d.includes('READY')) child.kill('SIGTERM') }) - child.on('exit', c => resolveP(c ?? -1)) - }) - expect(code).toBe(130) - }, 15000) -}) + const vetSpec = { + schema_version: "2", + script_path: script, + lab_id: "default", + tier: "vetted", + env: { kind: "provisioned" }, + }; + writeFileSync(join(root, "vet.json"), JSON.stringify(vetSpec)); + const rVet = run( + [script, "--runs-root", join(root, "runs2"), "--spec", join(root, "vet.json"), "--julia", julia], + AUTH, + ); + expect(rVet.stdout).not.toMatch(/AMICODE_VERIFIED/); + const vetDir = /runDir=(\S+)/.exec(rVet.stdout)![1]; + expect(existsSync(join(vetDir, "verification.toml"))).toBe(false); + }); + it("SIGTERM to the CLI → abort lane, exit 130", async () => { + const root = tmpRoot(); + const julia = fakeJulia(root, "j", `console.log('READY'); setInterval(() => {}, 1000)`); + const script = fakeJulia(root, "s.jl", ""); + const code: number = await new Promise((resolveP) => { + const child = execFile("node", [BUNDLE, script, "--runs-root", join(root, "runs"), "--julia", julia]); + child.stdout!.on("data", (d: string) => { + if (d.includes("READY")) child.kill("SIGTERM"); + }); + child.on("exit", (c) => resolveP(c ?? -1)); + }); + expect(code).toBe(130); + }, 15000); +}); diff --git a/packages/amico-run/test/failure_lanes.test.ts b/packages/amico-run/test/failure_lanes.test.ts index c6622bd1..b5819530 100644 --- a/packages/amico-run/test/failure_lanes.test.ts +++ b/packages/amico-run/test/failure_lanes.test.ts @@ -1,128 +1,143 @@ -import { describe, it, expect } from 'vitest' -import { chmodSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' -import { tmpRoot, fakeJulia, readToml } from './helpers.js' -import { LocalExecutor } from '../src/local_executor.js' +import { describe, it, expect } from "vitest"; +import { chmodSync, mkdirSync, readdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpRoot, fakeJulia, readToml } from "./helpers.js"; +import { LocalExecutor } from "../src/local_executor.js"; const sub = (root: string, julia: string, script: string) => - new LocalExecutor().submit(script, { runsRoot: join(root, 'runs'), julia: { julia } }) + new LocalExecutor().submit(script, { runsRoot: join(root, "runs"), julia: { julia } }); -describe('§6 failure matrix', () => { - it('nonzero exit → FINISHED{failed, rc}', async () => { - const root = tmpRoot() - const h = await sub(root, fakeJulia(root, 'j', 'process.exit(3)'), fakeJulia(root, 's.jl', '')) - expect(await h.finished).toEqual({ status: 'failed', exitCode: 3 }) - expect(readToml(join(h.runDir, 'FINISHED'))).toEqual({ status: 'failed', exit_code: 3 }) - }) +describe("§6 failure matrix", () => { + it("nonzero exit → FINISHED{failed, rc}", async () => { + const root = tmpRoot(); + const h = await sub(root, fakeJulia(root, "j", "process.exit(3)"), fakeJulia(root, "s.jl", "")); + expect(await h.finished).toEqual({ status: "failed", exitCode: 3 }); + expect(readToml(join(h.runDir, "FINISHED"))).toEqual({ status: "failed", exit_code: 3 }); + }); - it('crash before any output → FINISHED{failed}, manifest still valid', async () => { - const root = tmpRoot() - const h = await sub(root, fakeJulia(root, 'j', 'throw new Error("boom")'), fakeJulia(root, 's.jl', '')) - const f = await h.finished - expect(f.status).toBe('failed') - expect(readToml(join(h.runDir, 'run.toml')).run_id).toBe(h.runId) - }) + it("crash before any output → FINISHED{failed}, manifest still valid", async () => { + const root = tmpRoot(); + const h = await sub(root, fakeJulia(root, "j", 'throw new Error("boom")'), fakeJulia(root, "s.jl", "")); + const f = await h.finished; + expect(f.status).toBe("failed"); + expect(readToml(join(h.runDir, "run.toml")).run_id).toBe(h.runId); + }); - it('spawn failure after manifest (X_OK dir → spawn error) → FINISHED{failed, 127}', async () => { - const root = tmpRoot() + it("spawn failure after manifest (X_OK dir → spawn error) → FINISHED{failed, 127}", async () => { + const root = tmpRoot(); // a directory passes step-1 X_OK validation, but spawn() itself errors → child.on('error') - const dirAsJulia = join(root, 'julia-dir') - mkdirSync(dirAsJulia, { mode: 0o755 }) - const h = await sub(root, dirAsJulia, fakeJulia(root, 's.jl', '')) - expect(await h.finished).toEqual({ status: 'failed', exitCode: 127 }) - expect(readToml(join(h.runDir, 'run.toml')).run_id).toBe(h.runId) // manifest survived - }) + const dirAsJulia = join(root, "julia-dir"); + mkdirSync(dirAsJulia, { mode: 0o755 }); + const h = await sub(root, dirAsJulia, fakeJulia(root, "s.jl", "")); + expect(await h.finished).toEqual({ status: "failed", exitCode: 127 }); + expect(readToml(join(h.runDir, "run.toml")).run_id).toBe(h.runId); // manifest survived + }); - it('shell exec-failure rc passes through verbatim (wrapper execs missing target)', async () => { - const root = tmpRoot() - const wrapper = join(root, 'julia-wrapper') - writeFileSync(wrapper, '#!/usr/bin/env bash\nexec /nonexistent/amico-test-julia "$@"\n') - chmodSync(wrapper, 0o755) - const h = await sub(root, wrapper, fakeJulia(root, 's.jl', '')) - const f = await h.finished - expect(f.status).toBe('failed') - expect([126, 127]).toContain(f.exitCode) // bash version dependent; both are julia-rc passthrough - }) + it("shell exec-failure rc passes through verbatim (wrapper execs missing target)", async () => { + const root = tmpRoot(); + const wrapper = join(root, "julia-wrapper"); + writeFileSync(wrapper, '#!/usr/bin/env bash\nexec /nonexistent/amico-test-julia "$@"\n'); + chmodSync(wrapper, 0o755); + const h = await sub(root, wrapper, fakeJulia(root, "s.jl", "")); + const f = await h.finished; + expect(f.status).toBe("failed"); + expect([126, 127]).toContain(f.exitCode); // bash version dependent; both are julia-rc passthrough + }); - it('crash mid-stream: iter events delivered, then FINISHED{failed}', async () => { - const root = tmpRoot() - const julia = fakeJulia(root, 'j', ` + it("crash mid-stream: iter events delivered, then FINISHED{failed}", async () => { + const root = tmpRoot(); + const julia = fakeJulia( + root, + "j", + ` console.log('AMICODE_ITER iter=1 f=0.5') console.log('AMICODE_ITER iter=2 f=0.1') - process.exit(3)`) - const h = await sub(root, julia, fakeJulia(root, 's.jl', '')) - const evs: string[] = [] - for await (const e of h.events) evs.push(e.kind) - expect(evs.filter(k => k === 'iter')).toHaveLength(2) - expect(evs.at(-1)).toBe('finished') - expect((await h.finished).exitCode).toBe(3) - }) + process.exit(3)`, + ); + const h = await sub(root, julia, fakeJulia(root, "s.jl", "")); + const evs: string[] = []; + for await (const e of h.events) evs.push(e.kind); + expect(evs.filter((k) => k === "iter")).toHaveLength(2); + expect(evs.at(-1)).toBe("finished"); + expect((await h.finished).exitCode).toBe(3); + }); - it('julia killed by an EXTERNAL signal (not abort) → FINISHED{failed, 128+sig}', async () => { - const root = tmpRoot() + it("julia killed by an EXTERNAL signal (not abort) → FINISHED{failed, 128+sig}", async () => { + const root = tmpRoot(); // self-inflicted SIGTERM stands in for an external kill: abort() was never called, // so status must be failed (143), not aborted - const julia = fakeJulia(root, 'j', `process.kill(process.pid, 'SIGTERM')`) - const h = await sub(root, julia, fakeJulia(root, 's.jl', '')) - expect(await h.finished).toEqual({ status: 'failed', exitCode: 143 }) - }) + const julia = fakeJulia(root, "j", `process.kill(process.pid, 'SIGTERM')`); + const h = await sub(root, julia, fakeJulia(root, "s.jl", "")); + expect(await h.finished).toEqual({ status: "failed", exitCode: 143 }); + }); - it('manifest is on disk BEFORE julia spawns (script observes it in cwd at startup)', async () => { - const root = tmpRoot() - const julia = fakeJulia(root, 'j', - `process.exit(require('node:fs').existsSync('run.toml') ? 0 : 7)`) - const h = await sub(root, julia, fakeJulia(root, 's.jl', '')) - expect(await h.finished).toEqual({ status: 'completed', exitCode: 0 }) - }) + it("manifest is on disk BEFORE julia spawns (script observes it in cwd at startup)", async () => { + const root = tmpRoot(); + const julia = fakeJulia(root, "j", `process.exit(require('node:fs').existsSync('run.toml') ? 0 : 7)`); + const h = await sub(root, julia, fakeJulia(root, "s.jl", "")); + expect(await h.finished).toEqual({ status: "completed", exitCode: 0 }); + }); - it('garbage / binary stdout never crashes the parser; classified as log', async () => { - const root = tmpRoot() - const h = await sub(root, - fakeJulia(root, 'j', `process.stdout.write(Buffer.from([0xff, 0xfe, 0x0a])); console.log('ok')`), - fakeJulia(root, 's.jl', '')) - expect((await h.finished).status).toBe('completed') - }) + it("garbage / binary stdout never crashes the parser; classified as log", async () => { + const root = tmpRoot(); + const h = await sub( + root, + fakeJulia(root, "j", `process.stdout.write(Buffer.from([0xff, 0xfe, 0x0a])); console.log('ok')`), + fakeJulia(root, "s.jl", ""), + ); + expect((await h.finished).status).toBe("completed"); + }); - it('script that writes nothing at all still yields manifest + FINISHED', async () => { - const root = tmpRoot() - const h = await sub(root, fakeJulia(root, 'j', ''), fakeJulia(root, 's.jl', '')) - await h.finished - expect(readToml(join(h.runDir, 'FINISHED')).status).toBe('completed') - }) + it("script that writes nothing at all still yields manifest + FINISHED", async () => { + const root = tmpRoot(); + const h = await sub(root, fakeJulia(root, "j", ""), fakeJulia(root, "s.jl", "")); + await h.finished; + expect(readToml(join(h.runDir, "FINISHED")).status).toBe("completed"); + }); it("script's own bogus FINISHED is overwritten by the orchestrator verdict", async () => { - const root = tmpRoot() - const julia = fakeJulia(root, 'j', ` + const root = tmpRoot(); + const julia = fakeJulia( + root, + "j", + ` require('node:fs').writeFileSync('FINISHED', 'status = "completed"\\nexit_code = 0\\n') - process.exit(9)`) - const h = await sub(root, julia, fakeJulia(root, 's.jl', '')) - await h.finished - expect(readToml(join(h.runDir, 'FINISHED'))).toEqual({ status: 'failed', exit_code: 9 }) - }) + process.exit(9)`, + ); + const h = await sub(root, julia, fakeJulia(root, "s.jl", "")); + await h.finished; + expect(readToml(join(h.runDir, "FINISHED"))).toEqual({ status: "failed", exit_code: 9 }); + }); - it('exactly one finished event; events iterator terminates', async () => { - const root = tmpRoot() - const h = await sub(root, fakeJulia(root, 'j', 'process.exit(0)'), fakeJulia(root, 's.jl', '')) - let n = 0 - for await (const e of h.events) if (e.kind === 'finished') n++ - expect(n).toBe(1) - }) + it("exactly one finished event; events iterator terminates", async () => { + const root = tmpRoot(); + const h = await sub(root, fakeJulia(root, "j", "process.exit(0)"), fakeJulia(root, "s.jl", "")); + let n = 0; + for await (const e of h.events) if (e.kind === "finished") n++; + expect(n).toBe(1); + }); - it('no partial orchestrator file is ever observable (tight-loop reader, spec §8)', async () => { - const root = tmpRoot() - const julia = fakeJulia(root, 'j', ` + it("no partial orchestrator file is ever observable (tight-loop reader, spec §8)", async () => { + const root = tmpRoot(); + const julia = fakeJulia( + root, + "j", + ` let i = 0 const t = setInterval(() => { console.log('AMICODE_ITER iter=' + ++i + ' f=0.1') - if (i >= 20) { clearInterval(t) } }, 10)`) - const h = await sub(root, julia, fakeJulia(root, 's.jl', '')) - let sawTmp = false - let done = false - void h.finished.then(() => { done = true }) + if (i >= 20) { clearInterval(t) } }, 10)`, + ); + const h = await sub(root, julia, fakeJulia(root, "s.jl", "")); + let sawTmp = false; + let done = false; + void h.finished.then(() => { + done = true; + }); while (!done) { - if (readdirSync(h.runDir).some(f => f.includes('.tmp-'))) sawTmp = true - await new Promise(r => setTimeout(r, 2)) + if (readdirSync(h.runDir).some((f) => f.includes(".tmp-"))) sawTmp = true; + await new Promise((r) => setTimeout(r, 2)); } - expect(sawTmp).toBe(false) - expect(readToml(join(h.runDir, 'FINISHED')).status).toBe('completed') - }) -}) + expect(sawTmp).toBe(false); + expect(readToml(join(h.runDir, "FINISHED")).status).toBe("completed"); + }); +}); diff --git a/packages/amico-run/test/gate.test.ts b/packages/amico-run/test/gate.test.ts index 27476e39..f73a3741 100644 --- a/packages/amico-run/test/gate.test.ts +++ b/packages/amico-run/test/gate.test.ts @@ -1,22 +1,22 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest" -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs" -import { tmpdir } from "node:os" -import { join } from "node:path" -import { runGate } from "../src/gate.js" -import { maskedHash } from "../src/baseline.js" -import type { AuthoringConfig } from "../src/authoring.js" +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runGate } from "../src/gate.js"; +import { maskedHash } from "../src/baseline.js"; +import type { AuthoringConfig } from "../src/authoring.js"; -let dir: string +let dir: string; beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), "amico-gate-")) -}) -afterEach(() => rmSync(dir, { recursive: true, force: true })) + dir = mkdtempSync(join(tmpdir(), "amico-gate-")); +}); +afterEach(() => rmSync(dir, { recursive: true, force: true })); -const EXEMPLAR_SCRIPT = `using Piccolo\nusing JLD2, TOML\n# ── FILL IN ──────\nT = 10.0\n# ─────────────────\nsolve()\n` +const EXEMPLAR_SCRIPT = `using Piccolo\nusing JLD2, TOML\n# ── FILL IN ──────\nT = 10.0\n# ─────────────────\nsolve()\n`; function authoring(overrides?: Partial): AuthoringConfig { // exemplars index on disk with the fixture exemplar's build-time baseline - const index = join(dir, "index.json") + const index = join(dir, "index.json"); writeFileSync( index, JSON.stringify({ @@ -33,14 +33,14 @@ function authoring(overrides?: Partial): AuthoringConfig { }, ], }), - ) + ); return { allowlist: ["Piccolo", "Legato"], support_set: ["JLD2", "CairoMakie", "TOML", "Printf"], exemplars: index, verify_tolerance: 0.01, ...overrides, - } + }; } function spec(overrides: Record = {}): Record { @@ -52,89 +52,89 @@ function spec(overrides: Record = {}): Record tier: "vetted", env: { kind: "provisioned" }, ...overrides, - } + }; } describe("runGate", () => { it("step 1: schema-invalid spec → one-line schema reason", () => { - const result = runGate({ nope: true }, "using Piccolo\n", authoring()) - expect(result.ok).toBe(false) - if (!result.ok) expect(result.reason).toMatch(/schema/) - }) + const result = runGate({ nope: true }, "using Piccolo\n", authoring()); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toMatch(/schema/); + }); it("step 2: blocked import → reason names the package", () => { - const result = runGate(spec(), "using Piccolo\nusing Zygote\n", authoring()) - expect(result.ok).toBe(false) - if (!result.ok) expect(result.reason).toMatch(/Zygote/) - }) + const result = runGate(spec(), "using Piccolo\nusing Zygote\n", authoring()); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toMatch(/Zygote/); + }); it("step 3: free tier requires a sandbox env", () => { - const result = runGate(spec({ tier: "free", env: { kind: "provisioned" } }), "using Piccolo\n", authoring()) - expect(result.ok).toBe(false) - if (!result.ok) expect(result.reason).toMatch(/free tier requires a sandbox env/) - }) + const result = runGate(spec({ tier: "free", env: { kind: "provisioned" } }), "using Piccolo\n", authoring()); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toMatch(/free tier requires a sandbox env/); + }); it("step 3: project env without a Manifest.toml → instantiate message", () => { - const env = join(dir, "env") - mkdirSync(env) - writeFileSync(join(env, "Project.toml"), `[deps]\nPiccolo = "c4671d76-df94-11ed-2057-43d4fd632fad"\n`) - const result = runGate(spec({ env: { kind: "project", project: env } }), "using Piccolo\n", authoring()) - expect(result.ok).toBe(false) - if (!result.ok) expect(result.reason).toMatch(/instantiate/) - }) + const env = join(dir, "env"); + mkdirSync(env); + writeFileSync(join(env, "Project.toml"), `[deps]\nPiccolo = "c4671d76-df94-11ed-2057-43d4fd632fad"\n`); + const result = runGate(spec({ env: { kind: "project", project: env } }), "using Piccolo\n", authoring()); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toMatch(/instantiate/); + }); it("step 3b: stale env — Project dep missing from its OWN Manifest → named + re-instantiate", () => { - const env = join(dir, "env") - mkdirSync(env) + const env = join(dir, "env"); + mkdirSync(env); writeFileSync( join(env, "Project.toml"), `[deps]\nPiccolo = "c4671d76-df94-11ed-2057-43d4fd632fad"\nJLD2 = "033835bb-8acc-5ee8-8aae-3f567f8a3819"\n`, - ) - writeFileSync(join(env, "Manifest.toml"), `julia_version = "1.11.0"\n\n[[deps.Piccolo]]\nversion = "1.19.0"\n`) - const result = runGate(spec({ env: { kind: "project", project: env } }), "using Piccolo\n", authoring()) - expect(result.ok).toBe(false) - if (!result.ok) expect(result.reason).toMatch(/stale env.*JLD2.*re-instantiate/) + ); + writeFileSync(join(env, "Manifest.toml"), `julia_version = "1.11.0"\n\n[[deps.Piccolo]]\nversion = "1.19.0"\n`); + const result = runGate(spec({ env: { kind: "project", project: env } }), "using Piccolo\n", authoring()); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toMatch(/stale env.*JLD2.*re-instantiate/); // consistent pair passes writeFileSync( join(env, "Manifest.toml"), `julia_version = "1.11.0"\n\n[[deps.Piccolo]]\nversion = "1.19.0"\n\n[[deps.JLD2]]\nversion = "0.5.0"\n`, - ) - expect(runGate(spec({ env: { kind: "project", project: env } }), "using Piccolo\n", authoring()).ok).toBe(true) - }) + ); + expect(runGate(spec({ env: { kind: "project", project: env } }), "using Piccolo\n", authoring()).ok).toBe(true); + }); it("step 3: non-local executor rejected at schema level", () => { - const result = runGate(spec({ executor: "cloud" }), "using Piccolo\n", authoring()) - expect(result.ok).toBe(false) - if (!result.ok) expect(result.reason).toMatch(/executor/) - }) + const result = runGate(spec({ executor: "cloud" }), "using Piccolo\n", authoring()); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toMatch(/executor/); + }); it("step 4: composed — inside-fill-point edits pass; outside edits reject with demote_to", () => { - const sandboxSpec = spec({ tier: "composed", source: { exemplar_id: "ex-1" } }) - const filled = EXEMPLAR_SCRIPT.replace("T = 10.0", "T = 25.0") - expect(runGate(sandboxSpec, filled, authoring()).ok).toBe(true) - const hacked = EXEMPLAR_SCRIPT.replace("solve()", "solve!(other_physics)") - const result = runGate(sandboxSpec, hacked, authoring()) - expect(result.ok).toBe(false) + const sandboxSpec = spec({ tier: "composed", source: { exemplar_id: "ex-1" } }); + const filled = EXEMPLAR_SCRIPT.replace("T = 10.0", "T = 25.0"); + expect(runGate(sandboxSpec, filled, authoring()).ok).toBe(true); + const hacked = EXEMPLAR_SCRIPT.replace("solve()", "solve!(other_physics)"); + const result = runGate(sandboxSpec, hacked, authoring()); + expect(result.ok).toBe(false); if (!result.ok) { - expect(result.reason).toMatch(/no longer the exemplar/) - expect(result.demote_to).toBe("free") + expect(result.reason).toMatch(/no longer the exemplar/); + expect(result.demote_to).toBe("free"); } - }) + }); it("step 4: composed without exemplar_id → clear reason", () => { - const result = runGate(spec({ tier: "composed" }), EXEMPLAR_SCRIPT, authoring()) - expect(result.ok).toBe(false) - if (!result.ok) expect(result.reason).toMatch(/exemplar_id/) - }) + const result = runGate(spec({ tier: "composed" }), EXEMPLAR_SCRIPT, authoring()); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toMatch(/exemplar_id/); + }); it("step 5: pass returns the stamp; spec_hash is gate-computed and spec-sensitive", () => { - const specA = spec({ hashes: { system_hash: "sha256:ab" } }) - const resultA = runGate(specA, "using Piccolo\n", authoring()) - expect(resultA.ok).toBe(true) + const specA = spec({ hashes: { system_hash: "sha256:ab" } }); + const resultA = runGate(specA, "using Piccolo\n", authoring()); + expect(resultA.ok).toBe(true); if (resultA.ok) { - expect(resultA.stamp.tier).toBe("vetted") - expect(resultA.stamp.hashes.system_hash).toBe("sha256:ab") - expect(resultA.stamp.hashes.spec_hash).toMatch(/^sha256:/) - expect(JSON.parse(resultA.stamp.specCanonical)).toMatchObject({ tier: "vetted" }) - const resultB = runGate(spec({ hashes: { system_hash: "sha256:cd" } }), "using Piccolo\n", authoring()) - if (resultB.ok) expect(resultB.stamp.hashes.spec_hash).not.toBe(resultA.stamp.hashes.spec_hash) + expect(resultA.stamp.tier).toBe("vetted"); + expect(resultA.stamp.hashes.system_hash).toBe("sha256:ab"); + expect(resultA.stamp.hashes.spec_hash).toMatch(/^sha256:/); + expect(JSON.parse(resultA.stamp.specCanonical)).toMatchObject({ tier: "vetted" }); + const resultB = runGate(spec({ hashes: { system_hash: "sha256:cd" } }), "using Piccolo\n", authoring()); + if (resultB.ok) expect(resultB.stamp.hashes.spec_hash).not.toBe(resultA.stamp.hashes.spec_hash); } - }) + }); it("v1 specs (no tier) pass through with import scan only", () => { - const v1 = { schema_version: "1", script_path: "/s.jl", lab_id: "default" } - expect(runGate(v1, "using Piccolo\n", authoring()).ok).toBe(true) - expect(runGate(v1, "using Zygote\n", authoring()).ok).toBe(false) - }) -}) + const v1 = { schema_version: "1", script_path: "/s.jl", lab_id: "default" }; + expect(runGate(v1, "using Piccolo\n", authoring()).ok).toBe(true); + expect(runGate(v1, "using Zygote\n", authoring()).ok).toBe(false); + }); +}); diff --git a/packages/amico-run/test/helpers.ts b/packages/amico-run/test/helpers.ts index ff491074..26595876 100644 --- a/packages/amico-run/test/helpers.ts +++ b/packages/amico-run/test/helpers.ts @@ -1,21 +1,21 @@ -import { mkdtempSync, readFileSync, writeFileSync, chmodSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { parse } from 'smol-toml' +import { mkdtempSync, readFileSync, writeFileSync, chmodSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parse } from "smol-toml"; export function tmpRoot(): string { - return mkdtempSync(join(tmpdir(), 'amico-run-test-')) + return mkdtempSync(join(tmpdir(), "amico-run-test-")); } export function readToml(path: string): Record { - return parse(readFileSync(path, 'utf8')) as Record + return parse(readFileSync(path, "utf8")) as Record; } /** Create an executable fake-julia "binary" (node script via shebang). It receives * the julia argv (flags + script path) and ignores it unless the body uses it. */ export function fakeJulia(dir: string, name: string, body: string): string { - const p = join(dir, name) - writeFileSync(p, `#!/usr/bin/env node\n${body}\n`) - chmodSync(p, 0o755) - return p + const p = join(dir, name); + writeFileSync(p, `#!/usr/bin/env node\n${body}\n`); + chmodSync(p, 0o755); + return p; } diff --git a/packages/amico-run/test/import_scan.test.ts b/packages/amico-run/test/import_scan.test.ts index cbd472d3..4bbba736 100644 --- a/packages/amico-run/test/import_scan.test.ts +++ b/packages/amico-run/test/import_scan.test.ts @@ -1,7 +1,7 @@ -import { describe, it, expect } from "vitest" -import { scanImports, checkImports } from "../src/import_scan.js" +import { describe, it, expect } from "vitest"; +import { scanImports, checkImports } from "../src/import_scan.js"; -const ALLOW = { allowlist: ["Piccolo", "Legato"], support_set: ["JLD2", "CairoMakie", "TOML", "Printf"] } +const ALLOW = { allowlist: ["Piccolo", "Legato"], support_set: ["JLD2", "CairoMakie", "TOML", "Printf"] }; describe("scanImports", () => { it("extracts roots from every using/import form", () => { @@ -9,29 +9,29 @@ describe("scanImports", () => { scanImports( `using Piccolo\nusing JLD2, TOML\nimport LinearAlgebra as LA\nusing Piccolo.NamedTrajectories\nusing CairoMakie: heatmap\n# using Zygote (comment)`, ), - ).toEqual({ ok: true, roots: ["Piccolo", "JLD2", "TOML", "LinearAlgebra", "CairoMakie"] }) - }) + ).toEqual({ ok: true, roots: ["Piccolo", "JLD2", "TOML", "LinearAlgebra", "CairoMakie"] }); + }); it("fails CLOSED on a trailing-comma continuation line (multi-line using)", () => { - const scanned = scanImports(`using Piccolo,\n Zygote\n`) - expect(scanned.ok).toBe(false) - if (!scanned.ok) expect(scanned.reason).toMatch(/one statement per line/) - }) -}) + const scanned = scanImports(`using Piccolo,\n Zygote\n`); + expect(scanned.ok).toBe(false); + if (!scanned.ok) expect(scanned.reason).toMatch(/one statement per line/); + }); +}); describe("checkImports", () => { it("allows allowlist ∪ support ∪ stdlib", () => { - expect(checkImports(["Piccolo", "JLD2", "LinearAlgebra", "Printf"], ALLOW)).toEqual({ ok: true }) - }) + expect(checkImports(["Piccolo", "JLD2", "LinearAlgebra", "Printf"], ALLOW)).toEqual({ ok: true }); + }); it("blocks others with a one-line reason naming every blocked package", () => { - const bad = checkImports(["Piccolo", "Zygote", "Flux"], ALLOW) - expect(bad.ok).toBe(false) + const bad = checkImports(["Piccolo", "Zygote", "Flux"], ALLOW); + expect(bad.ok).toBe(false); if (!bad.ok) { - expect(bad.reason).toMatch(/Zygote/) - expect(bad.reason).toMatch(/Flux/) - expect(bad.reason).toMatch(/not in the allowed package set/) + expect(bad.reason).toMatch(/Zygote/); + expect(bad.reason).toMatch(/Flux/); + expect(bad.reason).toMatch(/not in the allowed package set/); } - }) + }); it("issimo package blocked without entitlement", () => { - expect(checkImports(["Piccolissimo"], ALLOW).ok).toBe(false) - }) -}) + expect(checkImports(["Piccolissimo"], ALLOW).ok).toBe(false); + }); +}); diff --git a/packages/amico-run/test/local_executor.test.ts b/packages/amico-run/test/local_executor.test.ts index 371e9027..393b0cd1 100644 --- a/packages/amico-run/test/local_executor.test.ts +++ b/packages/amico-run/test/local_executor.test.ts @@ -1,75 +1,89 @@ -import { describe, it, expect } from 'vitest' -import { existsSync, readFileSync, readdirSync } from 'node:fs' -import { join } from 'node:path' -import { tmpRoot, fakeJulia, readToml } from './helpers.js' -import { LocalExecutor } from '../src/local_executor.js' -import { validateManifest, validateFinished } from '../src/schemas.js' -import type { RunEvent } from '../src/types.js' +import { describe, it, expect } from "vitest"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { tmpRoot, fakeJulia, readToml } from "./helpers.js"; +import { LocalExecutor } from "../src/local_executor.js"; +import { validateManifest, validateFinished } from "../src/schemas.js"; +import type { RunEvent } from "../src/types.js"; const CLEAN = ` console.log('AMICODE_ITER iter=1 f=1.0e-2') console.log('AMICODE_ITER iter=2 f=3.0e-4') console.log('DONE fidelity=0.9999') -` +`; async function collect(events: AsyncIterable): Promise { - const out: RunEvent[] = [] - for await (const e of events) out.push(e) - return out + const out: RunEvent[] = []; + for await (const e of events) out.push(e); + return out; } -describe('LocalExecutor happy path', () => { - it('produces a conforming run dir and ordered event stream', async () => { - const root = tmpRoot() - const julia = fakeJulia(root, 'julia-clean', CLEAN) - const script = fakeJulia(root, 'solve.jl', '') // content irrelevant; must exist +describe("LocalExecutor happy path", () => { + it("produces a conforming run dir and ordered event stream", async () => { + const root = tmpRoot(); + const julia = fakeJulia(root, "julia-clean", CLEAN); + const script = fakeJulia(root, "solve.jl", ""); // content irrelevant; must exist const h = await new LocalExecutor().submit(script, { - lab: 'testlab', runsRoot: join(root, 'runs'), julia: { julia }, - }) + lab: "testlab", + runsRoot: join(root, "runs"), + julia: { julia }, + }); // manifest observable before events finish — submit() resolved, so it must exist NOW - const manifest = readToml(join(h.runDir, 'run.toml')) - expect(validateManifest(manifest).ok).toBe(true) - expect(manifest.lab_id).toBe('testlab') + const manifest = readToml(join(h.runDir, "run.toml")); + expect(validateManifest(manifest).ok).toBe(true); + expect(manifest.lab_id).toBe("testlab"); - const evs = await collect(h.events) - expect(evs.filter(e => e.kind === 'iter')).toHaveLength(2) - expect(evs.filter(e => e.kind === 'done')).toHaveLength(1) - const fin = evs.at(-1)! - expect(fin).toEqual({ kind: 'finished', status: 'completed', exitCode: 0 }) - expect(await h.finished).toEqual({ status: 'completed', exitCode: 0 }) + const evs = await collect(h.events); + expect(evs.filter((e) => e.kind === "iter")).toHaveLength(2); + expect(evs.filter((e) => e.kind === "done")).toHaveLength(1); + const fin = evs.at(-1)!; + expect(fin).toEqual({ kind: "finished", status: "completed", exitCode: 0 }); + expect(await h.finished).toEqual({ status: "completed", exitCode: 0 }); - const finished = readToml(join(h.runDir, 'FINISHED')) - expect(validateFinished(finished).ok).toBe(true) - expect(finished.status).toBe('completed') + const finished = readToml(join(h.runDir, "FINISHED")); + expect(validateFinished(finished).ok).toBe(true); + expect(finished.status).toBe("completed"); // run.log mirrors stdout verbatim; index has exactly one line; latest points at the run - expect(readFileSync(join(h.runDir, 'run.log'), 'utf8')).toContain('AMICODE_ITER iter=2') - expect(readFileSync(join(root, 'runs', 'index'), 'utf8').trim().split('\n')).toHaveLength(1) + expect(readFileSync(join(h.runDir, "run.log"), "utf8")).toContain("AMICODE_ITER iter=2"); + expect( + readFileSync(join(root, "runs", "index"), "utf8") + .trim() + .split("\n"), + ).toHaveLength(1); // no temp files left anywhere in the run dir - expect(readdirSync(h.runDir).filter(f => f.includes('.tmp-'))).toHaveLength(0) - }) + expect(readdirSync(h.runDir).filter((f) => f.includes(".tmp-"))).toHaveLength(0); + }); - it('config errors reject BEFORE any run dir exists (exit-64 class)', async () => { - const root = tmpRoot() - await expect(new LocalExecutor().submit(join(root, 'nope.jl'), { runsRoot: join(root, 'runs') })) - .rejects.toThrow(/script not found/) - expect(existsSync(join(root, 'runs'))).toBe(false) - }) + it("config errors reject BEFORE any run dir exists (exit-64 class)", async () => { + const root = tmpRoot(); + await expect(new LocalExecutor().submit(join(root, "nope.jl"), { runsRoot: join(root, "runs") })).rejects.toThrow( + /script not found/, + ); + expect(existsSync(join(root, "runs"))).toBe(false); + }); - it('passes --project/--sysimage through and runs with cwd = runDir', async () => { - const root = tmpRoot() - const julia = fakeJulia(root, 'julia-echo', - `console.log('ARGS ' + process.argv.slice(2).join(' ')); console.log('CWD ' + process.cwd())`) - const script = fakeJulia(root, 's.jl', '') + it("passes --project/--sysimage through and runs with cwd = runDir", async () => { + const root = tmpRoot(); + const julia = fakeJulia( + root, + "julia-echo", + `console.log('ARGS ' + process.argv.slice(2).join(' ')); console.log('CWD ' + process.cwd())`, + ); + const script = fakeJulia(root, "s.jl", ""); const h = await new LocalExecutor().submit(script, { - runsRoot: join(root, 'runs'), julia: { julia, project: '/proj', sysimage: '/img.so' }, - }) - const evs = await collect(h.events) - const argLine = evs.find(e => e.kind === 'log' && e.line.startsWith('ARGS')) as Extract - expect(argLine.line).toContain('--project=/proj') - expect(argLine.line).toContain('--sysimage=/img.so') - const cwdLine = evs.find(e => e.kind === 'log' && e.line.startsWith('CWD')) as Extract - expect(cwdLine.line).toContain(h.runDir) - }) -}) + runsRoot: join(root, "runs"), + julia: { julia, project: "/proj", sysimage: "/img.so" }, + }); + const evs = await collect(h.events); + const argLine = evs.find((e) => e.kind === "log" && e.line.startsWith("ARGS")) as Extract< + RunEvent, + { kind: "log" } + >; + expect(argLine.line).toContain("--project=/proj"); + expect(argLine.line).toContain("--sysimage=/img.so"); + const cwdLine = evs.find((e) => e.kind === "log" && e.line.startsWith("CWD")) as Extract; + expect(cwdLine.line).toContain(h.runDir); + }); +}); diff --git a/packages/amico-run/test/run_dir.test.ts b/packages/amico-run/test/run_dir.test.ts index d05ba01c..b408ba47 100644 --- a/packages/amico-run/test/run_dir.test.ts +++ b/packages/amico-run/test/run_dir.test.ts @@ -1,95 +1,110 @@ -import { describe, it, expect } from 'vitest' -import { existsSync, readFileSync, readlinkSync, mkdirSync } from 'node:fs' -import { join } from 'node:path' -import { tmpRoot, readToml } from './helpers.js' +import { describe, it, expect } from "vitest"; +import { existsSync, readFileSync, readlinkSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { tmpRoot, readToml } from "./helpers.js"; import { - deriveLabId, generateRunId, atomicWriteFile, - writeManifest, writeFinished, appendIndex, updateLatest, -} from '../src/run_dir.js' -import { ConfigError } from '../src/types.js' -import { validate } from '@amicode/schema' + deriveLabId, + generateRunId, + atomicWriteFile, + writeManifest, + writeFinished, + appendIndex, + updateLatest, +} from "../src/run_dir.js"; +import { ConfigError } from "../src/types.js"; +import { validate } from "@amicode/schema"; -describe('deriveLabId', () => { - it('uses id pointers verbatim', () => expect(deriveLabId('schuster')).toBe('schuster')) - it('derives from parent dir of a lab.toml path', () => - expect(deriveLabId('/labs/schuster/lab.toml')).toBe('schuster')) - it('rejects pointers that fit neither rule', () => - expect(() => deriveLabId('Bad Lab!')).toThrow(ConfigError)) -}) +describe("deriveLabId", () => { + it("uses id pointers verbatim", () => expect(deriveLabId("schuster")).toBe("schuster")); + it("derives from parent dir of a lab.toml path", () => + expect(deriveLabId("/labs/schuster/lab.toml")).toBe("schuster")); + it("rejects pointers that fit neither rule", () => expect(() => deriveLabId("Bad Lab!")).toThrow(ConfigError)); +}); -describe('generateRunId', () => { - it('matches r-<4hex> and avoids collisions', () => { - const root = tmpRoot() - const id = generateRunId(root, new Date('2026-06-10T10:12:45.678Z')) - expect(id).toMatch(/^r20260610-101245Z-[0-9a-f]{4}$/) - mkdirSync(join(root, id)) - const id2 = generateRunId(root, new Date('2026-06-10T10:12:45.678Z')) - expect(id2).not.toBe(id) - }) -}) +describe("generateRunId", () => { + it("matches r-<4hex> and avoids collisions", () => { + const root = tmpRoot(); + const id = generateRunId(root, new Date("2026-06-10T10:12:45.678Z")); + expect(id).toMatch(/^r20260610-101245Z-[0-9a-f]{4}$/); + mkdirSync(join(root, id)); + const id2 = generateRunId(root, new Date("2026-06-10T10:12:45.678Z")); + expect(id2).not.toBe(id); + }); +}); -describe('writers', () => { - it('manifest round-trips through a TOML parser with exact snake_case keys', () => { - const root = tmpRoot() +describe("writers", () => { + it("manifest round-trips through a TOML parser with exact snake_case keys", () => { + const root = tmpRoot(); writeManifest(root, { - schema_version: '1', run_id: 'r1', script_path: '/s.jl', - lab: '/labs/x/lab.toml', lab_id: 'x', - created_at: '2026-06-10T10:12:45Z', orchestrator_version: '0.1.0', - julia: { binary: 'julia', project: '/proj' }, - }) - const m = readToml(join(root, 'run.toml')) - expect(m.schema_version).toBe('1') - expect(m.lab_id).toBe('x') - expect((m.julia as Record).project).toBe('/proj') - expect(m).not.toHaveProperty('sizeClass') // spec §5: intentionally absent - }) + schema_version: "1", + run_id: "r1", + script_path: "/s.jl", + lab: "/labs/x/lab.toml", + lab_id: "x", + created_at: "2026-06-10T10:12:45Z", + orchestrator_version: "0.1.0", + julia: { binary: "julia", project: "/proj" }, + }); + const m = readToml(join(root, "run.toml")); + expect(m.schema_version).toBe("1"); + expect(m.lab_id).toBe("x"); + expect((m.julia as Record).project).toBe("/proj"); + expect(m).not.toHaveProperty("sizeClass"); // spec §5: intentionally absent + }); it('manifest v2: tier + [hashes] emitted only when present; validates as "run" v2 (spec C)', () => { - const root = tmpRoot() + const root = tmpRoot(); const base = { - run_id: 'r1', script_path: '/s.jl', lab: 'default', lab_id: 'default', - created_at: '2026-07-03T00:00:00Z', orchestrator_version: '0.1.0', - julia: { binary: 'julia' }, - } + run_id: "r1", + script_path: "/s.jl", + lab: "default", + lab_id: "default", + created_at: "2026-07-03T00:00:00Z", + orchestrator_version: "0.1.0", + julia: { binary: "julia" }, + }; // bare (v1) output is byte-stable: no tier/hashes lines at all - writeManifest(root, { schema_version: '1', ...base }) - const v1text = readFileSync(join(root, 'run.toml'), 'utf8') - expect(v1text).not.toContain('tier') - expect(v1text).not.toContain('[hashes]') + writeManifest(root, { schema_version: "1", ...base }); + const v1text = readFileSync(join(root, "run.toml"), "utf8"); + expect(v1text).not.toContain("tier"); + expect(v1text).not.toContain("[hashes]"); // spec-driven (v2) writeManifest(root, { - schema_version: '2', ...base, tier: 'free', - hashes: { system_hash: 'sha256:ab', spec_hash: 'sha256:cd' }, - }) - const m = readToml(join(root, 'run.toml')) - expect(m.schema_version).toBe('2') - expect(m.tier).toBe('free') - expect((m.hashes as Record).spec_hash).toBe('sha256:cd') - expect(validate(m, 'run').errors).toEqual([]) - }) - it('FINISHED carries status + exit_code (snake_case)', () => { - const root = tmpRoot() - writeFinished(root, 'failed', 7) - expect(readToml(join(root, 'FINISHED'))).toEqual({ status: 'failed', exit_code: 7 }) - }) - it('atomicWriteFile leaves no temp file behind', () => { - const root = tmpRoot() - atomicWriteFile(root, 'f.toml', 'a = 1\n') - expect(readFileSync(join(root, 'f.toml'), 'utf8')).toBe('a = 1\n') - expect(existsSync(join(root, `.f.toml.tmp-${process.pid}`))).toBe(false) - }) - it('index appends one tab-separated line per run; latest symlink swings', () => { - const root = tmpRoot() - appendIndex(root, 'r1', 't1', '/a.jl'); appendIndex(root, 'r2', 't2', '/b.jl') - expect(readFileSync(join(root, 'index'), 'utf8')).toBe('r1\tt1\t/a.jl\nr2\tt2\t/b.jl\n') - mkdirSync(join(root, 'r2')) - updateLatest(root, 'r2') - expect(readlinkSync(join(root, 'latest'))).toBe('r2') - }) - it('sanitizes tab/newline in the script path so the TSV index stays one line per run', () => { - const root = tmpRoot() - appendIndex(root, 'r1', 't1', '/weird\tpath\nwith/ctrl.jl') - const lines = readFileSync(join(root, 'index'), 'utf8').trimEnd().split('\n') - expect(lines).toHaveLength(1) // not corrupted into multiple rows - expect(lines[0].split('\t')).toHaveLength(3) // exactly runId/createdAt/path fields - }) -}) + schema_version: "2", + ...base, + tier: "free", + hashes: { system_hash: "sha256:ab", spec_hash: "sha256:cd" }, + }); + const m = readToml(join(root, "run.toml")); + expect(m.schema_version).toBe("2"); + expect(m.tier).toBe("free"); + expect((m.hashes as Record).spec_hash).toBe("sha256:cd"); + expect(validate(m, "run").errors).toEqual([]); + }); + it("FINISHED carries status + exit_code (snake_case)", () => { + const root = tmpRoot(); + writeFinished(root, "failed", 7); + expect(readToml(join(root, "FINISHED"))).toEqual({ status: "failed", exit_code: 7 }); + }); + it("atomicWriteFile leaves no temp file behind", () => { + const root = tmpRoot(); + atomicWriteFile(root, "f.toml", "a = 1\n"); + expect(readFileSync(join(root, "f.toml"), "utf8")).toBe("a = 1\n"); + expect(existsSync(join(root, `.f.toml.tmp-${process.pid}`))).toBe(false); + }); + it("index appends one tab-separated line per run; latest symlink swings", () => { + const root = tmpRoot(); + appendIndex(root, "r1", "t1", "/a.jl"); + appendIndex(root, "r2", "t2", "/b.jl"); + expect(readFileSync(join(root, "index"), "utf8")).toBe("r1\tt1\t/a.jl\nr2\tt2\t/b.jl\n"); + mkdirSync(join(root, "r2")); + updateLatest(root, "r2"); + expect(readlinkSync(join(root, "latest"))).toBe("r2"); + }); + it("sanitizes tab/newline in the script path so the TSV index stays one line per run", () => { + const root = tmpRoot(); + appendIndex(root, "r1", "t1", "/weird\tpath\nwith/ctrl.jl"); + const lines = readFileSync(join(root, "index"), "utf8").trimEnd().split("\n"); + expect(lines).toHaveLength(1); // not corrupted into multiple rows + expect(lines[0].split("\t")).toHaveLength(3); // exactly runId/createdAt/path fields + }); +}); diff --git a/packages/amico-run/test/s31.test.ts b/packages/amico-run/test/s31.test.ts index d0914185..89f6a08b 100644 --- a/packages/amico-run/test/s31.test.ts +++ b/packages/amico-run/test/s31.test.ts @@ -1,23 +1,22 @@ -import { describe, it, expect } from 'vitest' -import { readFileSync, readdirSync } from 'node:fs' -import { join } from 'node:path' +import { describe, it, expect } from "vitest"; +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; // S31 / spec §4: no PHYSICS flag parsing, no MCP, no HTTP in the orchestrator. // (The original /SolveSpec/ ban is lifted by spec C: amico-run is now the // named SolveSpec launch gate — it validates + gates the spec before spawning // Julia. The physics-flag bans below still hold: --spec is a spec-file path, // NOT a physics knob; all physics stays in the script.) -const FORBIDDEN = [/--gate\b/, /--system\b/, /--pulse\b/, - /modelcontextprotocol/i, /node:https?\b/, /\bfetch\s*\(/] +const FORBIDDEN = [/--gate\b/, /--system\b/, /--pulse\b/, /modelcontextprotocol/i, /node:https?\b/, /\bfetch\s*\(/]; -describe('S31 grep rule', () => { - it('src/ contains no forbidden tool-layer patterns', () => { - const srcDir = join(__dirname, '..', 'src') +describe("S31 grep rule", () => { + it("src/ contains no forbidden tool-layer patterns", () => { + const srcDir = join(__dirname, "..", "src"); for (const f of readdirSync(srcDir)) { - const text = readFileSync(join(srcDir, f), 'utf8') + const text = readFileSync(join(srcDir, f), "utf8"); for (const re of FORBIDDEN) { - expect(text, `${f} matches forbidden ${re}`).not.toMatch(re) + expect(text, `${f} matches forbidden ${re}`).not.toMatch(re); } } - }) -}) + }); +}); diff --git a/packages/amico-run/test/schemas.test.ts b/packages/amico-run/test/schemas.test.ts index 879978af..0e5f3ca4 100644 --- a/packages/amico-run/test/schemas.test.ts +++ b/packages/amico-run/test/schemas.test.ts @@ -1,59 +1,62 @@ -import { describe, it, expect } from 'vitest' -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { validateManifest, validateFinished, validateResult } from '../src/schemas.js' +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { validateManifest, validateFinished, validateResult } from "../src/schemas.js"; // These wrappers delegate to the shared @amicode/schema (single source of truth); // this suite is the delegation smoke + the field-precise contract they expose. const goodManifest = { - schema_version: '1', run_id: 'r20260610-101245Z-ab12', script_path: '/s.jl', - lab: 'default', lab_id: 'default', created_at: '2026-06-10T10:12:45Z', - orchestrator_version: '0.1.0', julia: { binary: 'julia' }, -} + schema_version: "1", + run_id: "r20260610-101245Z-ab12", + script_path: "/s.jl", + lab: "default", + lab_id: "default", + created_at: "2026-06-10T10:12:45Z", + orchestrator_version: "0.1.0", + julia: { binary: "julia" }, +}; -describe('validateManifest', () => { - it('accepts a conforming manifest', () => - expect(validateManifest(goodManifest)).toEqual({ ok: true, errors: [] })) - it('reports each missing/mistyped field by path', () => { - const r = validateManifest({ ...goodManifest, run_id: 42, julia: {} }) - expect(r.ok).toBe(false) - expect(r.errors.join(' ')).toContain('run_id') // wrong-typed top-level field - expect(r.errors.join(' ')).toContain('binary') // /julia missing required "binary" - }) - it('rejects unknown schema_version (v2 is now valid — spec C bump)', () => { - expect(validateManifest({ ...goodManifest, schema_version: '99' }).ok).toBe(false) - expect(validateManifest({ ...goodManifest, schema_version: '2' }).ok).toBe(true) - }) -}) +describe("validateManifest", () => { + it("accepts a conforming manifest", () => expect(validateManifest(goodManifest)).toEqual({ ok: true, errors: [] })); + it("reports each missing/mistyped field by path", () => { + const r = validateManifest({ ...goodManifest, run_id: 42, julia: {} }); + expect(r.ok).toBe(false); + expect(r.errors.join(" ")).toContain("run_id"); // wrong-typed top-level field + expect(r.errors.join(" ")).toContain("binary"); // /julia missing required "binary" + }); + it("rejects unknown schema_version (v2 is now valid — spec C bump)", () => { + expect(validateManifest({ ...goodManifest, schema_version: "99" }).ok).toBe(false); + expect(validateManifest({ ...goodManifest, schema_version: "2" }).ok).toBe(true); + }); +}); -describe('validateFinished', () => { - it('accepts {status, exit_code}', () => - expect(validateFinished({ status: 'aborted', exit_code: 143 }).ok).toBe(true)) - it('rejects bad status and non-integer exit_code', () => { - expect(validateFinished({ status: 'ok', exit_code: 0 }).ok).toBe(false) - expect(validateFinished({ status: 'failed', exit_code: 1.5 }).ok).toBe(false) - }) -}) +describe("validateFinished", () => { + it("accepts {status, exit_code}", () => + expect(validateFinished({ status: "aborted", exit_code: 143 }).ok).toBe(true)); + it("rejects bad status and non-integer exit_code", () => { + expect(validateFinished({ status: "ok", exit_code: 0 }).ok).toBe(false); + expect(validateFinished({ status: "failed", exit_code: 1.5 }).ok).toBe(false); + }); +}); -describe('validateResult (reader-side)', () => { - it('requires schema_version, fidelity number, iterations integer', () => { +describe("validateResult (reader-side)", () => { + it("requires schema_version, fidelity number, iterations integer", () => { // The formalized contract carries schema_version on result.toml (0.1a adds the // emit; the Julia round-trip enforces it). An artifact lacking it is rejected. - expect(validateResult({ schema_version: '1', fidelity: 0.999, iterations: 200, wall_seconds: 12.5 }).ok).toBe(true) - expect(validateResult({ fidelity: 0.999, iterations: 200 }).ok).toBe(false) // no schema_version - expect(validateResult({ schema_version: '1', iterations: 200 }).ok).toBe(false) // no fidelity - }) -}) + expect(validateResult({ schema_version: "1", fidelity: 0.999, iterations: 200, wall_seconds: 12.5 }).ok).toBe(true); + expect(validateResult({ fidelity: 0.999, iterations: 200 }).ok).toBe(false); // no schema_version + expect(validateResult({ schema_version: "1", iterations: 200 }).ok).toBe(false); // no fidelity + }); +}); // Anti-regression (N4): schemas.ts must remain a thin DELEGATION, never re-define // a schema/validator. Guards the "one validator path" invariant (#15 AC7). -describe('schemas.ts is delegation-only (no re-introduced schema)', () => { - const src = readFileSync(join(__dirname, '..', 'src', 'schemas.ts'), 'utf8') - it('imports the shared @amicode/schema', () => - expect(src).toMatch(/from ["']@amicode\/schema["']/)) - it('does not hand-roll validation (no local check helper / additionalProperties / required arrays)', () => { - expect(src).not.toMatch(/additionalProperties/) - expect(src).not.toMatch(/function check\b/) - expect(src).not.toMatch(/errors\.push/) - }) -}) +describe("schemas.ts is delegation-only (no re-introduced schema)", () => { + const src = readFileSync(join(__dirname, "..", "src", "schemas.ts"), "utf8"); + it("imports the shared @amicode/schema", () => expect(src).toMatch(/from ["']@amicode\/schema["']/)); + it("does not hand-roll validation (no local check helper / additionalProperties / required arrays)", () => { + expect(src).not.toMatch(/additionalProperties/); + expect(src).not.toMatch(/function check\b/); + expect(src).not.toMatch(/errors\.push/); + }); +}); diff --git a/packages/amico-run/test/slow/integration.test.ts b/packages/amico-run/test/slow/integration.test.ts index 8cbb5636..87dd4219 100644 --- a/packages/amico-run/test/slow/integration.test.ts +++ b/packages/amico-run/test/slow/integration.test.ts @@ -1,38 +1,39 @@ -import { describe, it, expect } from 'vitest' -import { execFileSync } from 'node:child_process' -import { join } from 'node:path' -import { tmpRoot, readToml } from '../helpers.js' -import { validateManifest, validateFinished, validateResult } from '../../src/schemas.js' +import { describe, it, expect } from "vitest"; +import { execFileSync } from "node:child_process"; +import { join } from "node:path"; +import { tmpRoot, readToml } from "../helpers.js"; +import { validateManifest, validateFinished, validateResult } from "../../src/schemas.js"; // Slow tier (spec §8): real Piccolo solves through the real CLI. Dev machine only — not CI. // Requires: julia on PATH + a Piccolo project (pass via AMICO_TEST_JULIA_PROJECT to *the test*, // which forwards it as an explicit --project flag — the orchestrator itself stays env-free). -const PROJECT = process.env.AMICO_TEST_JULIA_PROJECT -const BUNDLE = join(__dirname, '..', '..', 'dist', 'amico-run.js') +const PROJECT = process.env.AMICO_TEST_JULIA_PROJECT; +const BUNDLE = join(__dirname, "..", "..", "dist", "amico-run.js"); function solveAndValidate(script: string): void { - const root = tmpRoot() - const stdout = execFileSync('node', [ - BUNDLE, join(__dirname, script), - '--runs-root', join(root, 'runs'), '--project', PROJECT!, '--lab', 'devlab', - ], { encoding: 'utf8', timeout: 600_000 }) + const root = tmpRoot(); + const stdout = execFileSync( + "node", + [BUNDLE, join(__dirname, script), "--runs-root", join(root, "runs"), "--project", PROJECT!, "--lab", "devlab"], + { encoding: "utf8", timeout: 600_000 }, + ); - expect(stdout).toMatch(/AMICODE_ITER iter=/) - expect(stdout).toMatch(/AMICODE_FINISHED status=completed exitCode=0 runDir=(.+)/) - const runDir = stdout.match(/runDir=(.+)/)![1].trim() - expect(validateManifest(readToml(join(runDir, 'run.toml'))).ok).toBe(true) - expect(validateFinished(readToml(join(runDir, 'FINISHED'))).ok).toBe(true) - const result = readToml(join(runDir, 'result.toml')) - expect(validateResult(result).ok).toBe(true) - expect(result.fidelity as number).toBeGreaterThan(0.99) + expect(stdout).toMatch(/AMICODE_ITER iter=/); + expect(stdout).toMatch(/AMICODE_FINISHED status=completed exitCode=0 runDir=(.+)/); + const runDir = stdout.match(/runDir=(.+)/)![1].trim(); + expect(validateManifest(readToml(join(runDir, "run.toml"))).ok).toBe(true); + expect(validateFinished(readToml(join(runDir, "FINISHED"))).ok).toBe(true); + const result = readToml(join(runDir, "result.toml")); + expect(validateResult(result).ok).toBe(true); + expect(result.fidelity as number).toBeGreaterThan(0.99); } -describe.skipIf(!PROJECT)('slow: real Piccolo solves through amico-run', () => { - it('x-gate solve produces a fully conforming run dir', () => { - solveAndValidate('solve_x_gate.jl') - }, 600_000) +describe.skipIf(!PROJECT)("slow: real Piccolo solves through amico-run", () => { + it("x-gate solve produces a fully conforming run dir", () => { + solveAndValidate("solve_x_gate.jl"); + }, 600_000); - it('h-gate solve produces a fully conforming run dir', () => { - solveAndValidate('solve_h_gate.jl') - }, 600_000) -}) + it("h-gate solve produces a fully conforming run dir", () => { + solveAndValidate("solve_h_gate.jl"); + }, 600_000); +}); diff --git a/packages/amico-run/test/subcommands.test.ts b/packages/amico-run/test/subcommands.test.ts index 9936dc36..6f374fae 100644 --- a/packages/amico-run/test/subcommands.test.ts +++ b/packages/amico-run/test/subcommands.test.ts @@ -1,22 +1,22 @@ -import { describe, it, expect, beforeAll } from "vitest" -import { execFileSync } from "node:child_process" -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" -import { tmpdir } from "node:os" -import { join } from "node:path" -import { readToml } from "./helpers.js" +import { describe, it, expect, beforeAll } from "vitest"; +import { execFileSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { readToml } from "./helpers.js"; -const BUNDLE = join(__dirname, "..", "dist", "amico-run.js") +const BUNDLE = join(__dirname, "..", "dist", "amico-run.js"); beforeAll(() => { - execFileSync("node", [join(__dirname, "..", "esbuild.config.mjs")], { cwd: join(__dirname, "..") }) -}) + execFileSync("node", [join(__dirname, "..", "esbuild.config.mjs")], { cwd: join(__dirname, "..") }); +}); function run(args: string[], env: Record = {}): { code: number; stdout: string; stderr: string } { try { - const stdout = execFileSync("node", [BUNDLE, ...args], { encoding: "utf8", env: { ...process.env, ...env } }) - return { code: 0, stdout, stderr: "" } + const stdout = execFileSync("node", [BUNDLE, ...args], { encoding: "utf8", env: { ...process.env, ...env } }); + return { code: 0, stdout, stderr: "" }; } catch (e) { - const err = e as { status?: number; stdout?: string; stderr?: string } - return { code: err.status ?? -1, stdout: err.stdout ?? "", stderr: err.stderr ?? "" } + const err = e as { status?: number; stdout?: string; stderr?: string }; + return { code: err.status ?? -1, stdout: err.stdout ?? "", stderr: err.stderr ?? "" }; } } @@ -35,12 +35,12 @@ packages = ["JLD2", "CairoMakie", "TOML", "Printf"] [uuids] Piccolo = "c4671d76-df94-11ed-2057-43d4fd632fad" JLD2 = "033835bb-8acc-5ee8-8aae-3f567f8a3819" -` +`; function authoringDir(): string { - const dir = mkdtempSync(join(tmpdir(), "amico-sub-")) - writeFileSync(join(dir, "registry.toml"), REGISTRY) - writeFileSync(join(dir, "index.json"), JSON.stringify({ schema_version: 1, exemplars: [] })) + const dir = mkdtempSync(join(tmpdir(), "amico-sub-")); + writeFileSync(join(dir, "registry.toml"), REGISTRY); + writeFileSync(join(dir, "index.json"), JSON.stringify({ schema_version: 1, exemplars: [] })); writeFileSync( join(dir, "authoring.json"), JSON.stringify({ @@ -51,79 +51,79 @@ function authoringDir(): string { exemplars: join(dir, "index.json"), verify_tolerance: 0.01, }), - ) - return dir + ); + return dir; } describe("resolve subcommand", () => { it("exact vetted shape → tier vetted with template_path + packages", () => { - const dir = authoringDir() + const dir = authoringDir(); const r = run(["resolve", "--platform", "transmon", "--kind", "gate_synthesis", "--size", "1"], { AMICO_AUTHORING_FILE: join(dir, "authoring.json"), - }) - expect(r.code).toBe(0) - const out = JSON.parse(r.stdout) - expect(out.tier).toBe("vetted") - expect(out.template_path).toMatch(/solve_template\.jl$/) - expect(out.packages).toContain("Piccolo") - rmSync(dir, { recursive: true, force: true }) - }) + }); + expect(r.code).toBe(0); + const out = JSON.parse(r.stdout); + expect(out.tier).toBe("vetted"); + expect(out.template_path).toMatch(/solve_template\.jl$/); + expect(out.packages).toContain("Piccolo"); + rmSync(dir, { recursive: true, force: true }); + }); it("unknown shape → tier free WITH the skeleton's minimum package set", () => { - const dir = authoringDir() + const dir = authoringDir(); const r = run(["resolve", "--platform", "ions", "--kind", "gate_synthesis", "--size", "1"], { AMICO_AUTHORING_FILE: join(dir, "authoring.json"), - }) - const out = JSON.parse(r.stdout) - expect(out.tier).toBe("free") - expect(out.packages).toEqual(expect.arrayContaining(["Piccolo", "CairoMakie", "JLD2", "TOML", "Printf"])) - rmSync(dir, { recursive: true, force: true }) - }) -}) + }); + const out = JSON.parse(r.stdout); + expect(out.tier).toBe("free"); + expect(out.packages).toEqual(expect.arrayContaining(["Piccolo", "CairoMakie", "JLD2", "TOML", "Printf"])); + rmSync(dir, { recursive: true, force: true }); + }); +}); describe("sandbox subcommand", () => { it("writes env/Project.toml with [deps] uuids + prints instantiate instructions", () => { - const dir = authoringDir() - const target = mkdtempSync(join(tmpdir(), "amico-ws-")) + const dir = authoringDir(); + const target = mkdtempSync(join(tmpdir(), "amico-ws-")); const r = run(["sandbox", target, "--packages", "Piccolo,JLD2"], { AMICO_AUTHORING_FILE: join(dir, "authoring.json"), - }) - expect(r.code).toBe(0) - expect(existsSync(join(target, "env", "Project.toml"))).toBe(true) - const proj = readToml(join(target, "env", "Project.toml")) - const deps = proj.deps as Record - expect(deps.Piccolo).toBe("c4671d76-df94-11ed-2057-43d4fd632fad") - expect(deps.JLD2).toBe("033835bb-8acc-5ee8-8aae-3f567f8a3819") - expect(r.stdout).toContain("JULIA_PKG_USE_CLI_GIT=true") - expect(r.stdout).toContain("Pkg.instantiate()") - rmSync(dir, { recursive: true, force: true }) - rmSync(target, { recursive: true, force: true }) - }) + }); + expect(r.code).toBe(0); + expect(existsSync(join(target, "env", "Project.toml"))).toBe(true); + const proj = readToml(join(target, "env", "Project.toml")); + const deps = proj.deps as Record; + expect(deps.Piccolo).toBe("c4671d76-df94-11ed-2057-43d4fd632fad"); + expect(deps.JLD2).toBe("033835bb-8acc-5ee8-8aae-3f567f8a3819"); + expect(r.stdout).toContain("JULIA_PKG_USE_CLI_GIT=true"); + expect(r.stdout).toContain("Pkg.instantiate()"); + rmSync(dir, { recursive: true, force: true }); + rmSync(target, { recursive: true, force: true }); + }); it("unknown package (no uuid in registry) → exit 64 naming it", () => { - const dir = authoringDir() - const target = mkdtempSync(join(tmpdir(), "amico-ws-")) + const dir = authoringDir(); + const target = mkdtempSync(join(tmpdir(), "amico-ws-")); const r = run(["sandbox", target, "--packages", "Piccolo,Zygote"], { AMICO_AUTHORING_FILE: join(dir, "authoring.json"), - }) - expect(r.code).toBe(64) - expect(r.stderr).toMatch(/Zygote/) - rmSync(dir, { recursive: true, force: true }) - rmSync(target, { recursive: true, force: true }) - }) + }); + expect(r.code).toBe(64); + expect(r.stderr).toMatch(/Zygote/); + rmSync(dir, { recursive: true, force: true }); + rmSync(target, { recursive: true, force: true }); + }); it("stdlibs need no [deps] entry — they load from @stdlib (spec-20260704-113005 §3 defect #2)", () => { - const dir = authoringDir() - const target = mkdtempSync(join(tmpdir(), "amico-ws-")) + const dir = authoringDir(); + const target = mkdtempSync(join(tmpdir(), "amico-ws-")); // TOML + Printf are stdlibs with NO uuid in the fixture registry — before the // filter this exit-64'd; now they are dropped from [deps] and the run succeeds. const r = run(["sandbox", target, "--packages", "Piccolo,JLD2,TOML,Printf"], { AMICO_AUTHORING_FILE: join(dir, "authoring.json"), - }) - expect(r.code).toBe(0) - const deps = readToml(join(target, "env", "Project.toml")).deps as Record - expect(Object.keys(deps).sort()).toEqual(["JLD2", "Piccolo"]) // stdlibs filtered out - rmSync(dir, { recursive: true, force: true }) - rmSync(target, { recursive: true, force: true }) - }) -}) + }); + expect(r.code).toBe(0); + const deps = readToml(join(target, "env", "Project.toml")).deps as Record; + expect(Object.keys(deps).sort()).toEqual(["JLD2", "Piccolo"]); // stdlibs filtered out + rmSync(dir, { recursive: true, force: true }); + rmSync(target, { recursive: true, force: true }); + }); +}); // Production-path (spec-20260704-113005 §3 defect #2): the EXACT tier-free // resolve output (TIER3_MIN_PACKAGES) must sandbox against the BUNDLED registry, @@ -131,9 +131,9 @@ describe("sandbox subcommand", () => { // (filtered); Piccolo/CairoMakie/JLD2 must all be in the bundled [uuids]. describe("sandbox — bundled-asset production path", () => { function bundledAuthoringDir(): string { - const dir = mkdtempSync(join(tmpdir(), "amico-prod-")) - const registry = join(__dirname, "..", "..", "extension", "templates", "registry.toml") - const exemplars = join(__dirname, "..", "..", "extension", "exemplars", "index.json") + const dir = mkdtempSync(join(tmpdir(), "amico-prod-")); + const registry = join(__dirname, "..", "..", "extension", "templates", "registry.toml"); + const exemplars = join(__dirname, "..", "..", "extension", "exemplars", "index.json"); writeFileSync( join(dir, "authoring.json"), JSON.stringify({ @@ -144,19 +144,19 @@ describe("sandbox — bundled-asset production path", () => { exemplars, verify_tolerance: 0.001, }), - ) - return dir + ); + return dir; } it("TIER3_MIN_PACKAGES sandboxes clean against the bundled registry", () => { - const dir = bundledAuthoringDir() - const target = mkdtempSync(join(tmpdir(), "amico-ws-")) + const dir = bundledAuthoringDir(); + const target = mkdtempSync(join(tmpdir(), "amico-ws-")); const r = run(["sandbox", target, "--packages", "Piccolo,CairoMakie,JLD2,TOML,Printf"], { AMICO_AUTHORING_FILE: join(dir, "authoring.json"), - }) - expect(r.code).toBe(0) - const deps = readToml(join(target, "env", "Project.toml")).deps as Record - expect(Object.keys(deps).sort()).toEqual(["CairoMakie", "JLD2", "Piccolo"]) - rmSync(dir, { recursive: true, force: true }) - rmSync(target, { recursive: true, force: true }) - }) -}) + }); + expect(r.code).toBe(0); + const deps = readToml(join(target, "env", "Project.toml")).deps as Record; + expect(Object.keys(deps).sort()).toEqual(["CairoMakie", "JLD2", "Piccolo"]); + rmSync(dir, { recursive: true, force: true }); + rmSync(target, { recursive: true, force: true }); + }); +}); diff --git a/packages/amico-run/test/telemetry.test.ts b/packages/amico-run/test/telemetry.test.ts index 126489ff..2e3cb1e2 100644 --- a/packages/amico-run/test/telemetry.test.ts +++ b/packages/amico-run/test/telemetry.test.ts @@ -1,28 +1,30 @@ -import { describe, it, expect } from 'vitest' -import { classifyLine } from '../src/telemetry.js' +import { describe, it, expect } from "vitest"; +import { classifyLine } from "../src/telemetry.js"; -describe('classifyLine', () => { - it('parses AMICODE_ITER key=value fields', () => { - const ev = classifyLine('AMICODE_ITER iter=12 f=3.4e-5 inf_pr=1.2e-8', 'stdout') +describe("classifyLine", () => { + it("parses AMICODE_ITER key=value fields", () => { + const ev = classifyLine("AMICODE_ITER iter=12 f=3.4e-5 inf_pr=1.2e-8", "stdout"); expect(ev).toEqual({ - kind: 'iter', - raw: 'AMICODE_ITER iter=12 f=3.4e-5 inf_pr=1.2e-8', - fields: { iter: '12', f: '3.4e-5', inf_pr: '1.2e-8' }, - }) - }) - it('classifies DONE as done', () => { - expect(classifyLine('DONE fidelity=0.9999', 'stdout').kind).toBe('done') - }) - it('AMICODE_ITER on stderr is just log (convention is stdout-only)', () => { - expect(classifyLine('AMICODE_ITER iter=1', 'stderr').kind).toBe('log') - }) - it('malformed tokens are skipped, never throw', () => { - const ev = classifyLine('AMICODE_ITER iter=1 ====garbage', 'stdout') - expect(ev.kind).toBe('iter') - }) - it('everything else is log with stream tagged', () => { - expect(classifyLine('Ipopt banner', 'stderr')).toEqual({ - kind: 'log', stream: 'stderr', line: 'Ipopt banner', - }) - }) -}) + kind: "iter", + raw: "AMICODE_ITER iter=12 f=3.4e-5 inf_pr=1.2e-8", + fields: { iter: "12", f: "3.4e-5", inf_pr: "1.2e-8" }, + }); + }); + it("classifies DONE as done", () => { + expect(classifyLine("DONE fidelity=0.9999", "stdout").kind).toBe("done"); + }); + it("AMICODE_ITER on stderr is just log (convention is stdout-only)", () => { + expect(classifyLine("AMICODE_ITER iter=1", "stderr").kind).toBe("log"); + }); + it("malformed tokens are skipped, never throw", () => { + const ev = classifyLine("AMICODE_ITER iter=1 ====garbage", "stdout"); + expect(ev.kind).toBe("iter"); + }); + it("everything else is log with stream tagged", () => { + expect(classifyLine("Ipopt banner", "stderr")).toEqual({ + kind: "log", + stream: "stderr", + line: "Ipopt banner", + }); + }); +}); diff --git a/packages/amico-run/test/verify.test.ts b/packages/amico-run/test/verify.test.ts index 01a5d70b..2b25c1e0 100644 --- a/packages/amico-run/test/verify.test.ts +++ b/packages/amico-run/test/verify.test.ts @@ -1,27 +1,27 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest" -import { mkdtempSync, mkdirSync, existsSync, readFileSync, writeFileSync, chmodSync, rmSync } from "node:fs" -import { tmpdir } from "node:os" -import { join } from "node:path" -import { runVerification } from "../src/verify.js" -import { readToml } from "./helpers.js" -import type { AuthoringConfig } from "../src/authoring.js" -import type { SpecStamp } from "../src/types.js" +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, mkdirSync, existsSync, readFileSync, writeFileSync, chmodSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runVerification } from "../src/verify.js"; +import { readToml } from "./helpers.js"; +import type { AuthoringConfig } from "../src/authoring.js"; +import type { SpecStamp } from "../src/types.js"; -let root: string +let root: string; beforeEach(() => { - root = mkdtempSync(join(tmpdir(), "amico-verify-")) -}) + root = mkdtempSync(join(tmpdir(), "amico-verify-")); +}); afterEach(() => { - delete process.env.AMICO_VERIFY_RUNNER - rmSync(root, { recursive: true, force: true }) -}) + delete process.env.AMICO_VERIFY_RUNNER; + rmSync(root, { recursive: true, force: true }); +}); // A fake harness = a node script that writes verification.toml into argv[1] (the run dir). function fakeHarness(name: string, body: string): string { - const p = join(root, name) - writeFileSync(p, `#!/usr/bin/env node\n${body}\n`) - chmodSync(p, 0o755) - return p + const p = join(root, name); + writeFileSync(p, `#!/usr/bin/env node\n${body}\n`); + chmodSync(p, 0o755); + return p; } function authoring(harness?: string): AuthoringConfig { @@ -30,47 +30,47 @@ function authoring(harness?: string): AuthoringConfig { support_set: [], verify_harness: harness, verify_tolerance: 0.01, - } + }; } -const FREE_SPEC: SpecStamp = { canonical: "{}", tier: "free" } +const FREE_SPEC: SpecStamp = { canonical: "{}", tier: "free" }; describe("runVerification", () => { it("harness writes verification.toml → left intact", async () => { - const runDir = join(root, "run") - mkdirSync(runDir) + const runDir = join(root, "run"); + mkdirSync(runDir); const harness = fakeHarness( "h.js", `const fs=require('fs'),p=require('path');fs.writeFileSync(p.join(process.argv[2],'verification.toml'),'schema_version = "1"\\nagree = true\\nfidelity_rerolled = 0.998\\n')`, - ) - process.env.AMICO_VERIFY_RUNNER = "node" - await runVerification(runDir, FREE_SPEC, authoring(harness)) - const v = readToml(join(runDir, "verification.toml")) - expect(v.agree).toBe(true) - expect(v.fidelity_rerolled).toBe(0.998) - }) + ); + process.env.AMICO_VERIFY_RUNNER = "node"; + await runVerification(runDir, FREE_SPEC, authoring(harness)); + const v = readToml(join(runDir, "verification.toml")); + expect(v.agree).toBe(true); + expect(v.fidelity_rerolled).toBe(0.998); + }); it("missing harness path → fallback verification.toml agree=false + error", async () => { - const runDir = join(root, "run") - mkdirSync(runDir) - await runVerification(runDir, FREE_SPEC, authoring(join(root, "nonexistent.jl"))) - const v = readToml(join(runDir, "verification.toml")) - expect(v.agree).toBe(false) - expect(String(v.error)).toMatch(/harness/) - }) + const runDir = join(root, "run"); + mkdirSync(runDir); + await runVerification(runDir, FREE_SPEC, authoring(join(root, "nonexistent.jl"))); + const v = readToml(join(runDir, "verification.toml")); + expect(v.agree).toBe(false); + expect(String(v.error)).toMatch(/harness/); + }); it("harness exits nonzero WITHOUT writing → fallback agree=false + error", async () => { - const runDir = join(root, "run") - mkdirSync(runDir) - const harness = fakeHarness("h.js", `process.exit(3)`) - process.env.AMICO_VERIFY_RUNNER = "node" - await runVerification(runDir, FREE_SPEC, authoring(harness)) - const v = readToml(join(runDir, "verification.toml")) - expect(v.agree).toBe(false) - expect(existsSync(join(runDir, "verification.toml"))).toBe(true) - }) + const runDir = join(root, "run"); + mkdirSync(runDir); + const harness = fakeHarness("h.js", `process.exit(3)`); + process.env.AMICO_VERIFY_RUNNER = "node"; + await runVerification(runDir, FREE_SPEC, authoring(harness)); + const v = readToml(join(runDir, "verification.toml")); + expect(v.agree).toBe(false); + expect(existsSync(join(runDir, "verification.toml"))).toBe(true); + }); it("no harness configured at all → fallback agree=false (never verification-less)", async () => { - const runDir = join(root, "run") - mkdirSync(runDir) - await runVerification(runDir, FREE_SPEC, authoring(undefined)) - expect(existsSync(join(runDir, "verification.toml"))).toBe(true) - expect(readToml(join(runDir, "verification.toml")).agree).toBe(false) - }) -}) + const runDir = join(root, "run"); + mkdirSync(runDir); + await runVerification(runDir, FREE_SPEC, authoring(undefined)); + expect(existsSync(join(runDir, "verification.toml"))).toBe(true); + expect(readToml(join(runDir, "verification.toml")).agree).toBe(false); + }); +}); diff --git a/packages/extension/.DS_Store b/packages/extension/.DS_Store index 77a0145d3a4010916414f37f4bf7593d505e8905..b1828c242a460f3c314e9c06329c0bb46e65d60f 100644 GIT binary patch delta 183 zcmZn(XbIS$BES^nJy}iQ8IwfoWIn+VIRgeDU|~pM$YdyHD9K4T3{K9^EdU8JSUT8E zZV-%OY?ypoa37<^ObS7hrGyh0H%zV;)?u`tTq7(q`H1iWrf%lRb3{}ocZtko zJU3ZIG>!4W7Dn~4c9GPzyb>>+-eml%bUJ;e6_0Bol@ A>Hq)$ delta 183 zcmZn(XbIS$BES^TJXuZP8I!o!WIn+VIRyqFU|~pM$YdyHD9K4T3{K9^EdU8JSjyQ< zZV-%OOqhIIa37=DOhQ_drGyh07fh}e)?o~sTq7(q`H1iWrY^b3b3{}ocZtko z)SRp$n#TBHa<`}=Q-|5)b)q?p7bZ)KNivx|-E1Z%z{un/solvespec.json`**: `{schema_version:"2", script_path:"…/solve.jl", lab_id:"default", - executor:"local", tier:"", env:{kind, project?}, source:, - hashes:{system_hash, formulation_hash}}` — read the hashes from the LAST +executor:"local", tier:"", env:{kind, project?}, source:, +hashes:{system_hash, formulation_hash}}` — read the hashes from the LAST matching events in `~/.amico/problems//events.jsonl` (the `hash` field on the newest `system`/`formulation` events). 6. **Launch through the gate, detached.** Pass `--project` matching the tier's @@ -189,7 +189,7 @@ Stages, in order: Piccolissimo **free-phase CZ path**), honest about depth; 3. no skill matches → **offer free-tier from-scratch authoring anyway** (public packages, **unvetted**, re-rollout-verified). "No template" is never a decline. - Show the model Hamiltonian when you know it. + Show the model Hamiltonian when you know it. - transmon: $\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, @@ -237,7 +237,7 @@ Stages, in order: **Transmon: single qubit only via the vetted template.** The bundled vetted template builds ONE `TransmonSystem` (scalar `ω`/`δ`) and embeds a single-qubit target: X, Y, Z, H, S, T, √X, and arbitrary single-qubit unitaries. Multi-qubit -*transmon* gates (CNOT, CZ, iSWAP on transmons) have no vetted template or +_transmon_ gates (CNOT, CZ, iSWAP on transmons) have no vetted template or exemplar — but they are **not declined**: they route through the **free-tier** offer (author from scratch, **unvetted**, re-rollout-verified), with that caveat stated up front. (Piccolo's `MultiTransmonSystem` exists; a from-scratch coupled @@ -247,6 +247,7 @@ CZ is the exception:** it resolves to the composed `rydberg-cz` exemplar lists it — honestly caveated (see the PLATFORM stage). **Choose parameters for the regime** (the defaults converge to F > 0.999): + - `levels`: 3 (default) or 4 for more leakage realism. **Avoid 5+** — added levels worsen conditioning and leakage and inflate solve cost, so convergence degrades; if the user insists, warn it may not converge. @@ -279,6 +280,7 @@ script, running with cwd = the run dir, must emit: trajectory from the primal, and prints the lines. **A script that skips these lines gets a dead live plot** — the Inspector sits on "warming up" until completion, then shows a no-pulse-data hint. + - `iter_.png` every few iterations — **archival/publication artifact** (`plot_pulse` is canonical there); the Inspector no longer displays PNGs. See the per-iter plotting idiom below — **`LivePulsePlotCallback`** once the bundled @@ -337,6 +339,7 @@ correct loader in this Piccolo. ## Julia project The Julia project to pass as `--project` is: + **{{JULIA_PROJECT}}**. Always pass it. ## Style diff --git a/packages/extension/CONTRACT.md b/packages/extension/CONTRACT.md index 54147ac4..5ed4f576 100644 --- a/packages/extension/CONTRACT.md +++ b/packages/extension/CONTRACT.md @@ -13,19 +13,19 @@ A run lives at `~/.amico/runs///`, where `runId` is `run.toml` **first** and `FINISHED` **last**; the script (cwd = the run dir) emits the rest. -| Artifact | Writer | Contents | -|---|---|---| -| `run.toml` | amico-run (first) | `schema_version = "1"`, snake_case keys: `run_id`, `lab`, `lab_id`, `script_path`, `created_at`, `orchestrator_version`, and a `[julia]` table (`binary`, optional `project`/`sysimage`). | -| `run.log` | amico-run (stdout tee) | One `AMICODE_ITER iter= f= inf_pr=<…> inf_du=<…>` line per Ipopt iteration (drives the live stats row), plus a final `DONE fidelity=<…>` line and any Julia traceback. | -| `iter_.png` | script | Per-iteration pulse/fidelity plot. `N` is the iteration with **unbounded digits** (`iter_0`, `iter_10`, … `iter_0060`). The inspector globs `iter_*.png`. | -| `result.toml` | script (atomic) | Written `result.toml.tmp` then renamed. At least `fidelity` (float) and `iterations` (int); `wall_seconds` optional. | -| `FINISHED` | amico-run (last, terminal) | `status = "completed" | "failed" | "aborted"` and `exit_code` (int). Its presence is the **only** completion signal — the inspector fires `onFinished` solely on a valid `FINISHED`, so a killed solve shows "running", never a false success. | +| Artifact | Writer | Contents | +| -------------- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `run.toml` | amico-run (first) | `schema_version = "1"`, snake_case keys: `run_id`, `lab`, `lab_id`, `script_path`, `created_at`, `orchestrator_version`, and a `[julia]` table (`binary`, optional `project`/`sysimage`). | +| `run.log` | amico-run (stdout tee) | One `AMICODE_ITER iter= f= inf_pr=<…> inf_du=<…>` line per Ipopt iteration (drives the live stats row), plus a final `DONE fidelity=<…>` line and any Julia traceback. | +| `iter_.png` | script | Per-iteration pulse/fidelity plot. `N` is the iteration with **unbounded digits** (`iter_0`, `iter_10`, … `iter_0060`). The inspector globs `iter_*.png`. | +| `result.toml` | script (atomic) | Written `result.toml.tmp` then renamed. At least `fidelity` (float) and `iterations` (int); `wall_seconds` optional. | +| `FINISHED` | amico-run (last, terminal) | `status = "completed" | "failed" | "aborted"`and`exit_code`(int). Its presence is the **only** completion signal — the inspector fires`onFinished`solely on a valid`FINISHED`, so a killed solve shows "running", never a false success. | Two convenience files live at the **lab runs root** (`~/.amico/runs//`): -| File | Writer | Contents | -|---|---|---| -| `index` | amico-run (`appendIndex`) | Append-only, tab-separated `\t\t` per run. | +| File | Writer | Contents | +| -------- | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| `index` | amico-run (`appendIndex`) | Append-only, tab-separated `\t\t` per run. | | `latest` | amico-run (`updateLatest`) | Symlink → the most recent ``; written via temp-then-rename so the watcher sees an atomic swing. The inspector follows `latest`. | ## Frozen schemas diff --git a/packages/extension/DEMO_CHECKLIST.md b/packages/extension/DEMO_CHECKLIST.md index 47eff76e..10792754 100644 --- a/packages/extension/DEMO_CHECKLIST.md +++ b/packages/extension/DEMO_CHECKLIST.md @@ -8,7 +8,7 @@ net; rows 4–6 are the live run. - [ ] **Install clean** — followed `RUNBOOK.md` end-to-end on the target machine; total time recorded below (target ≤ 60 min). - [ ] **Healthcheck green** — `node packages/extension/scripts/healthcheck.mjs` exits `0` (julia + pinned Piccolo project · opencode `/event` · `amico-run` · Bedrock creds). -- [ ] **Fallback armed** — Command Palette → **"Amicode: Replay demo run"** stages the bundled solve and the Run Inspector renders it (iter frames + final fidelity + promote prompt), with **no Julia, no opencode, no creds**. Confirm this works *before* relying on the live path. +- [ ] **Fallback armed** — Command Palette → **"Amicode: Replay demo run"** stages the bundled solve and the Run Inspector renders it (iter frames + final fidelity + promote prompt), with **no Julia, no opencode, no creds**. Confirm this works _before_ relying on the live path. ## Live run @@ -25,11 +25,11 @@ net; rows 4–6 are the live run. **Recorded timings (fill in at the dry-run):** -| Step | Time | -|---|---| -| Julia install | | -| `install.sh` (instantiate + precompile + VSIX) | | -| Healthcheck | | -| First live solve (cold) | | -| Replay fallback | instant | -| **Total** | | +| Step | Time | +| ---------------------------------------------- | ------- | +| Julia install | | +| `install.sh` (instantiate + precompile + VSIX) | | +| Healthcheck | | +| First live solve (cold) | | +| Replay fallback | instant | +| **Total** | | diff --git a/packages/extension/DISTILLER.md b/packages/extension/DISTILLER.md index 40139a2c..dd80de46 100644 --- a/packages/extension/DISTILLER.md +++ b/packages/extension/DISTILLER.md @@ -16,6 +16,7 @@ first run `KNOWLEDGE.md` / `problems/` may be empty or absent — that just mean no cards exist yet; create what you need under `amicode/`. Your input is ONE JSON job object (the message you were invoked with): + - `{"kind":"run","run_id":"r...","runs_root":"...","vault":"...","ops":"..."}` - `{"kind":"sweep","session_ids":[...],"vault":"...","ops":"..."}` — distill these sessions - `{"kind":"onboarding","vault":"...","ops":"..."}` — materialize the profile @@ -69,10 +70,12 @@ Your input is ONE JSON job object (the message you were invoked with): `run.toml` may carry `session_id` and `workspace` (newer runs — prefer them). Otherwise recover: + ``` sqlite3 "file:...opencode.db?mode=ro" \ "SELECT DISTINCT session_id FROM part WHERE data LIKE '%%';" ``` + ALL matched sessions are contributing `sessions:`. The **launching** session is the one whose matching part contains the launch command itself (`amico-run`); fallback: the earliest mention by `part.time_created`. The workspace is the @@ -81,6 +84,7 @@ session's `amicode_*` records. A run that joins to nothing still gets a card (note "orphan run — no session recovered"); never drop it silently. Useful transcript queries (ids are 30 chars — never truncate them): + ``` -- substantive check / entity writes and launches for a session: SELECT json_extract(data,'$.tool') FROM part WHERE session_id='' @@ -99,19 +103,19 @@ SELECT json_extract(data,'$.text') FROM part WHERE session_id='' type: amicode-problem slug: x-gate-transmon platform: transmon -problem_kind: gate_synthesis # gate_synthesis | state_prep -target: X # gate name or state name (e.g. cat-state) -status: solved # solved | attempted | failed -best_fidelity: 0.99995 # ONLY from result.toml; omit if none -best_run: r20260703-095831Z-e5b7 # omit if none -pulse_ref: pulses/x-gate-transmon-v1 # null if no successful pulse +problem_kind: gate_synthesis # gate_synthesis | state_prep +target: X # gate name or state name (e.g. cat-state) +status: solved # solved | attempted | failed +best_fidelity: 0.99995 # ONLY from result.toml; omit if none +best_run: r20260703-095831Z-e5b7 # omit if none +pulse_ref: pulses/x-gate-transmon-v1 # null if no successful pulse solve_count: 8 first_seen: 2026-07-03 last_seen: 2026-07-04 sessions: [ses_..., ses_...] -sys_params: # STRUCTURED regime scalars (L1 §2.1.1) — - levels: 3 # the deterministic "high"-confidence gate. - drive_max: 0.2 # Emit the platform's gating scalars: +sys_params: # STRUCTURED regime scalars (L1 §2.1.1) — + levels: 3 # the deterministic "high"-confidence gate. + drive_max: 0.2 # Emit the platform's gating scalars: # transmon: levels (int), drive_max (float) # cavity/bosonic: fock_cutoff (int), chi (float), alpha or fock_index # atoms: levels, rabi_max, delta_max, distance @@ -122,15 +126,19 @@ sys_params: # STRUCTURED regime scalars (L1 §2.1.1) # on ## System + ## Formulation + ## History + - solves , , F ∈ [, ]. ## Lessons + - ``` @@ -175,15 +183,17 @@ New `-v` ONLY when fidelity strictly improves on the card's Entity → card mapping (`/onboarding/events.jsonl`; replay in order, later entries win — update-in-place, never duplicate): + - `profile` entity (`name`,`role`,`org`,`platforms`,`goals`) → `PROFILE.md`: ```markdown # Profile — + - Role: - Org / lab: - Platforms: - Environment: [](environment/.md) — -- Devices: [](devices/.md) # one line per device, if any +- Devices: [](devices/.md) # one line per device, if any - Goals: - Onboarded: (re-run onboarding to update) ``` @@ -240,6 +250,7 @@ params, write a **thin card** (platform + script pointer + "params not extracted") — never skip the demo, never fabricate. Demo card frontmatter (mirror the problem card + these): + ``` type: amicode-demo slug: stanford-bosonics-cat @@ -254,6 +265,7 @@ sys_params: { fock_cutoff: 20, chi: 0.0000328, alpha: 2 } # if readable ``` DEMOS.md line: + ``` - [stanford-bosonics-cat](demos/stanford-bosonics-cat.md) — cavity state_prep cat-state, N_fock=20, script scripts/optimize_cat_alpha2.jl ``` @@ -265,4 +277,4 @@ but never merge with the user's own solves (source distinguishes them). ## Finishing a job 1. Write the files. 2. Pathspec-scoped commit (Hard rule 1). 3. Final message: -one line, e.g. `distilled r...-e5b7 → x-gate-transmon (updated, F=0.99995, v1 banked)`. + one line, e.g. `distilled r...-e5b7 → x-gate-transmon (updated, F=0.99995, v1 banked)`. diff --git a/packages/extension/RUNBOOK.md b/packages/extension/RUNBOOK.md index bad50a6f..5b38a67e 100644 --- a/packages/extension/RUNBOOK.md +++ b/packages/extension/RUNBOOK.md @@ -3,16 +3,17 @@ Target: a clean macOS/Linux machine → Amicode demo-ready. Times are estimates; the dominant cost is the first Julia precompile. -| # | Step | ~Time | -|---|------|------| -| 1 | Install Julia: `curl -fsSL https://install.julialang.org \| sh` (then restart your shell) | 5 min | -| 2 | Get the VSIX: in the amicode repo, `pnpm install && pnpm --filter amicode-v2 package` | 5 min | -| 3 | `bash packages/extension/scripts/install.sh` — instantiates the pinned Julia project (precompiles) + installs the VSIX + writes `~/.amico/lab.toml` | 15–25 min | -| 4 | Configure the LLM **through opencode** (amico reads opencode's resolution; it stores no key of its own). Give opencode a provider credential via any path it supports — the simplest is a provider API key in the environment (e.g. `export ANTHROPIC_API_KEY=sk-ant-…`), or `~/.config/opencode` / `opencode auth login`. Then select a matching model in `~/.config/opencode/opencode.jsonc`, e.g. `"model":"anthropic/claude-sonnet-4-6"` (Bedrock: `"model":"amazon-bedrock/us.anthropic.claude-sonnet-4-6"` + `"provider":{"amazon-bedrock":{"region":"us-east-1"}}` and AWS creds in env/`~/.aws`). The healthcheck + chat confirm a provider resolves via opencode's live `/config/providers`. | 5 min | -| 5 | `node packages/extension/scripts/healthcheck.mjs` → expect all ✓, exit 0 | 2 min | -| 6 | Open VS Code → Amicode chat → run a test gate; confirm the Run Inspector renders | 10 min | +| # | Step | ~Time | +| --- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | +| 1 | Install Julia: `curl -fsSL https://install.julialang.org \| sh` (then restart your shell) | 5 min | +| 2 | Get the VSIX: in the amicode repo, `pnpm install && pnpm --filter amicode-v2 package` | 5 min | +| 3 | `bash packages/extension/scripts/install.sh` — instantiates the pinned Julia project (precompiles) + installs the VSIX + writes `~/.amico/lab.toml` | 15–25 min | +| 4 | Configure the LLM **through opencode** (amico reads opencode's resolution; it stores no key of its own). Give opencode a provider credential via any path it supports — the simplest is a provider API key in the environment (e.g. `export ANTHROPIC_API_KEY=sk-ant-…`), or `~/.config/opencode` / `opencode auth login`. Then select a matching model in `~/.config/opencode/opencode.jsonc`, e.g. `"model":"anthropic/claude-sonnet-4-6"` (Bedrock: `"model":"amazon-bedrock/us.anthropic.claude-sonnet-4-6"` + `"provider":{"amazon-bedrock":{"region":"us-east-1"}}` and AWS creds in env/`~/.aws`). The healthcheck + chat confirm a provider resolves via opencode's live `/config/providers`. | 5 min | +| 5 | `node packages/extension/scripts/healthcheck.mjs` → expect all ✓, exit 0 | 2 min | +| 6 | Open VS Code → Amicode chat → run a test gate; confirm the Run Inspector renders | 10 min | ## Troubleshooting (healthcheck failures) + - `✗ julia+project` → re-run `install.sh`; check `julia --version`. - `✗ opencode /event` → `pnpm --filter amicode-v2 fetch:opencode`; re-run. - `✗ amico-run` → `pnpm -r build` (stages `bin/`) or reinstall the VSIX. diff --git a/packages/extension/TESTING.md b/packages/extension/TESTING.md index 5bb9c1b1..abb3d6cc 100644 --- a/packages/extension/TESTING.md +++ b/packages/extension/TESTING.md @@ -42,7 +42,7 @@ restarts reuse it. **Run Inspector** pops with the live pulse, expect **F ≥ 0.999** in ~1–2 min warm. 4. **Fast path** — new session, type "optimize an X gate on my transmon, defaults" — should skip the interview and launch directly. -5. **Rydberg** — pick "neutral-atom Rydberg" in the interview: expect the *honest scope* +5. **Rydberg** — pick "neutral-atom Rydberg" in the interview: expect the _honest scope_ behavior (System recorded, formulation captured for follow-up — no dead reckoning). An **experimental** CZ template exists (`templates/solve_rydberg_cz.jl`, QuEra gate-zone params, public-Piccolo-only) but is NOT yet vetted — its first NLP iteration is diff --git a/packages/extension/dev/pulseplot_harness/index.html b/packages/extension/dev/pulseplot_harness/index.html index 5e7190e8..45835f05 100644 --- a/packages/extension/dev/pulseplot_harness/index.html +++ b/packages/extension/dev/pulseplot_harness/index.html @@ -1,40 +1,68 @@ - + - - - pulseplot harness (#66) - - - - - -

- - - - - -
-
- - + body { + --vscode-foreground: #333; + --vscode-editor-background: #fcfcfb; + --vscode-descriptionForeground: #717171; + --vscode-charts-blue: #1a85ff; + --vscode-charts-orange: #a05a00; + --vscode-charts-purple: #8b47b7; + --vscode-charts-green: #2e7d32; + background: var(--vscode-editor-background); + color: var(--vscode-foreground); + font-family: system-ui, sans-serif; + font-size: 13px; + margin: 0; + padding: 16px; + height: 100vh; + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 12px; + } + body.dark { + --vscode-foreground: #ccc; + --vscode-editor-background: #1a1a19; + --vscode-descriptionForeground: #9d9d9d; + --vscode-charts-blue: #3794ff; + --vscode-charts-orange: #c17800; + --vscode-charts-purple: #9b6bc4; + --vscode-charts-green: #4d9e51; + } + #controls { + display: flex; + gap: 8px; + align-items: center; + } + #plot-host { + flex: 1; + display: flex; + min-height: 0; + } + #bench-out { + font-family: monospace; + font-size: 12px; + } + + + +
+ + + + + +
+
+ + diff --git a/packages/extension/dev/pulseplot_harness/main.ts b/packages/extension/dev/pulseplot_harness/main.ts index b7174a72..f77d5e97 100644 --- a/packages/extension/dev/pulseplot_harness/main.ts +++ b/packages/extension/dev/pulseplot_harness/main.ts @@ -8,7 +8,15 @@ import { pulseplot } from "../../media/ui/components/pulseplot"; -const HARNESS_META = { drives: 2, knots: 50, labels: ["a_1", "a_2"], bounds: [[-0.2, 0.2], [-0.2, 0.2]] as [number, number][] }; +const HARNESS_META = { + drives: 2, + knots: 50, + labels: ["a_1", "a_2"], + bounds: [ + [-0.2, 0.2], + [-0.2, 0.2], + ] as [number, number][], +}; const root = document.getElementById("plot-host")!; const plot = pulseplot("Harness idle — press play."); @@ -39,16 +47,24 @@ let timer: ReturnType | undefined; let iter = 0; const playBtn = document.getElementById("play")!; playBtn.addEventListener("click", () => { - if (timer) { clearInterval(timer); timer = undefined; playBtn.textContent = "▶ play"; return; } + if (timer) { + clearInterval(timer); + timer = undefined; + playBtn.textContent = "▶ play"; + return; + } plot.meta(HARNESS_META); playBtn.textContent = "⏸ pause"; timer = setInterval(() => { plot.update(syntheticRecord(iter++)); if (iter > 60) iter = 0; - }, 200); // the host's 5 Hz cadence + }, 200); // the host's 5 Hz cadence }); -document.getElementById("clear")!.addEventListener("click", () => { plot.clear(); iter = 0; }); +document.getElementById("clear")!.addEventListener("click", () => { + plot.clear(); + iter = 0; +}); // --- AC8 budget: median + p95 of component update at fixture scale document.getElementById("bench")!.addEventListener("click", () => { @@ -61,7 +77,9 @@ document.getElementById("bench")!.addEventListener("click", () => { times.push(performance.now() - t0); } times.sort((a, b) => a - b); - const med = times[150].toFixed(3), p95 = times[285].toFixed(3), max = times[299].toFixed(3); + const med = times[150].toFixed(3), + p95 = times[285].toFixed(3), + max = times[299].toFixed(3); const verdict = times[285] <= 16 ? "PASS (≤16ms)" : "FAIL (>16ms)"; document.getElementById("bench-out")!.textContent = `update() over 300 frames @ 2×50: median ${med}ms · p95 ${p95}ms · max ${max}ms → ${verdict}`; @@ -69,24 +87,34 @@ document.getElementById("bench")!.addEventListener("click", () => { }); // --- optional recorded replay -fetch("./pulse-events.json").then((r) => (r.ok ? r.json() : undefined)).then((events?: Array>) => { - if (!events) return; - const btn = document.createElement("button"); - btn.textContent = "▶ replay recording"; - btn.addEventListener("click", () => { - if (timer) { clearInterval(timer); timer = undefined; } - let i = 0; - timer = setInterval(() => { - const e = events[i++]; - if (!e) { clearInterval(timer!); timer = undefined; return; } - if (e.type === "pulsemeta") plot.meta(e as never); - else if (e.type === "pulse") plot.update(e as never); - }, 200); +fetch("./pulse-events.json") + .then((r) => (r.ok ? r.json() : undefined)) + .then((events?: Array>) => { + if (!events) return; + const btn = document.createElement("button"); + btn.textContent = "▶ replay recording"; + btn.addEventListener("click", () => { + if (timer) { + clearInterval(timer); + timer = undefined; + } + let i = 0; + timer = setInterval(() => { + const e = events[i++]; + if (!e) { + clearInterval(timer!); + timer = undefined; + return; + } + if (e.type === "pulsemeta") plot.meta(e as never); + else if (e.type === "pulse") plot.update(e as never); + }, 200); + }); + document.getElementById("controls")!.append(btn); }); - document.getElementById("controls")!.append(btn); -}); // --- URL-hash automation for headless-ish eyeballing: #autoplay #bench #dark if (location.hash.includes("dark")) document.body.classList.add("dark"); if (location.hash.includes("autoplay")) (document.getElementById("play") as HTMLButtonElement).click(); -if (location.hash.includes("bench")) setTimeout(() => (document.getElementById("bench") as HTMLButtonElement).click(), 800); +if (location.hash.includes("bench")) + setTimeout(() => (document.getElementById("bench") as HTMLButtonElement).click(), 800); diff --git a/packages/extension/julia/README.md b/packages/extension/julia/README.md index 09892424..fd59eaf9 100644 --- a/packages/extension/julia/README.md +++ b/packages/extension/julia/README.md @@ -1,3 +1,5 @@ # Pinned, vetted Julia env (Piccolo 1.19) bundled in the VSIX. + # Provisioned to ~/.amico/julia via `Pkg.instantiate()` (scripts/install.sh). + # Regenerate: re-instantiate ~/.amico/julia, then copy Project.toml + Manifest.toml here. diff --git a/packages/extension/media/brand.css b/packages/extension/media/brand.css index 9f3c55cc..fc55f1a3 100644 --- a/packages/extension/media/brand.css +++ b/packages/extension/media/brand.css @@ -13,8 +13,8 @@ :root { /* color */ - --color-accent: #FFF676; - --color-on-accent: #000000; + --color-accent: #fff676; + --color-on-accent: #000000; --color-ok: var(--vscode-testing-iconPassed); --color-fail: var(--vscode-errorForeground); --color-run: var(--vscode-progressBar-background); diff --git a/packages/extension/media/layout.css b/packages/extension/media/layout.css index 12aeb420..3b7fcb86 100644 --- a/packages/extension/media/layout.css +++ b/packages/extension/media/layout.css @@ -1,16 +1,51 @@ /* layout.css — formal layout selectors. Composition only; values from brand.css. */ -* { box-sizing: border-box; } -.stack { display: flex; flex-direction: column; gap: var(--space-md); } -.row { display: flex; align-items: center; gap: var(--space-md); } -.wrap { flex-wrap: wrap; } -.grid-fit { display: grid; grid-template-columns: repeat(auto-fit, minmax(var(--grid-min, 112px), 1fr)); gap: var(--space-sm); } +* { + box-sizing: border-box; +} +.stack { + display: flex; + flex-direction: column; + gap: var(--space-md); +} +.row { + display: flex; + align-items: center; + gap: var(--space-md); +} +.wrap { + flex-wrap: wrap; +} +.grid-fit { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(var(--grid-min, 112px), 1fr)); + gap: var(--space-sm); +} /* metric-row: content-width tiles, left-aligned, wrapping — NOT stretched to fill (that made a scalar like the iteration count occupy a huge tile). */ -.metric-row { display: flex; align-items: stretch; gap: var(--space-sm); flex-wrap: wrap; } -.grow { flex: 1; } -.push-end { margin-left: auto; } -.scroll-y { overflow-y: auto; } -.gap-xs { gap: var(--space-xs); } -.gap-sm { gap: var(--space-sm); } -.gap-lg { gap: var(--space-lg); } -.pad-lg { padding: var(--space-lg); } +.metric-row { + display: flex; + align-items: stretch; + gap: var(--space-sm); + flex-wrap: wrap; +} +.grow { + flex: 1; +} +.push-end { + margin-left: auto; +} +.scroll-y { + overflow-y: auto; +} +.gap-xs { + gap: var(--space-xs); +} +.gap-sm { + gap: var(--space-sm); +} +.gap-lg { + gap: var(--space-lg); +} +.pad-lg { + padding: var(--space-lg); +} diff --git a/packages/extension/media/ui/atoms/button.ts b/packages/extension/media/ui/atoms/button.ts index e8886dca..1febcf5f 100644 --- a/packages/extension/media/ui/atoms/button.ts +++ b/packages/extension/media/ui/atoms/button.ts @@ -2,7 +2,9 @@ import { defineStyle } from "../style"; -defineStyle("button", ` +defineStyle( + "button", + ` .btn { font-family: var(--text-font); font-size: var(--text-small); color: var(--vscode-button-secondaryForeground, var(--vscode-foreground)); background: var(--vscode-button-secondaryBackground, transparent); @@ -11,7 +13,8 @@ defineStyle("button", ` cursor: pointer; display: inline-flex; align-items: center; gap: var(--space-xs); } .btn:hover:not(:disabled) { border-color: var(--color-accent); } .btn:disabled { opacity: 0.4; cursor: default; } -`); +`, +); export interface ButtonAtom { el: HTMLButtonElement; @@ -23,6 +26,13 @@ export function button(label: string, onClick: () => void): ButtonAtom { el.className = "btn"; el.type = "button"; el.textContent = label; - el.addEventListener("click", () => { if (!el.disabled) onClick(); }); - return { el, enable: (on: boolean) => { el.disabled = !on; } }; + el.addEventListener("click", () => { + if (!el.disabled) onClick(); + }); + return { + el, + enable: (on: boolean) => { + el.disabled = !on; + }, + }; } diff --git a/packages/extension/media/ui/atoms/text.ts b/packages/extension/media/ui/atoms/text.ts index f1bef606..721fca78 100644 --- a/packages/extension/media/ui/atoms/text.ts +++ b/packages/extension/media/ui/atoms/text.ts @@ -2,13 +2,16 @@ import { defineStyle } from "../style"; -defineStyle("text", ` +defineStyle( + "text", + ` .mono { font-family: var(--text-mono); } .dim { color: var(--color-dim); } .small { font-size: var(--text-small); } .label-k { font-size: var(--text-label); text-transform: uppercase; letter-spacing: 0.6px; font-weight: 600; color: var(--color-dim); } -`); +`, +); export interface TextAtom { el: HTMLSpanElement; @@ -19,5 +22,10 @@ export function text(className = "", initial = ""): TextAtom { const el = document.createElement("span"); if (className) el.className = className; el.textContent = initial; - return { el, set(t) { el.textContent = t; } }; + return { + el, + set(t) { + el.textContent = t; + }, + }; } diff --git a/packages/extension/media/ui/components/metric.ts b/packages/extension/media/ui/components/metric.ts index 0def7e42..d4866221 100644 --- a/packages/extension/media/ui/components/metric.ts +++ b/packages/extension/media/ui/components/metric.ts @@ -3,7 +3,9 @@ import { defineStyle } from "../style"; import { text } from "../atoms/text"; -defineStyle("metric", ` +defineStyle( + "metric", + ` .metric { background: var(--bg-box); border: var(--border-width) solid var(--border-color); border-radius: var(--border-radius); padding: var(--space-sm) var(--space-md); @@ -14,7 +16,8 @@ defineStyle("metric", ` /* hero = the number that matters: accent border, larger value. */ .metric-hero { border-color: var(--border-color-hero); } .metric-hero .v { font-size: var(--text-hero); font-weight: 600; } -`); +`, +); export type MetricVariant = "counter" | "small" | "hero"; diff --git a/packages/extension/media/ui/components/pulseplot.ts b/packages/extension/media/ui/components/pulseplot.ts index 678e5420..dcb09514 100644 --- a/packages/extension/media/ui/components/pulseplot.ts +++ b/packages/extension/media/ui/components/pulseplot.ts @@ -11,7 +11,9 @@ import { defineStyle } from "../style"; import { text } from "../atoms/text"; -defineStyle("pulseplot", ` +defineStyle( + "pulseplot", + ` .pulseplot { display: flex; flex-direction: column; gap: var(--space-xs); flex: 1 1 240px; min-width: 0; min-height: 240px; background: var(--bg-plot); @@ -30,15 +32,25 @@ defineStyle("pulseplot", ` .pulseplot.pp-empty .pp-panel, .pulseplot.pp-empty .pp-axis { display: none; } .pulseplot .pp-hint { place-self: center; margin: auto; opacity: 0.55; font-style: italic; } .pulseplot:not(.pp-empty) .pp-hint { display: none; } -`); +`, +); -const MAX_KNOTS = 512; // above this, stride-decimate before rendering -const W = 1000; // viewBox coordinate space (preserveAspectRatio=none) +const MAX_KNOTS = 512; // above this, stride-decimate before rendering +const W = 1000; // viewBox coordinate space (preserveAspectRatio=none) const H = 100; -const PAD = 0.08; // y-domain padding around the bounds band +const PAD = 0.08; // y-domain padding around the bounds band -export interface PulsePlotMeta { drives: number; knots: number; labels: string[]; bounds: [number, number][] } -export interface PulsePlotRecord { iter: number; dt: number; values: number[][] } +export interface PulsePlotMeta { + drives: number; + knots: number; + labels: string[]; + bounds: [number, number][]; +} +export interface PulsePlotRecord { + iter: number; + dt: number; + values: number[][]; +} interface Panel { el: HTMLDivElement; @@ -101,15 +113,18 @@ export function pulseplot(idleHint = "No pulse data yet."): PulsePlot { const y = yScale(lo, hi); const band = svgEl("rect"); band.setAttribute("class", "pp-band"); - band.setAttribute("x", "0"); band.setAttribute("width", String(W)); + band.setAttribute("x", "0"); + band.setAttribute("width", String(W)); band.setAttribute("y", String(y(hi))); band.setAttribute("height", String(y(lo) - y(hi))); const mkLine = (cls: string, v: number): SVGLineElement => { const line = svgEl("line"); line.setAttribute("class", cls); - line.setAttribute("x1", "0"); line.setAttribute("x2", String(W)); - line.setAttribute("y1", String(y(v))); line.setAttribute("y2", String(y(v))); + line.setAttribute("x1", "0"); + line.setAttribute("x2", String(W)); + line.setAttribute("y1", String(y(v))); + line.setAttribute("y2", String(y(v))); return line; }; const limits: [SVGLineElement, SVGLineElement] = [mkLine("pp-limit", hi), mkLine("pp-limit", lo)]; @@ -124,7 +139,7 @@ export function pulseplot(idleHint = "No pulse data yet."): PulsePlot { el.append(panel); return { el: panel, svg, step, band, limits, zero, bounds: m.bounds[i] }; }); - el.append(axis); // shared time axis, bottom panel only + el.append(axis); // shared time axis, bottom panel only } function update(r: PulsePlotRecord): void { @@ -156,7 +171,8 @@ export function pulseplot(idleHint = "No pulse data yet."): PulsePlot { /** y-scale: bounds band → viewBox with PAD headroom; non-finite clamps to edge. */ function yScale(lo: number, hi: number): (v: number) => number { const pad = PAD * (hi - lo || 1); - const min = lo - pad, max = hi + pad; + const min = lo - pad, + max = hi + pad; return (v) => { const t = Number.isFinite(v) ? (v - min) / (max - min) : v > 0 ? 1 : 0; return H - Math.min(1, Math.max(0, t)) * H; diff --git a/packages/extension/media/ui/components/sparkline.ts b/packages/extension/media/ui/components/sparkline.ts index f64ed5a0..110aab90 100644 --- a/packages/extension/media/ui/components/sparkline.ts +++ b/packages/extension/media/ui/components/sparkline.ts @@ -3,9 +3,12 @@ import { defineStyle } from "../style"; -defineStyle("sparkline", ` +defineStyle( + "sparkline", + ` .sparkline { display: block; margin-top: var(--space-xs); } -`); +`, +); const SVGNS = "http://www.w3.org/2000/svg"; @@ -13,9 +16,16 @@ const SVGNS = "http://www.w3.org/2000/svg"; export function makeSparkBuffer(capacity: number) { const buf: number[] = []; return { - push(v: number) { buf.push(v); if (buf.length > capacity) buf.shift(); }, - values(): number[] { return buf.slice(); }, - reset() { buf.length = 0; }, + push(v: number) { + buf.push(v); + if (buf.length > capacity) buf.shift(); + }, + values(): number[] { + return buf.slice(); + }, + reset() { + buf.length = 0; + }, }; } @@ -27,7 +37,9 @@ export interface Sparkline { export function sparkline(capacity = 60): Sparkline { const buf = makeSparkBuffer(capacity); - const W = 120, H = 26, PAD = 2; + const W = 120, + H = 26, + PAD = 2; const svg = document.createElementNS(SVGNS, "svg") as SVGSVGElement; svg.setAttribute("viewBox", `0 0 ${W} ${H}`); svg.setAttribute("width", String(W)); @@ -43,9 +55,14 @@ export function sparkline(capacity = 60): Sparkline { // Only positive, finite objectives on a log axis; largest at top so a // converging (descending) objective reads as a descending line. const vs = buf.values().filter((v) => v > 0 && Number.isFinite(v)); - if (vs.length < 2) { poly.setAttribute("points", ""); return; } + if (vs.length < 2) { + poly.setAttribute("points", ""); + return; + } const logs = vs.map((v) => Math.log10(v)); - const lo = Math.min(...logs), hi = Math.max(...logs), span = hi - lo || 1; + const lo = Math.min(...logs), + hi = Math.max(...logs), + span = hi - lo || 1; const pts = logs.map((l, i) => { const x = PAD + (i / (logs.length - 1)) * (W - 2 * PAD); const y = PAD + (1 - (l - lo) / span) * (H - 2 * PAD); @@ -56,7 +73,13 @@ export function sparkline(capacity = 60): Sparkline { return { el: svg, - update(v: number) { buf.push(v); render(); }, - reset() { buf.reset(); render(); }, + update(v: number) { + buf.push(v); + render(); + }, + reset() { + buf.reset(); + render(); + }, }; } diff --git a/packages/extension/media/vendor/katex/katex.min.css b/packages/extension/media/vendor/katex/katex.min.css index 71298b5b..317de128 100644 --- a/packages/extension/media/vendor/katex/katex.min.css +++ b/packages/extension/media/vendor/katex/katex.min.css @@ -1 +1,1162 @@ -@font-face{font-display:block;font-family:KaTeX_AMS;font-style:normal;font-weight:400;src:url(fonts/KaTeX_AMS-Regular.woff2) format("woff2"),url(fonts/KaTeX_AMS-Regular.woff) format("woff"),url(fonts/KaTeX_AMS-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Caligraphic;font-style:normal;font-weight:700;src:url(fonts/KaTeX_Caligraphic-Bold.woff2) format("woff2"),url(fonts/KaTeX_Caligraphic-Bold.woff) format("woff"),url(fonts/KaTeX_Caligraphic-Bold.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Caligraphic;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Caligraphic-Regular.woff2) format("woff2"),url(fonts/KaTeX_Caligraphic-Regular.woff) format("woff"),url(fonts/KaTeX_Caligraphic-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Fraktur;font-style:normal;font-weight:700;src:url(fonts/KaTeX_Fraktur-Bold.woff2) format("woff2"),url(fonts/KaTeX_Fraktur-Bold.woff) format("woff"),url(fonts/KaTeX_Fraktur-Bold.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Fraktur;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Fraktur-Regular.woff2) format("woff2"),url(fonts/KaTeX_Fraktur-Regular.woff) format("woff"),url(fonts/KaTeX_Fraktur-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Main;font-style:normal;font-weight:700;src:url(fonts/KaTeX_Main-Bold.woff2) format("woff2"),url(fonts/KaTeX_Main-Bold.woff) format("woff"),url(fonts/KaTeX_Main-Bold.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Main;font-style:italic;font-weight:700;src:url(fonts/KaTeX_Main-BoldItalic.woff2) format("woff2"),url(fonts/KaTeX_Main-BoldItalic.woff) format("woff"),url(fonts/KaTeX_Main-BoldItalic.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Main;font-style:italic;font-weight:400;src:url(fonts/KaTeX_Main-Italic.woff2) format("woff2"),url(fonts/KaTeX_Main-Italic.woff) format("woff"),url(fonts/KaTeX_Main-Italic.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Main;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Main-Regular.woff2) format("woff2"),url(fonts/KaTeX_Main-Regular.woff) format("woff"),url(fonts/KaTeX_Main-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Math;font-style:italic;font-weight:700;src:url(fonts/KaTeX_Math-BoldItalic.woff2) format("woff2"),url(fonts/KaTeX_Math-BoldItalic.woff) format("woff"),url(fonts/KaTeX_Math-BoldItalic.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Math;font-style:italic;font-weight:400;src:url(fonts/KaTeX_Math-Italic.woff2) format("woff2"),url(fonts/KaTeX_Math-Italic.woff) format("woff"),url(fonts/KaTeX_Math-Italic.ttf) format("truetype")}@font-face{font-display:block;font-family:"KaTeX_SansSerif";font-style:normal;font-weight:700;src:url(fonts/KaTeX_SansSerif-Bold.woff2) format("woff2"),url(fonts/KaTeX_SansSerif-Bold.woff) format("woff"),url(fonts/KaTeX_SansSerif-Bold.ttf) format("truetype")}@font-face{font-display:block;font-family:"KaTeX_SansSerif";font-style:italic;font-weight:400;src:url(fonts/KaTeX_SansSerif-Italic.woff2) format("woff2"),url(fonts/KaTeX_SansSerif-Italic.woff) format("woff"),url(fonts/KaTeX_SansSerif-Italic.ttf) format("truetype")}@font-face{font-display:block;font-family:"KaTeX_SansSerif";font-style:normal;font-weight:400;src:url(fonts/KaTeX_SansSerif-Regular.woff2) format("woff2"),url(fonts/KaTeX_SansSerif-Regular.woff) format("woff"),url(fonts/KaTeX_SansSerif-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Script;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Script-Regular.woff2) format("woff2"),url(fonts/KaTeX_Script-Regular.woff) format("woff"),url(fonts/KaTeX_Script-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Size1;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size1-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size1-Regular.woff) format("woff"),url(fonts/KaTeX_Size1-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Size2;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size2-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size2-Regular.woff) format("woff"),url(fonts/KaTeX_Size2-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Size3;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size3-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size3-Regular.woff) format("woff"),url(fonts/KaTeX_Size3-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Size4;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size4-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size4-Regular.woff) format("woff"),url(fonts/KaTeX_Size4-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Typewriter;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Typewriter-Regular.woff2) format("woff2"),url(fonts/KaTeX_Typewriter-Regular.woff) format("woff"),url(fonts/KaTeX_Typewriter-Regular.ttf) format("truetype")}.katex{font:normal 1.21em KaTeX_Main,Times New Roman,serif;line-height:1.2;position:relative;text-indent:0;text-rendering:auto}.katex *{-ms-high-contrast-adjust:none!important;border-color:currentColor}.katex .katex-version:after{content:"0.17.0"}.katex .katex-mathml{border:0;-webkit-clip-path:inset(50%);clip-path:inset(50%);height:1px;overflow:hidden;padding:0;position:absolute;width:1px}.katex .katex-html>.newline{display:block}.katex .base{position:relative;white-space:nowrap;width:-webkit-min-content;width:-moz-min-content;width:min-content}.katex .base,.katex .strut{display:inline-block}.katex .textbf{font-weight:700}.katex .textit{font-style:italic}.katex .textrm{font-family:KaTeX_Main}.katex .textsf{font-family:KaTeX_SansSerif}.katex .texttt{font-family:KaTeX_Typewriter}.katex .mathnormal{font-family:KaTeX_Math;font-style:italic}.katex .mathit{font-family:KaTeX_Main;font-style:italic}.katex .mathrm{font-style:normal}.katex .mathbf{font-family:KaTeX_Main;font-weight:700}.katex .boldsymbol{font-family:KaTeX_Math;font-style:italic;font-weight:700}.katex .amsrm,.katex .mathbb,.katex .textbb{font-family:KaTeX_AMS}.katex .mathcal{font-family:KaTeX_Caligraphic}.katex .mathfrak,.katex .textfrak{font-family:KaTeX_Fraktur}.katex .mathboldfrak,.katex .textboldfrak{font-family:KaTeX_Fraktur;font-weight:700}.katex .mathtt{font-family:KaTeX_Typewriter}.katex .mathscr,.katex .textscr{font-family:KaTeX_Script}.katex .mathsf,.katex .textsf{font-family:KaTeX_SansSerif}.katex .mathboldsf,.katex .textboldsf{font-family:KaTeX_SansSerif;font-weight:700}.katex .mathitsf,.katex .mathsfit,.katex .textitsf{font-family:KaTeX_SansSerif;font-style:italic}.katex .mainrm{font-family:KaTeX_Main;font-style:normal}.katex .vlist-t{border-collapse:collapse;display:inline-table;table-layout:fixed}.katex .vlist-r{display:table-row}.katex .vlist{display:table-cell;position:relative;vertical-align:bottom}.katex .vlist>span{display:block;height:0;position:relative}.katex .vlist>span>span{display:inline-block}.katex .vlist>span>.pstrut{overflow:hidden;width:0}.katex .vlist-t2{margin-right:-2px}.katex .vlist-s{display:table-cell;font-size:1px;min-width:2px;vertical-align:bottom;width:2px}.katex .vbox{align-items:baseline;display:inline-flex;flex-direction:column}.katex .hbox{width:100%}.katex .hbox,.katex .thinbox{display:inline-flex;flex-direction:row}.katex .thinbox{max-width:0;width:0}.katex .msupsub{text-align:left}.katex .mfrac>span>span{text-align:center}.katex .mfrac .frac-line{border-bottom-style:solid;display:inline-block;width:100%}.katex .hdashline,.katex .hline,.katex .mfrac .frac-line,.katex .overline .overline-line,.katex .rule,.katex .underline .underline-line{min-height:1px}.katex .mspace{display:inline-block}.katex .smash{display:inline;line-height:0}.katex .clap,.katex .llap,.katex .rlap{position:relative;width:0}.katex .clap>.inner,.katex .llap>.inner,.katex .rlap>.inner{position:absolute}.katex .clap>.fix,.katex .llap>.fix,.katex .rlap>.fix{display:inline-block}.katex .llap>.inner{right:0}.katex .clap>.inner,.katex .rlap>.inner{left:0}.katex .clap>.inner>span{margin-left:-50%;margin-right:50%}.katex .rule{border:0 solid;display:inline-block;position:relative}.katex .hline,.katex .overline .overline-line,.katex .underline .underline-line{border-bottom-style:solid;display:inline-block;width:100%}.katex .hdashline{border-bottom-style:dashed;display:inline-block;width:100%}.katex .sqrt>.root{margin-left:.2777777778em;margin-right:-.5555555556em}.katex .fontsize-ensurer.reset-size1.size1,.katex .sizing.reset-size1.size1{font-size:1em}.katex .fontsize-ensurer.reset-size1.size2,.katex .sizing.reset-size1.size2{font-size:1.2em}.katex .fontsize-ensurer.reset-size1.size3,.katex .sizing.reset-size1.size3{font-size:1.4em}.katex .fontsize-ensurer.reset-size1.size4,.katex .sizing.reset-size1.size4{font-size:1.6em}.katex .fontsize-ensurer.reset-size1.size5,.katex .sizing.reset-size1.size5{font-size:1.8em}.katex .fontsize-ensurer.reset-size1.size6,.katex .sizing.reset-size1.size6{font-size:2em}.katex .fontsize-ensurer.reset-size1.size7,.katex .sizing.reset-size1.size7{font-size:2.4em}.katex .fontsize-ensurer.reset-size1.size8,.katex .sizing.reset-size1.size8{font-size:2.88em}.katex .fontsize-ensurer.reset-size1.size9,.katex .sizing.reset-size1.size9{font-size:3.456em}.katex .fontsize-ensurer.reset-size1.size10,.katex .sizing.reset-size1.size10{font-size:4.148em}.katex .fontsize-ensurer.reset-size1.size11,.katex .sizing.reset-size1.size11{font-size:4.976em}.katex .fontsize-ensurer.reset-size2.size1,.katex .sizing.reset-size2.size1{font-size:.8333333333em}.katex .fontsize-ensurer.reset-size2.size2,.katex .sizing.reset-size2.size2{font-size:1em}.katex .fontsize-ensurer.reset-size2.size3,.katex .sizing.reset-size2.size3{font-size:1.1666666667em}.katex .fontsize-ensurer.reset-size2.size4,.katex .sizing.reset-size2.size4{font-size:1.3333333333em}.katex .fontsize-ensurer.reset-size2.size5,.katex .sizing.reset-size2.size5{font-size:1.5em}.katex .fontsize-ensurer.reset-size2.size6,.katex .sizing.reset-size2.size6{font-size:1.6666666667em}.katex .fontsize-ensurer.reset-size2.size7,.katex .sizing.reset-size2.size7{font-size:2em}.katex .fontsize-ensurer.reset-size2.size8,.katex .sizing.reset-size2.size8{font-size:2.4em}.katex .fontsize-ensurer.reset-size2.size9,.katex .sizing.reset-size2.size9{font-size:2.88em}.katex .fontsize-ensurer.reset-size2.size10,.katex .sizing.reset-size2.size10{font-size:3.4566666667em}.katex .fontsize-ensurer.reset-size2.size11,.katex .sizing.reset-size2.size11{font-size:4.1466666667em}.katex .fontsize-ensurer.reset-size3.size1,.katex .sizing.reset-size3.size1{font-size:.7142857143em}.katex .fontsize-ensurer.reset-size3.size2,.katex .sizing.reset-size3.size2{font-size:.8571428571em}.katex .fontsize-ensurer.reset-size3.size3,.katex .sizing.reset-size3.size3{font-size:1em}.katex .fontsize-ensurer.reset-size3.size4,.katex .sizing.reset-size3.size4{font-size:1.1428571429em}.katex .fontsize-ensurer.reset-size3.size5,.katex .sizing.reset-size3.size5{font-size:1.2857142857em}.katex .fontsize-ensurer.reset-size3.size6,.katex .sizing.reset-size3.size6{font-size:1.4285714286em}.katex .fontsize-ensurer.reset-size3.size7,.katex .sizing.reset-size3.size7{font-size:1.7142857143em}.katex .fontsize-ensurer.reset-size3.size8,.katex .sizing.reset-size3.size8{font-size:2.0571428571em}.katex .fontsize-ensurer.reset-size3.size9,.katex .sizing.reset-size3.size9{font-size:2.4685714286em}.katex .fontsize-ensurer.reset-size3.size10,.katex .sizing.reset-size3.size10{font-size:2.9628571429em}.katex .fontsize-ensurer.reset-size3.size11,.katex .sizing.reset-size3.size11{font-size:3.5542857143em}.katex .fontsize-ensurer.reset-size4.size1,.katex .sizing.reset-size4.size1{font-size:.625em}.katex .fontsize-ensurer.reset-size4.size2,.katex .sizing.reset-size4.size2{font-size:.75em}.katex .fontsize-ensurer.reset-size4.size3,.katex .sizing.reset-size4.size3{font-size:.875em}.katex .fontsize-ensurer.reset-size4.size4,.katex .sizing.reset-size4.size4{font-size:1em}.katex .fontsize-ensurer.reset-size4.size5,.katex .sizing.reset-size4.size5{font-size:1.125em}.katex .fontsize-ensurer.reset-size4.size6,.katex .sizing.reset-size4.size6{font-size:1.25em}.katex .fontsize-ensurer.reset-size4.size7,.katex .sizing.reset-size4.size7{font-size:1.5em}.katex .fontsize-ensurer.reset-size4.size8,.katex .sizing.reset-size4.size8{font-size:1.8em}.katex .fontsize-ensurer.reset-size4.size9,.katex .sizing.reset-size4.size9{font-size:2.16em}.katex .fontsize-ensurer.reset-size4.size10,.katex .sizing.reset-size4.size10{font-size:2.5925em}.katex .fontsize-ensurer.reset-size4.size11,.katex .sizing.reset-size4.size11{font-size:3.11em}.katex .fontsize-ensurer.reset-size5.size1,.katex .sizing.reset-size5.size1{font-size:.5555555556em}.katex .fontsize-ensurer.reset-size5.size2,.katex .sizing.reset-size5.size2{font-size:.6666666667em}.katex .fontsize-ensurer.reset-size5.size3,.katex .sizing.reset-size5.size3{font-size:.7777777778em}.katex .fontsize-ensurer.reset-size5.size4,.katex .sizing.reset-size5.size4{font-size:.8888888889em}.katex .fontsize-ensurer.reset-size5.size5,.katex .sizing.reset-size5.size5{font-size:1em}.katex .fontsize-ensurer.reset-size5.size6,.katex .sizing.reset-size5.size6{font-size:1.1111111111em}.katex .fontsize-ensurer.reset-size5.size7,.katex .sizing.reset-size5.size7{font-size:1.3333333333em}.katex .fontsize-ensurer.reset-size5.size8,.katex .sizing.reset-size5.size8{font-size:1.6em}.katex .fontsize-ensurer.reset-size5.size9,.katex .sizing.reset-size5.size9{font-size:1.92em}.katex .fontsize-ensurer.reset-size5.size10,.katex .sizing.reset-size5.size10{font-size:2.3044444444em}.katex .fontsize-ensurer.reset-size5.size11,.katex .sizing.reset-size5.size11{font-size:2.7644444444em}.katex .fontsize-ensurer.reset-size6.size1,.katex .sizing.reset-size6.size1{font-size:.5em}.katex .fontsize-ensurer.reset-size6.size2,.katex .sizing.reset-size6.size2{font-size:.6em}.katex .fontsize-ensurer.reset-size6.size3,.katex .sizing.reset-size6.size3{font-size:.7em}.katex .fontsize-ensurer.reset-size6.size4,.katex .sizing.reset-size6.size4{font-size:.8em}.katex .fontsize-ensurer.reset-size6.size5,.katex .sizing.reset-size6.size5{font-size:.9em}.katex .fontsize-ensurer.reset-size6.size6,.katex .sizing.reset-size6.size6{font-size:1em}.katex .fontsize-ensurer.reset-size6.size7,.katex .sizing.reset-size6.size7{font-size:1.2em}.katex .fontsize-ensurer.reset-size6.size8,.katex .sizing.reset-size6.size8{font-size:1.44em}.katex .fontsize-ensurer.reset-size6.size9,.katex .sizing.reset-size6.size9{font-size:1.728em}.katex .fontsize-ensurer.reset-size6.size10,.katex .sizing.reset-size6.size10{font-size:2.074em}.katex .fontsize-ensurer.reset-size6.size11,.katex .sizing.reset-size6.size11{font-size:2.488em}.katex .fontsize-ensurer.reset-size7.size1,.katex .sizing.reset-size7.size1{font-size:.4166666667em}.katex .fontsize-ensurer.reset-size7.size2,.katex .sizing.reset-size7.size2{font-size:.5em}.katex .fontsize-ensurer.reset-size7.size3,.katex .sizing.reset-size7.size3{font-size:.5833333333em}.katex .fontsize-ensurer.reset-size7.size4,.katex .sizing.reset-size7.size4{font-size:.6666666667em}.katex .fontsize-ensurer.reset-size7.size5,.katex .sizing.reset-size7.size5{font-size:.75em}.katex .fontsize-ensurer.reset-size7.size6,.katex .sizing.reset-size7.size6{font-size:.8333333333em}.katex .fontsize-ensurer.reset-size7.size7,.katex .sizing.reset-size7.size7{font-size:1em}.katex .fontsize-ensurer.reset-size7.size8,.katex .sizing.reset-size7.size8{font-size:1.2em}.katex .fontsize-ensurer.reset-size7.size9,.katex .sizing.reset-size7.size9{font-size:1.44em}.katex .fontsize-ensurer.reset-size7.size10,.katex .sizing.reset-size7.size10{font-size:1.7283333333em}.katex .fontsize-ensurer.reset-size7.size11,.katex .sizing.reset-size7.size11{font-size:2.0733333333em}.katex .fontsize-ensurer.reset-size8.size1,.katex .sizing.reset-size8.size1{font-size:.3472222222em}.katex .fontsize-ensurer.reset-size8.size2,.katex .sizing.reset-size8.size2{font-size:.4166666667em}.katex .fontsize-ensurer.reset-size8.size3,.katex .sizing.reset-size8.size3{font-size:.4861111111em}.katex .fontsize-ensurer.reset-size8.size4,.katex .sizing.reset-size8.size4{font-size:.5555555556em}.katex .fontsize-ensurer.reset-size8.size5,.katex .sizing.reset-size8.size5{font-size:.625em}.katex .fontsize-ensurer.reset-size8.size6,.katex .sizing.reset-size8.size6{font-size:.6944444444em}.katex .fontsize-ensurer.reset-size8.size7,.katex .sizing.reset-size8.size7{font-size:.8333333333em}.katex .fontsize-ensurer.reset-size8.size8,.katex .sizing.reset-size8.size8{font-size:1em}.katex .fontsize-ensurer.reset-size8.size9,.katex .sizing.reset-size8.size9{font-size:1.2em}.katex .fontsize-ensurer.reset-size8.size10,.katex .sizing.reset-size8.size10{font-size:1.4402777778em}.katex .fontsize-ensurer.reset-size8.size11,.katex .sizing.reset-size8.size11{font-size:1.7277777778em}.katex .fontsize-ensurer.reset-size9.size1,.katex .sizing.reset-size9.size1{font-size:.2893518519em}.katex .fontsize-ensurer.reset-size9.size2,.katex .sizing.reset-size9.size2{font-size:.3472222222em}.katex .fontsize-ensurer.reset-size9.size3,.katex .sizing.reset-size9.size3{font-size:.4050925926em}.katex .fontsize-ensurer.reset-size9.size4,.katex .sizing.reset-size9.size4{font-size:.462962963em}.katex .fontsize-ensurer.reset-size9.size5,.katex .sizing.reset-size9.size5{font-size:.5208333333em}.katex .fontsize-ensurer.reset-size9.size6,.katex .sizing.reset-size9.size6{font-size:.5787037037em}.katex .fontsize-ensurer.reset-size9.size7,.katex .sizing.reset-size9.size7{font-size:.6944444444em}.katex .fontsize-ensurer.reset-size9.size8,.katex .sizing.reset-size9.size8{font-size:.8333333333em}.katex .fontsize-ensurer.reset-size9.size9,.katex .sizing.reset-size9.size9{font-size:1em}.katex .fontsize-ensurer.reset-size9.size10,.katex .sizing.reset-size9.size10{font-size:1.2002314815em}.katex .fontsize-ensurer.reset-size9.size11,.katex .sizing.reset-size9.size11{font-size:1.4398148148em}.katex .fontsize-ensurer.reset-size10.size1,.katex .sizing.reset-size10.size1{font-size:.2410800386em}.katex .fontsize-ensurer.reset-size10.size2,.katex .sizing.reset-size10.size2{font-size:.2892960463em}.katex .fontsize-ensurer.reset-size10.size3,.katex .sizing.reset-size10.size3{font-size:.337512054em}.katex .fontsize-ensurer.reset-size10.size4,.katex .sizing.reset-size10.size4{font-size:.3857280617em}.katex .fontsize-ensurer.reset-size10.size5,.katex .sizing.reset-size10.size5{font-size:.4339440694em}.katex .fontsize-ensurer.reset-size10.size6,.katex .sizing.reset-size10.size6{font-size:.4821600771em}.katex .fontsize-ensurer.reset-size10.size7,.katex .sizing.reset-size10.size7{font-size:.5785920926em}.katex .fontsize-ensurer.reset-size10.size8,.katex .sizing.reset-size10.size8{font-size:.6943105111em}.katex .fontsize-ensurer.reset-size10.size9,.katex .sizing.reset-size10.size9{font-size:.8331726133em}.katex .fontsize-ensurer.reset-size10.size10,.katex .sizing.reset-size10.size10{font-size:1em}.katex .fontsize-ensurer.reset-size10.size11,.katex .sizing.reset-size10.size11{font-size:1.1996142719em}.katex .fontsize-ensurer.reset-size11.size1,.katex .sizing.reset-size11.size1{font-size:.2009646302em}.katex .fontsize-ensurer.reset-size11.size2,.katex .sizing.reset-size11.size2{font-size:.2411575563em}.katex .fontsize-ensurer.reset-size11.size3,.katex .sizing.reset-size11.size3{font-size:.2813504823em}.katex .fontsize-ensurer.reset-size11.size4,.katex .sizing.reset-size11.size4{font-size:.3215434084em}.katex .fontsize-ensurer.reset-size11.size5,.katex .sizing.reset-size11.size5{font-size:.3617363344em}.katex .fontsize-ensurer.reset-size11.size6,.katex .sizing.reset-size11.size6{font-size:.4019292605em}.katex .fontsize-ensurer.reset-size11.size7,.katex .sizing.reset-size11.size7{font-size:.4823151125em}.katex .fontsize-ensurer.reset-size11.size8,.katex .sizing.reset-size11.size8{font-size:.578778135em}.katex .fontsize-ensurer.reset-size11.size9,.katex .sizing.reset-size11.size9{font-size:.6945337621em}.katex .fontsize-ensurer.reset-size11.size10,.katex .sizing.reset-size11.size10{font-size:.8336012862em}.katex .fontsize-ensurer.reset-size11.size11,.katex .sizing.reset-size11.size11{font-size:1em}.katex .delimsizing.size1{font-family:KaTeX_Size1}.katex .delimsizing.size2{font-family:KaTeX_Size2}.katex .delimsizing.size3{font-family:KaTeX_Size3}.katex .delimsizing.size4{font-family:KaTeX_Size4}.katex .delimsizing.mult .delim-size1>span{font-family:KaTeX_Size1}.katex .delimsizing.mult .delim-size4>span{font-family:KaTeX_Size4}.katex .nulldelimiter{display:inline-block;width:.12em}.katex .delimcenter,.katex .op-symbol{position:relative}.katex .op-symbol.small-op{font-family:KaTeX_Size1}.katex .op-symbol.large-op{font-family:KaTeX_Size2}.katex .accent>.vlist-t,.katex .op-limits>.vlist-t{text-align:center}.katex .accent .accent-body{position:relative}.katex .accent .accent-body:not(.accent-full){width:0}.katex .overlay{display:block}.katex .mtable .vertical-separator{display:inline-block;min-width:1px}.katex .mtable .arraycolsep{display:inline-block}.katex .mtable .col-align-c>.vlist-t{text-align:center}.katex .mtable .col-align-l>.vlist-t{text-align:left}.katex .mtable .col-align-r>.vlist-t{text-align:right}.katex .svg-align{text-align:left}.katex svg{fill:currentColor;stroke:currentColor;display:block;height:inherit;position:absolute;width:100%}.katex svg path{stroke:none}.katex svg{fill-rule:nonzero;fill-opacity:1;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1}.katex img{border-style:none;max-height:none;max-width:none;min-height:0;min-width:0}.katex .stretchy{display:block;overflow:hidden;position:relative;width:100%}.katex .stretchy:after,.katex .stretchy:before{content:""}.katex .hide-tail{overflow:hidden;position:relative;width:100%}.katex .halfarrow-left{left:0;overflow:hidden;position:absolute;width:50.2%}.katex .halfarrow-right{overflow:hidden;position:absolute;right:0;width:50.2%}.katex .brace-left{left:0;overflow:hidden;position:absolute;width:25.1%}.katex .brace-center{left:25%;overflow:hidden;position:absolute;width:50%}.katex .brace-right{overflow:hidden;position:absolute;right:0;width:25.1%}.katex .x-arrow-pad{padding:0 .5em}.katex .cd-arrow-pad{padding:0 .55556em 0 .27778em}.katex .mover,.katex .munder,.katex .x-arrow{text-align:center}.katex .boxpad{padding:0 .3em}.katex .fbox,.katex .fcolorbox{border:.04em solid;box-sizing:border-box}.katex .cancel-pad{padding:0 .2em}.katex .cancel-lap{margin-left:-.2em;margin-right:-.2em}.katex .sout{border-bottom-style:solid;border-bottom-width:.08em}.katex .angl{border-right:.049em solid;border-top:.049em solid;box-sizing:border-box;margin-right:.03889em}.katex .anglpad{padding:0 .03889em}.katex .eqn-num:before{content:"(" counter(katexEqnNo) ")";counter-increment:katexEqnNo}.katex .mml-eqn-num:before{content:"(" counter(mmlEqnNo) ")";counter-increment:mmlEqnNo}.katex .mtr-glue{width:50%}.katex .cd-vert-arrow{display:inline-block;position:relative}.katex .cd-label-left{display:inline-block;position:absolute;right:calc(50% + .3em);text-align:left}.katex .cd-label-right{display:inline-block;left:calc(50% + .3em);position:absolute;text-align:right}.katex-display{display:block;margin:1em 0;text-align:center}.katex-display>.katex{display:block;text-align:center;white-space:nowrap}.katex-display>.katex>.katex-html{display:block;position:relative}.katex-display>.katex>.katex-html>.tag{position:absolute;right:0}.katex-display.leqno>.katex>.katex-html>.tag{left:0;right:auto}.katex-display.fleqn>.katex{padding-left:2em;text-align:left}body{counter-reset:katexEqnNo mmlEqnNo} +@font-face { + font-display: block; + font-family: KaTeX_AMS; + font-style: normal; + font-weight: 400; + src: + url(fonts/KaTeX_AMS-Regular.woff2) format("woff2"), + url(fonts/KaTeX_AMS-Regular.woff) format("woff"), + url(fonts/KaTeX_AMS-Regular.ttf) format("truetype"); +} +@font-face { + font-display: block; + font-family: KaTeX_Caligraphic; + font-style: normal; + font-weight: 700; + src: + url(fonts/KaTeX_Caligraphic-Bold.woff2) format("woff2"), + url(fonts/KaTeX_Caligraphic-Bold.woff) format("woff"), + url(fonts/KaTeX_Caligraphic-Bold.ttf) format("truetype"); +} +@font-face { + font-display: block; + font-family: KaTeX_Caligraphic; + font-style: normal; + font-weight: 400; + src: + url(fonts/KaTeX_Caligraphic-Regular.woff2) format("woff2"), + url(fonts/KaTeX_Caligraphic-Regular.woff) format("woff"), + url(fonts/KaTeX_Caligraphic-Regular.ttf) format("truetype"); +} +@font-face { + font-display: block; + font-family: KaTeX_Fraktur; + font-style: normal; + font-weight: 700; + src: + url(fonts/KaTeX_Fraktur-Bold.woff2) format("woff2"), + url(fonts/KaTeX_Fraktur-Bold.woff) format("woff"), + url(fonts/KaTeX_Fraktur-Bold.ttf) format("truetype"); +} +@font-face { + font-display: block; + font-family: KaTeX_Fraktur; + font-style: normal; + font-weight: 400; + src: + url(fonts/KaTeX_Fraktur-Regular.woff2) format("woff2"), + url(fonts/KaTeX_Fraktur-Regular.woff) format("woff"), + url(fonts/KaTeX_Fraktur-Regular.ttf) format("truetype"); +} +@font-face { + font-display: block; + font-family: KaTeX_Main; + font-style: normal; + font-weight: 700; + src: + url(fonts/KaTeX_Main-Bold.woff2) format("woff2"), + url(fonts/KaTeX_Main-Bold.woff) format("woff"), + url(fonts/KaTeX_Main-Bold.ttf) format("truetype"); +} +@font-face { + font-display: block; + font-family: KaTeX_Main; + font-style: italic; + font-weight: 700; + src: + url(fonts/KaTeX_Main-BoldItalic.woff2) format("woff2"), + url(fonts/KaTeX_Main-BoldItalic.woff) format("woff"), + url(fonts/KaTeX_Main-BoldItalic.ttf) format("truetype"); +} +@font-face { + font-display: block; + font-family: KaTeX_Main; + font-style: italic; + font-weight: 400; + src: + url(fonts/KaTeX_Main-Italic.woff2) format("woff2"), + url(fonts/KaTeX_Main-Italic.woff) format("woff"), + url(fonts/KaTeX_Main-Italic.ttf) format("truetype"); +} +@font-face { + font-display: block; + font-family: KaTeX_Main; + font-style: normal; + font-weight: 400; + src: + url(fonts/KaTeX_Main-Regular.woff2) format("woff2"), + url(fonts/KaTeX_Main-Regular.woff) format("woff"), + url(fonts/KaTeX_Main-Regular.ttf) format("truetype"); +} +@font-face { + font-display: block; + font-family: KaTeX_Math; + font-style: italic; + font-weight: 700; + src: + url(fonts/KaTeX_Math-BoldItalic.woff2) format("woff2"), + url(fonts/KaTeX_Math-BoldItalic.woff) format("woff"), + url(fonts/KaTeX_Math-BoldItalic.ttf) format("truetype"); +} +@font-face { + font-display: block; + font-family: KaTeX_Math; + font-style: italic; + font-weight: 400; + src: + url(fonts/KaTeX_Math-Italic.woff2) format("woff2"), + url(fonts/KaTeX_Math-Italic.woff) format("woff"), + url(fonts/KaTeX_Math-Italic.ttf) format("truetype"); +} +@font-face { + font-display: block; + font-family: "KaTeX_SansSerif"; + font-style: normal; + font-weight: 700; + src: + url(fonts/KaTeX_SansSerif-Bold.woff2) format("woff2"), + url(fonts/KaTeX_SansSerif-Bold.woff) format("woff"), + url(fonts/KaTeX_SansSerif-Bold.ttf) format("truetype"); +} +@font-face { + font-display: block; + font-family: "KaTeX_SansSerif"; + font-style: italic; + font-weight: 400; + src: + url(fonts/KaTeX_SansSerif-Italic.woff2) format("woff2"), + url(fonts/KaTeX_SansSerif-Italic.woff) format("woff"), + url(fonts/KaTeX_SansSerif-Italic.ttf) format("truetype"); +} +@font-face { + font-display: block; + font-family: "KaTeX_SansSerif"; + font-style: normal; + font-weight: 400; + src: + url(fonts/KaTeX_SansSerif-Regular.woff2) format("woff2"), + url(fonts/KaTeX_SansSerif-Regular.woff) format("woff"), + url(fonts/KaTeX_SansSerif-Regular.ttf) format("truetype"); +} +@font-face { + font-display: block; + font-family: KaTeX_Script; + font-style: normal; + font-weight: 400; + src: + url(fonts/KaTeX_Script-Regular.woff2) format("woff2"), + url(fonts/KaTeX_Script-Regular.woff) format("woff"), + url(fonts/KaTeX_Script-Regular.ttf) format("truetype"); +} +@font-face { + font-display: block; + font-family: KaTeX_Size1; + font-style: normal; + font-weight: 400; + src: + url(fonts/KaTeX_Size1-Regular.woff2) format("woff2"), + url(fonts/KaTeX_Size1-Regular.woff) format("woff"), + url(fonts/KaTeX_Size1-Regular.ttf) format("truetype"); +} +@font-face { + font-display: block; + font-family: KaTeX_Size2; + font-style: normal; + font-weight: 400; + src: + url(fonts/KaTeX_Size2-Regular.woff2) format("woff2"), + url(fonts/KaTeX_Size2-Regular.woff) format("woff"), + url(fonts/KaTeX_Size2-Regular.ttf) format("truetype"); +} +@font-face { + font-display: block; + font-family: KaTeX_Size3; + font-style: normal; + font-weight: 400; + src: + url(fonts/KaTeX_Size3-Regular.woff2) format("woff2"), + url(fonts/KaTeX_Size3-Regular.woff) format("woff"), + url(fonts/KaTeX_Size3-Regular.ttf) format("truetype"); +} +@font-face { + font-display: block; + font-family: KaTeX_Size4; + font-style: normal; + font-weight: 400; + src: + url(fonts/KaTeX_Size4-Regular.woff2) format("woff2"), + url(fonts/KaTeX_Size4-Regular.woff) format("woff"), + url(fonts/KaTeX_Size4-Regular.ttf) format("truetype"); +} +@font-face { + font-display: block; + font-family: KaTeX_Typewriter; + font-style: normal; + font-weight: 400; + src: + url(fonts/KaTeX_Typewriter-Regular.woff2) format("woff2"), + url(fonts/KaTeX_Typewriter-Regular.woff) format("woff"), + url(fonts/KaTeX_Typewriter-Regular.ttf) format("truetype"); +} +.katex { + font: + normal 1.21em KaTeX_Main, + Times New Roman, + serif; + line-height: 1.2; + position: relative; + text-indent: 0; + text-rendering: auto; +} +.katex * { + -ms-high-contrast-adjust: none !important; + border-color: currentColor; +} +.katex .katex-version:after { + content: "0.17.0"; +} +.katex .katex-mathml { + border: 0; + -webkit-clip-path: inset(50%); + clip-path: inset(50%); + height: 1px; + overflow: hidden; + padding: 0; + position: absolute; + width: 1px; +} +.katex .katex-html > .newline { + display: block; +} +.katex .base { + position: relative; + white-space: nowrap; + width: -webkit-min-content; + width: -moz-min-content; + width: min-content; +} +.katex .base, +.katex .strut { + display: inline-block; +} +.katex .textbf { + font-weight: 700; +} +.katex .textit { + font-style: italic; +} +.katex .textrm { + font-family: KaTeX_Main; +} +.katex .textsf { + font-family: KaTeX_SansSerif; +} +.katex .texttt { + font-family: KaTeX_Typewriter; +} +.katex .mathnormal { + font-family: KaTeX_Math; + font-style: italic; +} +.katex .mathit { + font-family: KaTeX_Main; + font-style: italic; +} +.katex .mathrm { + font-style: normal; +} +.katex .mathbf { + font-family: KaTeX_Main; + font-weight: 700; +} +.katex .boldsymbol { + font-family: KaTeX_Math; + font-style: italic; + font-weight: 700; +} +.katex .amsrm, +.katex .mathbb, +.katex .textbb { + font-family: KaTeX_AMS; +} +.katex .mathcal { + font-family: KaTeX_Caligraphic; +} +.katex .mathfrak, +.katex .textfrak { + font-family: KaTeX_Fraktur; +} +.katex .mathboldfrak, +.katex .textboldfrak { + font-family: KaTeX_Fraktur; + font-weight: 700; +} +.katex .mathtt { + font-family: KaTeX_Typewriter; +} +.katex .mathscr, +.katex .textscr { + font-family: KaTeX_Script; +} +.katex .mathsf, +.katex .textsf { + font-family: KaTeX_SansSerif; +} +.katex .mathboldsf, +.katex .textboldsf { + font-family: KaTeX_SansSerif; + font-weight: 700; +} +.katex .mathitsf, +.katex .mathsfit, +.katex .textitsf { + font-family: KaTeX_SansSerif; + font-style: italic; +} +.katex .mainrm { + font-family: KaTeX_Main; + font-style: normal; +} +.katex .vlist-t { + border-collapse: collapse; + display: inline-table; + table-layout: fixed; +} +.katex .vlist-r { + display: table-row; +} +.katex .vlist { + display: table-cell; + position: relative; + vertical-align: bottom; +} +.katex .vlist > span { + display: block; + height: 0; + position: relative; +} +.katex .vlist > span > span { + display: inline-block; +} +.katex .vlist > span > .pstrut { + overflow: hidden; + width: 0; +} +.katex .vlist-t2 { + margin-right: -2px; +} +.katex .vlist-s { + display: table-cell; + font-size: 1px; + min-width: 2px; + vertical-align: bottom; + width: 2px; +} +.katex .vbox { + align-items: baseline; + display: inline-flex; + flex-direction: column; +} +.katex .hbox { + width: 100%; +} +.katex .hbox, +.katex .thinbox { + display: inline-flex; + flex-direction: row; +} +.katex .thinbox { + max-width: 0; + width: 0; +} +.katex .msupsub { + text-align: left; +} +.katex .mfrac > span > span { + text-align: center; +} +.katex .mfrac .frac-line { + border-bottom-style: solid; + display: inline-block; + width: 100%; +} +.katex .hdashline, +.katex .hline, +.katex .mfrac .frac-line, +.katex .overline .overline-line, +.katex .rule, +.katex .underline .underline-line { + min-height: 1px; +} +.katex .mspace { + display: inline-block; +} +.katex .smash { + display: inline; + line-height: 0; +} +.katex .clap, +.katex .llap, +.katex .rlap { + position: relative; + width: 0; +} +.katex .clap > .inner, +.katex .llap > .inner, +.katex .rlap > .inner { + position: absolute; +} +.katex .clap > .fix, +.katex .llap > .fix, +.katex .rlap > .fix { + display: inline-block; +} +.katex .llap > .inner { + right: 0; +} +.katex .clap > .inner, +.katex .rlap > .inner { + left: 0; +} +.katex .clap > .inner > span { + margin-left: -50%; + margin-right: 50%; +} +.katex .rule { + border: 0 solid; + display: inline-block; + position: relative; +} +.katex .hline, +.katex .overline .overline-line, +.katex .underline .underline-line { + border-bottom-style: solid; + display: inline-block; + width: 100%; +} +.katex .hdashline { + border-bottom-style: dashed; + display: inline-block; + width: 100%; +} +.katex .sqrt > .root { + margin-left: 0.2777777778em; + margin-right: -0.5555555556em; +} +.katex .fontsize-ensurer.reset-size1.size1, +.katex .sizing.reset-size1.size1 { + font-size: 1em; +} +.katex .fontsize-ensurer.reset-size1.size2, +.katex .sizing.reset-size1.size2 { + font-size: 1.2em; +} +.katex .fontsize-ensurer.reset-size1.size3, +.katex .sizing.reset-size1.size3 { + font-size: 1.4em; +} +.katex .fontsize-ensurer.reset-size1.size4, +.katex .sizing.reset-size1.size4 { + font-size: 1.6em; +} +.katex .fontsize-ensurer.reset-size1.size5, +.katex .sizing.reset-size1.size5 { + font-size: 1.8em; +} +.katex .fontsize-ensurer.reset-size1.size6, +.katex .sizing.reset-size1.size6 { + font-size: 2em; +} +.katex .fontsize-ensurer.reset-size1.size7, +.katex .sizing.reset-size1.size7 { + font-size: 2.4em; +} +.katex .fontsize-ensurer.reset-size1.size8, +.katex .sizing.reset-size1.size8 { + font-size: 2.88em; +} +.katex .fontsize-ensurer.reset-size1.size9, +.katex .sizing.reset-size1.size9 { + font-size: 3.456em; +} +.katex .fontsize-ensurer.reset-size1.size10, +.katex .sizing.reset-size1.size10 { + font-size: 4.148em; +} +.katex .fontsize-ensurer.reset-size1.size11, +.katex .sizing.reset-size1.size11 { + font-size: 4.976em; +} +.katex .fontsize-ensurer.reset-size2.size1, +.katex .sizing.reset-size2.size1 { + font-size: 0.8333333333em; +} +.katex .fontsize-ensurer.reset-size2.size2, +.katex .sizing.reset-size2.size2 { + font-size: 1em; +} +.katex .fontsize-ensurer.reset-size2.size3, +.katex .sizing.reset-size2.size3 { + font-size: 1.1666666667em; +} +.katex .fontsize-ensurer.reset-size2.size4, +.katex .sizing.reset-size2.size4 { + font-size: 1.3333333333em; +} +.katex .fontsize-ensurer.reset-size2.size5, +.katex .sizing.reset-size2.size5 { + font-size: 1.5em; +} +.katex .fontsize-ensurer.reset-size2.size6, +.katex .sizing.reset-size2.size6 { + font-size: 1.6666666667em; +} +.katex .fontsize-ensurer.reset-size2.size7, +.katex .sizing.reset-size2.size7 { + font-size: 2em; +} +.katex .fontsize-ensurer.reset-size2.size8, +.katex .sizing.reset-size2.size8 { + font-size: 2.4em; +} +.katex .fontsize-ensurer.reset-size2.size9, +.katex .sizing.reset-size2.size9 { + font-size: 2.88em; +} +.katex .fontsize-ensurer.reset-size2.size10, +.katex .sizing.reset-size2.size10 { + font-size: 3.4566666667em; +} +.katex .fontsize-ensurer.reset-size2.size11, +.katex .sizing.reset-size2.size11 { + font-size: 4.1466666667em; +} +.katex .fontsize-ensurer.reset-size3.size1, +.katex .sizing.reset-size3.size1 { + font-size: 0.7142857143em; +} +.katex .fontsize-ensurer.reset-size3.size2, +.katex .sizing.reset-size3.size2 { + font-size: 0.8571428571em; +} +.katex .fontsize-ensurer.reset-size3.size3, +.katex .sizing.reset-size3.size3 { + font-size: 1em; +} +.katex .fontsize-ensurer.reset-size3.size4, +.katex .sizing.reset-size3.size4 { + font-size: 1.1428571429em; +} +.katex .fontsize-ensurer.reset-size3.size5, +.katex .sizing.reset-size3.size5 { + font-size: 1.2857142857em; +} +.katex .fontsize-ensurer.reset-size3.size6, +.katex .sizing.reset-size3.size6 { + font-size: 1.4285714286em; +} +.katex .fontsize-ensurer.reset-size3.size7, +.katex .sizing.reset-size3.size7 { + font-size: 1.7142857143em; +} +.katex .fontsize-ensurer.reset-size3.size8, +.katex .sizing.reset-size3.size8 { + font-size: 2.0571428571em; +} +.katex .fontsize-ensurer.reset-size3.size9, +.katex .sizing.reset-size3.size9 { + font-size: 2.4685714286em; +} +.katex .fontsize-ensurer.reset-size3.size10, +.katex .sizing.reset-size3.size10 { + font-size: 2.9628571429em; +} +.katex .fontsize-ensurer.reset-size3.size11, +.katex .sizing.reset-size3.size11 { + font-size: 3.5542857143em; +} +.katex .fontsize-ensurer.reset-size4.size1, +.katex .sizing.reset-size4.size1 { + font-size: 0.625em; +} +.katex .fontsize-ensurer.reset-size4.size2, +.katex .sizing.reset-size4.size2 { + font-size: 0.75em; +} +.katex .fontsize-ensurer.reset-size4.size3, +.katex .sizing.reset-size4.size3 { + font-size: 0.875em; +} +.katex .fontsize-ensurer.reset-size4.size4, +.katex .sizing.reset-size4.size4 { + font-size: 1em; +} +.katex .fontsize-ensurer.reset-size4.size5, +.katex .sizing.reset-size4.size5 { + font-size: 1.125em; +} +.katex .fontsize-ensurer.reset-size4.size6, +.katex .sizing.reset-size4.size6 { + font-size: 1.25em; +} +.katex .fontsize-ensurer.reset-size4.size7, +.katex .sizing.reset-size4.size7 { + font-size: 1.5em; +} +.katex .fontsize-ensurer.reset-size4.size8, +.katex .sizing.reset-size4.size8 { + font-size: 1.8em; +} +.katex .fontsize-ensurer.reset-size4.size9, +.katex .sizing.reset-size4.size9 { + font-size: 2.16em; +} +.katex .fontsize-ensurer.reset-size4.size10, +.katex .sizing.reset-size4.size10 { + font-size: 2.5925em; +} +.katex .fontsize-ensurer.reset-size4.size11, +.katex .sizing.reset-size4.size11 { + font-size: 3.11em; +} +.katex .fontsize-ensurer.reset-size5.size1, +.katex .sizing.reset-size5.size1 { + font-size: 0.5555555556em; +} +.katex .fontsize-ensurer.reset-size5.size2, +.katex .sizing.reset-size5.size2 { + font-size: 0.6666666667em; +} +.katex .fontsize-ensurer.reset-size5.size3, +.katex .sizing.reset-size5.size3 { + font-size: 0.7777777778em; +} +.katex .fontsize-ensurer.reset-size5.size4, +.katex .sizing.reset-size5.size4 { + font-size: 0.8888888889em; +} +.katex .fontsize-ensurer.reset-size5.size5, +.katex .sizing.reset-size5.size5 { + font-size: 1em; +} +.katex .fontsize-ensurer.reset-size5.size6, +.katex .sizing.reset-size5.size6 { + font-size: 1.1111111111em; +} +.katex .fontsize-ensurer.reset-size5.size7, +.katex .sizing.reset-size5.size7 { + font-size: 1.3333333333em; +} +.katex .fontsize-ensurer.reset-size5.size8, +.katex .sizing.reset-size5.size8 { + font-size: 1.6em; +} +.katex .fontsize-ensurer.reset-size5.size9, +.katex .sizing.reset-size5.size9 { + font-size: 1.92em; +} +.katex .fontsize-ensurer.reset-size5.size10, +.katex .sizing.reset-size5.size10 { + font-size: 2.3044444444em; +} +.katex .fontsize-ensurer.reset-size5.size11, +.katex .sizing.reset-size5.size11 { + font-size: 2.7644444444em; +} +.katex .fontsize-ensurer.reset-size6.size1, +.katex .sizing.reset-size6.size1 { + font-size: 0.5em; +} +.katex .fontsize-ensurer.reset-size6.size2, +.katex .sizing.reset-size6.size2 { + font-size: 0.6em; +} +.katex .fontsize-ensurer.reset-size6.size3, +.katex .sizing.reset-size6.size3 { + font-size: 0.7em; +} +.katex .fontsize-ensurer.reset-size6.size4, +.katex .sizing.reset-size6.size4 { + font-size: 0.8em; +} +.katex .fontsize-ensurer.reset-size6.size5, +.katex .sizing.reset-size6.size5 { + font-size: 0.9em; +} +.katex .fontsize-ensurer.reset-size6.size6, +.katex .sizing.reset-size6.size6 { + font-size: 1em; +} +.katex .fontsize-ensurer.reset-size6.size7, +.katex .sizing.reset-size6.size7 { + font-size: 1.2em; +} +.katex .fontsize-ensurer.reset-size6.size8, +.katex .sizing.reset-size6.size8 { + font-size: 1.44em; +} +.katex .fontsize-ensurer.reset-size6.size9, +.katex .sizing.reset-size6.size9 { + font-size: 1.728em; +} +.katex .fontsize-ensurer.reset-size6.size10, +.katex .sizing.reset-size6.size10 { + font-size: 2.074em; +} +.katex .fontsize-ensurer.reset-size6.size11, +.katex .sizing.reset-size6.size11 { + font-size: 2.488em; +} +.katex .fontsize-ensurer.reset-size7.size1, +.katex .sizing.reset-size7.size1 { + font-size: 0.4166666667em; +} +.katex .fontsize-ensurer.reset-size7.size2, +.katex .sizing.reset-size7.size2 { + font-size: 0.5em; +} +.katex .fontsize-ensurer.reset-size7.size3, +.katex .sizing.reset-size7.size3 { + font-size: 0.5833333333em; +} +.katex .fontsize-ensurer.reset-size7.size4, +.katex .sizing.reset-size7.size4 { + font-size: 0.6666666667em; +} +.katex .fontsize-ensurer.reset-size7.size5, +.katex .sizing.reset-size7.size5 { + font-size: 0.75em; +} +.katex .fontsize-ensurer.reset-size7.size6, +.katex .sizing.reset-size7.size6 { + font-size: 0.8333333333em; +} +.katex .fontsize-ensurer.reset-size7.size7, +.katex .sizing.reset-size7.size7 { + font-size: 1em; +} +.katex .fontsize-ensurer.reset-size7.size8, +.katex .sizing.reset-size7.size8 { + font-size: 1.2em; +} +.katex .fontsize-ensurer.reset-size7.size9, +.katex .sizing.reset-size7.size9 { + font-size: 1.44em; +} +.katex .fontsize-ensurer.reset-size7.size10, +.katex .sizing.reset-size7.size10 { + font-size: 1.7283333333em; +} +.katex .fontsize-ensurer.reset-size7.size11, +.katex .sizing.reset-size7.size11 { + font-size: 2.0733333333em; +} +.katex .fontsize-ensurer.reset-size8.size1, +.katex .sizing.reset-size8.size1 { + font-size: 0.3472222222em; +} +.katex .fontsize-ensurer.reset-size8.size2, +.katex .sizing.reset-size8.size2 { + font-size: 0.4166666667em; +} +.katex .fontsize-ensurer.reset-size8.size3, +.katex .sizing.reset-size8.size3 { + font-size: 0.4861111111em; +} +.katex .fontsize-ensurer.reset-size8.size4, +.katex .sizing.reset-size8.size4 { + font-size: 0.5555555556em; +} +.katex .fontsize-ensurer.reset-size8.size5, +.katex .sizing.reset-size8.size5 { + font-size: 0.625em; +} +.katex .fontsize-ensurer.reset-size8.size6, +.katex .sizing.reset-size8.size6 { + font-size: 0.6944444444em; +} +.katex .fontsize-ensurer.reset-size8.size7, +.katex .sizing.reset-size8.size7 { + font-size: 0.8333333333em; +} +.katex .fontsize-ensurer.reset-size8.size8, +.katex .sizing.reset-size8.size8 { + font-size: 1em; +} +.katex .fontsize-ensurer.reset-size8.size9, +.katex .sizing.reset-size8.size9 { + font-size: 1.2em; +} +.katex .fontsize-ensurer.reset-size8.size10, +.katex .sizing.reset-size8.size10 { + font-size: 1.4402777778em; +} +.katex .fontsize-ensurer.reset-size8.size11, +.katex .sizing.reset-size8.size11 { + font-size: 1.7277777778em; +} +.katex .fontsize-ensurer.reset-size9.size1, +.katex .sizing.reset-size9.size1 { + font-size: 0.2893518519em; +} +.katex .fontsize-ensurer.reset-size9.size2, +.katex .sizing.reset-size9.size2 { + font-size: 0.3472222222em; +} +.katex .fontsize-ensurer.reset-size9.size3, +.katex .sizing.reset-size9.size3 { + font-size: 0.4050925926em; +} +.katex .fontsize-ensurer.reset-size9.size4, +.katex .sizing.reset-size9.size4 { + font-size: 0.462962963em; +} +.katex .fontsize-ensurer.reset-size9.size5, +.katex .sizing.reset-size9.size5 { + font-size: 0.5208333333em; +} +.katex .fontsize-ensurer.reset-size9.size6, +.katex .sizing.reset-size9.size6 { + font-size: 0.5787037037em; +} +.katex .fontsize-ensurer.reset-size9.size7, +.katex .sizing.reset-size9.size7 { + font-size: 0.6944444444em; +} +.katex .fontsize-ensurer.reset-size9.size8, +.katex .sizing.reset-size9.size8 { + font-size: 0.8333333333em; +} +.katex .fontsize-ensurer.reset-size9.size9, +.katex .sizing.reset-size9.size9 { + font-size: 1em; +} +.katex .fontsize-ensurer.reset-size9.size10, +.katex .sizing.reset-size9.size10 { + font-size: 1.2002314815em; +} +.katex .fontsize-ensurer.reset-size9.size11, +.katex .sizing.reset-size9.size11 { + font-size: 1.4398148148em; +} +.katex .fontsize-ensurer.reset-size10.size1, +.katex .sizing.reset-size10.size1 { + font-size: 0.2410800386em; +} +.katex .fontsize-ensurer.reset-size10.size2, +.katex .sizing.reset-size10.size2 { + font-size: 0.2892960463em; +} +.katex .fontsize-ensurer.reset-size10.size3, +.katex .sizing.reset-size10.size3 { + font-size: 0.337512054em; +} +.katex .fontsize-ensurer.reset-size10.size4, +.katex .sizing.reset-size10.size4 { + font-size: 0.3857280617em; +} +.katex .fontsize-ensurer.reset-size10.size5, +.katex .sizing.reset-size10.size5 { + font-size: 0.4339440694em; +} +.katex .fontsize-ensurer.reset-size10.size6, +.katex .sizing.reset-size10.size6 { + font-size: 0.4821600771em; +} +.katex .fontsize-ensurer.reset-size10.size7, +.katex .sizing.reset-size10.size7 { + font-size: 0.5785920926em; +} +.katex .fontsize-ensurer.reset-size10.size8, +.katex .sizing.reset-size10.size8 { + font-size: 0.6943105111em; +} +.katex .fontsize-ensurer.reset-size10.size9, +.katex .sizing.reset-size10.size9 { + font-size: 0.8331726133em; +} +.katex .fontsize-ensurer.reset-size10.size10, +.katex .sizing.reset-size10.size10 { + font-size: 1em; +} +.katex .fontsize-ensurer.reset-size10.size11, +.katex .sizing.reset-size10.size11 { + font-size: 1.1996142719em; +} +.katex .fontsize-ensurer.reset-size11.size1, +.katex .sizing.reset-size11.size1 { + font-size: 0.2009646302em; +} +.katex .fontsize-ensurer.reset-size11.size2, +.katex .sizing.reset-size11.size2 { + font-size: 0.2411575563em; +} +.katex .fontsize-ensurer.reset-size11.size3, +.katex .sizing.reset-size11.size3 { + font-size: 0.2813504823em; +} +.katex .fontsize-ensurer.reset-size11.size4, +.katex .sizing.reset-size11.size4 { + font-size: 0.3215434084em; +} +.katex .fontsize-ensurer.reset-size11.size5, +.katex .sizing.reset-size11.size5 { + font-size: 0.3617363344em; +} +.katex .fontsize-ensurer.reset-size11.size6, +.katex .sizing.reset-size11.size6 { + font-size: 0.4019292605em; +} +.katex .fontsize-ensurer.reset-size11.size7, +.katex .sizing.reset-size11.size7 { + font-size: 0.4823151125em; +} +.katex .fontsize-ensurer.reset-size11.size8, +.katex .sizing.reset-size11.size8 { + font-size: 0.578778135em; +} +.katex .fontsize-ensurer.reset-size11.size9, +.katex .sizing.reset-size11.size9 { + font-size: 0.6945337621em; +} +.katex .fontsize-ensurer.reset-size11.size10, +.katex .sizing.reset-size11.size10 { + font-size: 0.8336012862em; +} +.katex .fontsize-ensurer.reset-size11.size11, +.katex .sizing.reset-size11.size11 { + font-size: 1em; +} +.katex .delimsizing.size1 { + font-family: KaTeX_Size1; +} +.katex .delimsizing.size2 { + font-family: KaTeX_Size2; +} +.katex .delimsizing.size3 { + font-family: KaTeX_Size3; +} +.katex .delimsizing.size4 { + font-family: KaTeX_Size4; +} +.katex .delimsizing.mult .delim-size1 > span { + font-family: KaTeX_Size1; +} +.katex .delimsizing.mult .delim-size4 > span { + font-family: KaTeX_Size4; +} +.katex .nulldelimiter { + display: inline-block; + width: 0.12em; +} +.katex .delimcenter, +.katex .op-symbol { + position: relative; +} +.katex .op-symbol.small-op { + font-family: KaTeX_Size1; +} +.katex .op-symbol.large-op { + font-family: KaTeX_Size2; +} +.katex .accent > .vlist-t, +.katex .op-limits > .vlist-t { + text-align: center; +} +.katex .accent .accent-body { + position: relative; +} +.katex .accent .accent-body:not(.accent-full) { + width: 0; +} +.katex .overlay { + display: block; +} +.katex .mtable .vertical-separator { + display: inline-block; + min-width: 1px; +} +.katex .mtable .arraycolsep { + display: inline-block; +} +.katex .mtable .col-align-c > .vlist-t { + text-align: center; +} +.katex .mtable .col-align-l > .vlist-t { + text-align: left; +} +.katex .mtable .col-align-r > .vlist-t { + text-align: right; +} +.katex .svg-align { + text-align: left; +} +.katex svg { + fill: currentColor; + stroke: currentColor; + display: block; + height: inherit; + position: absolute; + width: 100%; +} +.katex svg path { + stroke: none; +} +.katex svg { + fill-rule: nonzero; + fill-opacity: 1; + stroke-width: 1; + stroke-linecap: butt; + stroke-linejoin: miter; + stroke-miterlimit: 4; + stroke-dasharray: none; + stroke-dashoffset: 0; + stroke-opacity: 1; +} +.katex img { + border-style: none; + max-height: none; + max-width: none; + min-height: 0; + min-width: 0; +} +.katex .stretchy { + display: block; + overflow: hidden; + position: relative; + width: 100%; +} +.katex .stretchy:after, +.katex .stretchy:before { + content: ""; +} +.katex .hide-tail { + overflow: hidden; + position: relative; + width: 100%; +} +.katex .halfarrow-left { + left: 0; + overflow: hidden; + position: absolute; + width: 50.2%; +} +.katex .halfarrow-right { + overflow: hidden; + position: absolute; + right: 0; + width: 50.2%; +} +.katex .brace-left { + left: 0; + overflow: hidden; + position: absolute; + width: 25.1%; +} +.katex .brace-center { + left: 25%; + overflow: hidden; + position: absolute; + width: 50%; +} +.katex .brace-right { + overflow: hidden; + position: absolute; + right: 0; + width: 25.1%; +} +.katex .x-arrow-pad { + padding: 0 0.5em; +} +.katex .cd-arrow-pad { + padding: 0 0.55556em 0 0.27778em; +} +.katex .mover, +.katex .munder, +.katex .x-arrow { + text-align: center; +} +.katex .boxpad { + padding: 0 0.3em; +} +.katex .fbox, +.katex .fcolorbox { + border: 0.04em solid; + box-sizing: border-box; +} +.katex .cancel-pad { + padding: 0 0.2em; +} +.katex .cancel-lap { + margin-left: -0.2em; + margin-right: -0.2em; +} +.katex .sout { + border-bottom-style: solid; + border-bottom-width: 0.08em; +} +.katex .angl { + border-right: 0.049em solid; + border-top: 0.049em solid; + box-sizing: border-box; + margin-right: 0.03889em; +} +.katex .anglpad { + padding: 0 0.03889em; +} +.katex .eqn-num:before { + content: "(" counter(katexEqnNo) ")"; + counter-increment: katexEqnNo; +} +.katex .mml-eqn-num:before { + content: "(" counter(mmlEqnNo) ")"; + counter-increment: mmlEqnNo; +} +.katex .mtr-glue { + width: 50%; +} +.katex .cd-vert-arrow { + display: inline-block; + position: relative; +} +.katex .cd-label-left { + display: inline-block; + position: absolute; + right: calc(50% + 0.3em); + text-align: left; +} +.katex .cd-label-right { + display: inline-block; + left: calc(50% + 0.3em); + position: absolute; + text-align: right; +} +.katex-display { + display: block; + margin: 1em 0; + text-align: center; +} +.katex-display > .katex { + display: block; + text-align: center; + white-space: nowrap; +} +.katex-display > .katex > .katex-html { + display: block; + position: relative; +} +.katex-display > .katex > .katex-html > .tag { + position: absolute; + right: 0; +} +.katex-display.leqno > .katex > .katex-html > .tag { + left: 0; + right: auto; +} +.katex-display.fleqn > .katex { + padding-left: 2em; + text-align: left; +} +body { + counter-reset: katexEqnNo mmlEqnNo; +} diff --git a/packages/extension/opencode-plugin/amicode_tools.ts b/packages/extension/opencode-plugin/amicode_tools.ts index 406b25fb..dc97916c 100644 --- a/packages/extension/opencode-plugin/amicode_tools.ts +++ b/packages/extension/opencode-plugin/amicode_tools.ts @@ -186,13 +186,11 @@ export const AmicodeTools = async (_input: unknown) => ({ items: { type: "string" }, description: "Optional one-per-option short qualifier rendered dimly under each button " + - "(e.g. \"fully supported end-to-end\"). Same length as options; omit for none.", + '(e.g. "fully supported end-to-end"). Same length as options; omit for none.', }, }, async execute(a: { question: string; options: string[]; details?: string[] | null }) { - const opts = Array.isArray(a.options) - ? a.options.filter((o) => typeof o === "string" && o.trim() !== "") - : []; + 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."; if (Array.isArray(a.details) && a.details.length > 0 && a.details.length !== opts.length) @@ -221,7 +219,8 @@ export const AmicodeTools = async (_input: unknown) => ({ }, name: { type: "string", - description: "For create/open: the problem name (or slug) to create/find. For rename/archive: the target slug.", + description: + "For create/open: the problem name (or slug) to create/find. For rename/archive: the target slug.", }, new_name: { type: ["string", "null"], @@ -381,7 +380,7 @@ export const AmicodeTools = async (_input: unknown) => ({ params: { type: ["object", "null"], additionalProperties: { type: "number" }, - description: "Extra named numeric model parameters to merge (e.g. {\"T1\": 80}); null for none.", + 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 }) { @@ -421,22 +420,22 @@ export const AmicodeTools = async (_input: unknown) => ({ args: { problem: { type: "string", - description: "Problem kind, e.g. \"gate_synthesis\", \"state_prep\", \"min_time\".", + description: 'Problem kind, e.g. "gate_synthesis", "state_prep", "min_time".', }, target: { type: "string", - description: "The target, e.g. \"X\", \"H\", \"sqrt(X)\", or a description of the unitary/state.", + 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\".", + description: 'Objective; null for the default "unitary infidelity".', }, constraints: { // Optional nullable array — see the details field above. legacyJsonSchema // strips "null" → optional singular-typed array (provider-agnostic). type: ["array", "null"], items: { type: "string" }, - description: "Constraint list; omit for the default [\"amplitude bound (drive_max)\"].", + description: 'Constraint list; omit for the default ["amplitude bound (drive_max)"].', }, }, async execute(a: { problem: string; target: string; objective?: string | null; constraints?: string[] | null }) { @@ -452,9 +451,7 @@ export const AmicodeTools = async (_input: unknown) => ({ target: a.target, objective: given(a.objective) ? a.objective : "unitary infidelity", constraints: - Array.isArray(a.constraints) && a.constraints.length > 0 - ? a.constraints - : ["amplitude bound (drive_max)"], + Array.isArray(a.constraints) && a.constraints.length > 0 ? a.constraints : ["amplitude bound (drive_max)"], }; if (existing?.solve) entity.solve = existing.solve; const problems = validateFormulation(entity); @@ -485,14 +482,17 @@ export const AmicodeTools = async (_input: unknown) => ({ T: { type: ["number", "null"], description: "Gate time T in ns; null if not applicable." }, N: { type: ["integer", "null"], description: "Number of timesteps N; null if not applicable." }, max_iter: { type: ["integer", "null"], description: "Solver max iterations; null for the default." }, - integrator: { type: ["string", "null"], description: "Integrator name (e.g. \"MagnusGL4\"); null for the default." }, + integrator: { + type: ["string", "null"], + description: 'Integrator name (e.g. "MagnusGL4"); null for the default.', + }, tier: { type: ["string", "null"], - description: "Authoring tier: \"vetted\" | \"composed\" | \"free\" (spec C); null if unknown.", + description: 'Authoring tier: "vetted" | "composed" | "free" (spec C); null if unknown.', }, note: { type: ["string", "null"], - description: "Short free-text note, e.g. \"X gate, T=10ns, N=50, defaults\"; null for none.", + description: 'Short free-text note, e.g. "X gate, T=10ns, N=50, defaults"; null for none.', }, }, async execute(a: { @@ -569,7 +569,7 @@ export const AmicodeTools = async (_input: unknown) => ({ amicode_verify: { description: "Record the free-tier re-rollout VERIFICATION outcome on the Run entity (spec C). " + - "Call this AFTER a `tier=\"free\"` solve finishes: amico-run runs the fixed re-rollout " + + 'Call this AFTER a `tier="free"` solve finishes: amico-run runs the fixed re-rollout ' + "harness and writes verification.toml; read it and pass agree + the two fidelities here. " + "Bookkeeping AFTER the fact — no stage gate (a verification record must never be lost). " + "Promotion of a free run is blocked until agree = true.", @@ -651,9 +651,10 @@ export const AmicodeTools = async (_input: unknown) => ({ } 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."; + const warn = + stub.pulse_ref || stub.run_dir + ? "" + : " Note: no pulse/run referenced yet — re-record after the solve finishes."; return ( `Hardware intent noted for "${meta.slug}" — pending your sign-off.${warn}\n\n` + `The send-to-device gate, when wired: (1) automated checks — fidelity ≥ threshold, ` + @@ -672,8 +673,7 @@ export const AmicodeTools = async (_input: unknown) => ({ args: { device_session_ref: { type: ["string", "null"], - description: - "Path to the recorded device_session.toml; null to auto-reference the recorded one if present.", + description: "Path to the recorded device_session.toml; null to auto-reference the recorded one if present.", }, note: { type: ["string", "null"], @@ -782,11 +782,20 @@ export const AmicodeTools = async (_input: unknown) => ({ param: { type: "string", description: "parameter name, e.g. N | T | levels | drive_max | warm_start" }, value: { type: ["string", "number", "boolean", "null"], description: "recommended value (propose)" }, confidence: { type: ["string", "null"], description: "high | medium | low (propose)" }, - provenance: { type: ["array", "null"], description: "[{source, ref, note}] (propose) — cite where it came from" }, + provenance: { + type: ["array", "null"], + description: "[{source, ref, note}] (propose) — cite where it came from", + }, alternatives: { type: ["array", "null"], description: "optional [{value, note}] considered (propose)" }, outcome: { type: ["string", "null"], description: "accepted | overridden (outcome)" }, - applied_value: { type: ["string", "number", "boolean", "null"], description: "the value actually applied (outcome)" }, - auto_accepted: { type: ["boolean", "null"], description: "true when Veloce (L2) auto-accepted this without asking (propose)" }, + applied_value: { + type: ["string", "number", "boolean", "null"], + description: "the value actually applied (outcome)", + }, + auto_accepted: { + type: ["boolean", "null"], + description: "true when Veloce (L2) auto-accepted this without asking (propose)", + }, }, async execute(a: { action: string; @@ -802,7 +811,8 @@ export const AmicodeTools = async (_input: unknown) => ({ }) { try { const slug = readActiveSlug(); - if (!slug) return "No active problem yet — recommendation not recorded (recommendations begin at the problem stage)."; + if (!slug) + return "No active problem yet — recommendation not recorded (recommendations begin at the problem stage)."; const key = `${a.stage ?? "?"}/${a.param ?? "?"}`; if (a.action === "outcome") { const seq = appendEvent(slug, { @@ -831,9 +841,10 @@ export const AmicodeTools = async (_input: unknown) => ({ }, source: { tool: "amicode_recommend", stage: a.stage }, }); - const prov = Array.isArray(a.provenance) && a.provenance.length - ? (a.provenance[0] as { source?: string }).source ?? "?" - : "none"; + const prov = + Array.isArray(a.provenance) && a.provenance.length + ? ((a.provenance[0] as { source?: string }).source ?? "?") + : "none"; const auto = a.auto_accepted ? " ⚡auto" : ""; return `Recommended ${a.param}=${JSON.stringify(a.value)} (${a.confidence ?? "?"}, via ${prov})${auto} [event ${seq}].`; } catch (err) { @@ -870,7 +881,9 @@ export const AmicodeTools = async (_input: unknown) => ({ const e = JSON.parse(line); if (e.entity === "veloce" && e.diff?.mode) mode = e.diff.mode; } - } catch { /* no events yet */ } + } catch { + /* no events yet */ + } return `Veloce is ${mode}.`; } const mode = a.action === "on" ? "on" : "off"; diff --git a/packages/extension/opencode-plugin/distill_queue.ts b/packages/extension/opencode-plugin/distill_queue.ts index d35831e7..d5d32f31 100644 --- a/packages/extension/opencode-plugin/distill_queue.ts +++ b/packages/extension/opencode-plugin/distill_queue.ts @@ -88,7 +88,11 @@ export function releaseLock(opsDir: string): void { /** Reclaim a stale lock (older than 15 min, dead pid) by renaming it aside — * only one reclaimer's rename succeeds — then claiming fresh. Returns true if * THIS caller now holds the lock. */ -export function reclaimIfStale(opsDir: string, pid: number, clock: { now: number; isPidAlive: (pid: number) => boolean }): boolean { +export function reclaimIfStale( + opsDir: string, + pid: number, + clock: { now: number; isPidAlive: (pid: number) => boolean }, +): boolean { let owner: { pid: number; ts: number }; try { owner = JSON.parse(fs.readFileSync(path.join(lockDir(opsDir), "owner"), "utf8")); @@ -227,7 +231,10 @@ export async function runDrainLoop( handler: (job: DistillJob) => Promise, clock: DrainClock, ): Promise { - if (!claimLock(opsDir, clock.pid) && !reclaimIfStale(opsDir, clock.pid, { now: clock.now(), isPidAlive: clock.isPidAlive })) { + if ( + !claimLock(opsDir, clock.pid) && + !reclaimIfStale(opsDir, clock.pid, { now: clock.now(), isPidAlive: clock.isPidAlive }) + ) { return false; } // We hold the lock. diff --git a/packages/extension/opencode-plugin/entities.ts b/packages/extension/opencode-plugin/entities.ts index ba581ff5..29afb950 100644 --- a/packages/extension/opencode-plugin/entities.ts +++ b/packages/extension/opencode-plugin/entities.ts @@ -377,7 +377,10 @@ export function canonicalJson(value: unknown): string { /** Kebab-case slug from a problem name; empty result → "untitled". */ export function deriveSlug(name: string): string { - const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); + const slug = name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); return slug || "untitled"; } @@ -422,8 +425,7 @@ export function truncateDiffForSentinel( diff: Record, maxBytes = 1024, ): Record { - const trunc = (v: unknown): unknown => - typeof v === "string" && v.length > 120 ? v.slice(0, 120) + "…" : v; + const trunc = (v: unknown): unknown => (typeof v === "string" && v.length > 120 ? v.slice(0, 120) + "…" : v); const out: Record = {}; for (const [k, { from, to }] of Object.entries(diff)) out[k] = { from: trunc(from), to: trunc(to) }; const keys = Object.keys(out); diff --git a/packages/extension/opencode-plugin/onboarding.ts b/packages/extension/opencode-plugin/onboarding.ts index 11c0ac47..41f75e78 100644 --- a/packages/extension/opencode-plugin/onboarding.ts +++ b/packages/extension/opencode-plugin/onboarding.ts @@ -49,7 +49,10 @@ export function sanitizePayload(entity: OnboardingEntity, payload: Record l.trim()).length; + return fs + .readFileSync(file, "utf8") + .split("\n") + .filter((l) => l.trim()).length; } catch { return 0; } diff --git a/packages/extension/opencode-plugin/problems.ts b/packages/extension/opencode-plugin/problems.ts index d3026648..cc0c087a 100644 --- a/packages/extension/opencode-plugin/problems.ts +++ b/packages/extension/opencode-plugin/problems.ts @@ -126,9 +126,7 @@ export function openProblem(query: string): ProblemMeta | undefined { return exact; } const q = query.toLowerCase().trim(); - const matches = listProblems().filter( - (m) => m.status !== "archived" && m.name.toLowerCase().includes(q), - ); + const matches = listProblems().filter((m) => m.status !== "archived" && m.name.toLowerCase().includes(q)); if (matches.length === 0) return undefined; matches.sort((a, b) => (b.recorded ?? "").localeCompare(a.recorded ?? "")); setActiveSlug(matches[0].slug); @@ -197,7 +195,10 @@ export interface EventInput { export function lastEventSeq(slug: string): number { const file = path.join(problemDir(slug), "events.jsonl"); if (!fs.existsSync(file)) return 0; - return fs.readFileSync(file, "utf8").split("\n").filter((l) => l.trim() !== "").length; + return fs + .readFileSync(file, "utf8") + .split("\n") + .filter((l) => l.trim() !== "").length; } /** Append one event to the problem's events.jsonl; returns its monotonic seq @@ -206,7 +207,11 @@ export function appendEvent(slug: string, input: EventInput): number { const file = path.join(problemDir(slug), "events.jsonl"); let seq = 1; if (fs.existsSync(file)) { - seq = fs.readFileSync(file, "utf8").split("\n").filter((l) => l.trim() !== "").length + 1; + seq = + fs + .readFileSync(file, "utf8") + .split("\n") + .filter((l) => l.trim() !== "").length + 1; } const record = { seq, diff --git a/packages/extension/opencode-plugin/score_guard.ts b/packages/extension/opencode-plugin/score_guard.ts index 396f248b..db4d5f10 100644 --- a/packages/extension/opencode-plugin/score_guard.ts +++ b/packages/extension/opencode-plugin/score_guard.ts @@ -152,7 +152,12 @@ export function guardAndRecordStage(manifestDir: string, stateDir: string, stage } if (!state) { state = freshScoreState(manifest.id, manifest.version); - appendUsage(stateDir, { kind: "session_started", ts: new Date().toISOString(), score_id: manifest.id, score_version: manifest.version }); + appendUsage(stateDir, { + 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) { diff --git a/packages/extension/opencode.lock.json b/packages/extension/opencode.lock.json index f6685932..8e581aad 100644 --- a/packages/extension/opencode.lock.json +++ b/packages/extension/opencode.lock.json @@ -3,7 +3,13 @@ "repo": "harmoniqs/opencode", "tag": "v1.17.3-amicode.1", "platforms": { - "darwin-arm64": { "asset": "opencode-darwin-arm64.zip", "sha256": "f1d6291485246e03a3d33eefb40b4d264d68c8e5812b11b4ffdfec4444ab9455" }, - "linux-x64": { "asset": "opencode-linux-x64.tar.gz", "sha256": "268596e61475dd79d3076dd65d39bf97926d56c28477419250ab3c678bd55b10" } + "darwin-arm64": { + "asset": "opencode-darwin-arm64.zip", + "sha256": "f1d6291485246e03a3d33eefb40b4d264d68c8e5812b11b4ffdfec4444ab9455" + }, + "linux-x64": { + "asset": "opencode-linux-x64.tar.gz", + "sha256": "268596e61475dd79d3076dd65d39bf97926d56c28477419250ab3c678bd55b10" + } } } diff --git a/packages/extension/scores/README.md b/packages/extension/scores/README.md index f64041ab..d0573b51 100644 --- a/packages/extension/scores/README.md +++ b/packages/extension/scores/README.md @@ -22,34 +22,35 @@ Body = prose (per-stage narration, physics, defaults rationale, off-path guidanc ```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 +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 +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 + - 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)" + 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 + 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 + 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 + gate: heavy # light|heavy — checks must pass BEFORE entering optional: true --- [Amico's voice for this score — markdown + LaTeX, carried verbatim into the prompt] diff --git a/packages/extension/scores/memory/confidence-rubric.md b/packages/extension/scores/memory/confidence-rubric.md index 9a8c6ef7..f96d57da 100644 --- a/packages/extension/scores/memory/confidence-rubric.md +++ b/packages/extension/scores/memory/confidence-rubric.md @@ -11,7 +11,7 @@ never to model judgment. ## Resolution order (pick the highest available, then score it) 1. **own-precedent** — a `## Your recent problems` card matching the full 3-tuple - `(platform, problem_kind, target)`. A match is a *candidate*; score by §high. + `(platform, problem_kind, target)`. A match is a _candidate_; score by §high. 2. **demo** — a `## Reference demos` card matching the full 3-tuple → **medium**. 3. **physics** — the platform skill's canonical value (speed limit, cutoff sizing) → **medium**. @@ -20,6 +20,7 @@ never to model judgment. ## The `high` predicate (own-precedent only, mechanical) A candidate own-precedent card scores **high** iff: + - `platform`, `problem_kind`, `target` all equal, AND - **every gating scalar for the platform** matches within tolerance (below), AND - for a **warm-start** rec additionally: the card's `pulse_ref` resolves to a @@ -30,11 +31,11 @@ pulse). A bare 3-tuple match is NEVER high on its own. ### Gating scalars + tolerances (per platform) -| Platform | Gating scalars (tolerance) | -|---|---| -| transmon | `levels` (exact), `drive_max` (±10%) | -| cavity / bosonic | `fock_cutoff` (exact), `chi` (±10%), target `alpha` or Fock index (exact) | -| atoms (Rydberg) | `levels` (exact), `rabi_max` (±10%), `delta_max` (±10%), distance/blockade (±10%) | +| Platform | Gating scalars (tolerance) | +| ---------------- | --------------------------------------------------------------------------------- | +| transmon | `levels` (exact), `drive_max` (±10%) | +| cavity / bosonic | `fock_cutoff` (exact), `chi` (±10%), target `alpha` or Fock index (exact) | +| atoms (Rydberg) | `levels` (exact), `rabi_max` (±10%), `delta_max` (±10%), distance/blockade (±10%) | **Fail-safe:** a platform NOT listed here, or a card missing any required `sys_params` field, scores **medium, never high**. Unknown regime → fail safe. diff --git a/packages/extension/scores/overture/SCORE.md b/packages/extension/scores/overture/SCORE.md index 726745e4..4e010ad6 100644 --- a/packages/extension/scores/overture/SCORE.md +++ b/packages/extension/scores/overture/SCORE.md @@ -24,7 +24,13 @@ stages: questions: - id: environment prompt: "How will pulses eventually reach hardware — what are we patching into?" - choices: ["extant QICK control code (on-prem, à la Stanford/UChicago)", "a cloud system with an emulator (à la Pasqal)", "simulation only for now", "something else"] + choices: + [ + "extant QICK control code (on-prem, à la Stanford/UChicago)", + "a cloud system with an emulator (à la Pasqal)", + "simulation only for now", + "something else", + ] default: "simulation only for now" rationale_ref: "#environments" - id: devices @@ -90,13 +96,13 @@ Per-stage guidance and the `amicode_profile` mapping: is available in-flow. - **`local-sim`** — simulation only for now (nothing to patch into yet). - **`other`** — record exactly what they say. - Record: `amicode_profile {entity:"environment", payload:{slug, archetype, - control_stack, integration, emulator, endpoints}}` — where `slug` is a short - kebab name (e.g. `stanford-qick-lab`) and **`endpoints` holds pointers only, - NEVER tokens, keys, or passwords** (Amico refuses to store secrets). -4. **devices** *(optional)* — if they name a device, record + Record: `amicode_profile {entity:"environment", payload:{slug, archetype, +control_stack, integration, emulator, endpoints}}` — where `slug` is a short + kebab name (e.g. `stanford-qick-lab`) and **`endpoints` holds pointers only, + NEVER tokens, keys, or passwords** (Amico refuses to store secrets). +4. **devices** _(optional)_ — if they name a device, record `amicode_profile {entity:"device", payload:{name, platform, environment:, - qubits, params}}`. If they skip, move on — devices can be added any time. +qubits, params}}`. If they skip, move on — devices can be added any time. 5. **goals** — record `amicode_profile {entity:"profile", payload:{goals:"..."}}` in their own words. 6. **handoff** — this is the pivot. FIRST record the completion marker: diff --git a/packages/extension/scripts/build_exemplars.mjs b/packages/extension/scripts/build_exemplars.mjs index b4b54c8f..270903d6 100644 --- a/packages/extension/scripts/build_exemplars.mjs +++ b/packages/extension/scripts/build_exemplars.mjs @@ -8,69 +8,88 @@ // baseline_hash — the SAME mask+sha the amico-run gate recomputes at launch // (deliberately reimplemented here to keep the build dep-free of amico-run; // test/exemplars_build.test.ts cross-checks the two via a shared fixture). -import { createHash } from 'node:crypto' -import { existsSync, mkdirSync, copyFileSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' -import { dirname, join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { parse as parseToml } from 'smol-toml' +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, copyFileSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parse as parseToml } from "smol-toml"; -const here = dirname(fileURLToPath(import.meta.url)) -const exemplarsDir = join(here, '..', 'exemplars') +const here = dirname(fileURLToPath(import.meta.url)); +const exemplarsDir = join(here, "..", "exemplars"); // Masked baseline: interior lines between the fill markers → "#MASKED"; marker // lines kept; unterminated block masks to EOF. MUST match amico-run/src/baseline.ts. -const DEFAULT_BEGIN = /^# ── FILL IN/ -const DEFAULT_END = /^# ─────/ +const DEFAULT_BEGIN = /^# ── FILL IN/; +const DEFAULT_END = /^# ─────/; function maskFillPoints(text, beginSrc, endSrc) { - const begin = beginSrc ? new RegExp(beginSrc) : DEFAULT_BEGIN - const end = endSrc ? new RegExp(endSrc) : DEFAULT_END - const out = [] - let inside = false - for (const line of text.split('\n')) { - if (!inside && begin.test(line)) { inside = true; out.push(line); continue } - if (inside && end.test(line)) { inside = false; out.push(line); continue } - out.push(inside ? '#MASKED' : line) + const begin = beginSrc ? new RegExp(beginSrc) : DEFAULT_BEGIN; + const end = endSrc ? new RegExp(endSrc) : DEFAULT_END; + const out = []; + let inside = false; + for (const line of text.split("\n")) { + if (!inside && begin.test(line)) { + inside = true; + out.push(line); + continue; + } + if (inside && end.test(line)) { + inside = false; + out.push(line); + continue; + } + out.push(inside ? "#MASKED" : line); } - return out.join('\n') + return out.join("\n"); } function maskedHash(text, beginSrc, endSrc) { - return 'sha256:' + createHash('sha256').update(maskFillPoints(text, beginSrc, endSrc)).digest('hex') + return ( + "sha256:" + + createHash("sha256") + .update(maskFillPoints(text, beginSrc, endSrc)) + .digest("hex") + ); } function readEntries(tomlFile) { - if (!existsSync(tomlFile)) return [] - const parsed = parseToml(readFileSync(tomlFile, 'utf8')) - return Array.isArray(parsed.exemplar) ? parsed.exemplar : [] + if (!existsSync(tomlFile)) return []; + const parsed = parseToml(readFileSync(tomlFile, "utf8")); + return Array.isArray(parsed.exemplar) ? parsed.exemplar : []; } -const exemplars = [] +const exemplars = []; // 1. in-repo entries — scripts already live under exemplars/, paths are as-authored -for (const entry of readEntries(join(exemplarsDir, 'EXEMPLARS.toml'))) { - const scriptPath = join(exemplarsDir, entry.path) - if (!existsSync(scriptPath)) { console.error(`build_exemplars: missing in-repo script ${entry.path}`); process.exit(1) } - const text = readFileSync(scriptPath, 'utf8') - exemplars.push({ ...entry, baseline_hash: maskedHash(text, entry.fill_begin, entry.fill_end) }) +for (const entry of readEntries(join(exemplarsDir, "EXEMPLARS.toml"))) { + const scriptPath = join(exemplarsDir, entry.path); + if (!existsSync(scriptPath)) { + console.error(`build_exemplars: missing in-repo script ${entry.path}`); + process.exit(1); + } + const text = readFileSync(scriptPath, "utf8"); + exemplars.push({ ...entry, baseline_hash: maskedHash(text, entry.fill_begin, entry.fill_end) }); } // 2. external demo-repo entries — copy the script in-tree, rewrite path -const demosRoot = process.env.AMICODE_DEMOS_ROOT +const demosRoot = process.env.AMICODE_DEMOS_ROOT; if (demosRoot && existsSync(demosRoot)) { for (const demo of readdirSync(demosRoot, { withFileTypes: true })) { - if (!demo.isDirectory()) continue - const tomlFile = join(demosRoot, demo.name, 'EXEMPLARS.toml') + if (!demo.isDirectory()) continue; + const tomlFile = join(demosRoot, demo.name, "EXEMPLARS.toml"); for (const entry of readEntries(tomlFile)) { - const srcScript = join(demosRoot, demo.name, entry.path) - if (!existsSync(srcScript)) { console.error(`build_exemplars: missing demo script ${demo.name}/${entry.path}`); continue } - const destRel = join(entry.id, 'script.jl') - const destAbs = join(exemplarsDir, destRel) - mkdirSync(dirname(destAbs), { recursive: true }) - copyFileSync(srcScript, destAbs) - const text = readFileSync(destAbs, 'utf8') - exemplars.push({ ...entry, path: destRel, baseline_hash: maskedHash(text, entry.fill_begin, entry.fill_end) }) + const srcScript = join(demosRoot, demo.name, entry.path); + if (!existsSync(srcScript)) { + console.error(`build_exemplars: missing demo script ${demo.name}/${entry.path}`); + continue; + } + const destRel = join(entry.id, "script.jl"); + const destAbs = join(exemplarsDir, destRel); + mkdirSync(dirname(destAbs), { recursive: true }); + copyFileSync(srcScript, destAbs); + const text = readFileSync(destAbs, "utf8"); + exemplars.push({ ...entry, path: destRel, baseline_hash: maskedHash(text, entry.fill_begin, entry.fill_end) }); } } } -writeFileSync(join(exemplarsDir, 'index.json'), JSON.stringify({ schema_version: 1, exemplars }, null, 2) + '\n') -console.log(`build_exemplars: wrote index.json (${exemplars.length} exemplar${exemplars.length === 1 ? '' : 's'})`) +writeFileSync(join(exemplarsDir, "index.json"), JSON.stringify({ schema_version: 1, exemplars }, null, 2) + "\n"); +console.log(`build_exemplars: wrote index.json (${exemplars.length} exemplar${exemplars.length === 1 ? "" : "s"})`); diff --git a/packages/extension/scripts/distill_batch.mjs b/packages/extension/scripts/distill_batch.mjs index 303ddf1d..4c871660 100644 --- a/packages/extension/scripts/distill_batch.mjs +++ b/packages/extension/scripts/distill_batch.mjs @@ -66,7 +66,8 @@ function distillerConfig() { agent: { distiller: { description: "Amico's background memory distiller (headless; no subagents)", - prompt: "You are Amico's distiller. Follow the distiller instructions exactly. Your input is one JSON job object. Work silently; never spawn subagents; finish with a one-line summary.", + prompt: + "You are Amico's distiller. Follow the distiller instructions exactly. Your input is one JSON job object. Work silently; never spawn subagents; finish with a one-line summary.", model: MODEL, }, }, @@ -115,7 +116,14 @@ function workspaceHygiene() { if (e.entity === "formulation" && e.diff?.target?.to) target = e.diff.target.to; } catch {} } - if (target && !ws.toLowerCase().includes(String(target).toLowerCase().replace(/[^a-z0-9]/g, ""))) + if ( + target && + !ws.toLowerCase().includes( + String(target) + .toLowerCase() + .replace(/[^a-z0-9]/g, ""), + ) + ) flags.push(`${ws} → recorded target "${target}"`); } return flags; @@ -157,7 +165,9 @@ console.log(`model: ${MODEL}`); console.log(`runs w/ result.toml: ${runs.length} substantive sessions: ${sessions.length}`); console.log(`workspace hygiene flags (${hygiene.length}):`); for (const f of hygiene) console.log(` ⚠ ${f}`); -console.log(`opencode server alive: ${serverAlive} → DB archive step ${serverAlive ? "SKIPPED (deferred to a no-server window)" : "eligible"}`); +console.log( + `opencode server alive: ${serverAlive} → DB archive step ${serverAlive ? "SKIPPED (deferred to a no-server window)" : "eligible"}`, +); if (has("--dry-run")) { console.log("\n[dry-run] no distills spawned."); @@ -169,7 +179,8 @@ let ok = 0, if (has("--runs-only") || has("--all")) { const sel = runs.slice(0, limit === Infinity ? runs.length : limit); console.log(`\n[runs] distilling ${sel.length} run(s):`); - for (const r of sel) (distill({ kind: "run", run_id: r, vault: VAULT, ops: OPS, runs_root: RUNS_ROOT }, r) ? ok++ : fail++); + for (const r of sel) + distill({ kind: "run", run_id: r, vault: VAULT, ops: OPS, runs_root: RUNS_ROOT }, r) ? ok++ : fail++; } if (has("--demos-ingest")) { const DEMOS = path.join(HOME, "harmoniqs", "demos"); @@ -179,12 +190,17 @@ if (has("--demos-ingest")) { const sel = dirs.slice(0, limit === Infinity ? dirs.length : limit); console.log(`\n[demos] ingesting ${sel.length} demo(s) from ${DEMOS}:`); for (const d of sel) - (distill({ kind: "demo", demo_dir: path.join(DEMOS, d), vault: VAULT, ops: OPS, runs_root: RUNS_ROOT }, d) ? ok++ : fail++); + distill({ kind: "demo", demo_dir: path.join(DEMOS, d), vault: VAULT, ops: OPS, runs_root: RUNS_ROOT }, d) + ? ok++ + : fail++; } if (has("--sweeps") || has("--all")) { const sel = sessions.slice(0, limit === Infinity ? sessions.length : limit); console.log(`\n[sweeps] distilling ${sel.length} session(s):`); - for (const s of sel) (distill({ kind: "sweep", session_ids: [s], vault: VAULT, ops: OPS, runs_root: RUNS_ROOT }, s.slice(0, 20)) ? ok++ : fail++); + for (const s of sel) + distill({ kind: "sweep", session_ids: [s], vault: VAULT, ops: OPS, runs_root: RUNS_ROOT }, s.slice(0, 20)) + ? ok++ + : fail++; } // Summary report (spec §5 step 5) — stdout + a vault notes/ file. @@ -193,7 +209,8 @@ const report = [ `# Batch retro-ingest report — ${stamp}`, ``, `- runs distilled ok: ${ok}, failed: ${fail}`, - `- substantive sessions seen: ${sessions.length}` + (has("--sweeps") || has("--all") ? "" : " (sweeps NOT run this pass)"), + `- substantive sessions seen: ${sessions.length}` + + (has("--sweeps") || has("--all") ? "" : " (sweeps NOT run this pass)"), `- workspace hygiene flags: ${hygiene.length}`, ...hygiene.map((f) => ` - ⚠ ${f}`), `- DB archive of empty sessions: ${serverAlive ? "DEFERRED (server alive) — run with server stopped to sweep empties + agent='distiller' rows" : "eligible"}`, diff --git a/packages/extension/scripts/fetch_opencode.mjs b/packages/extension/scripts/fetch_opencode.mjs index 811ab9d9..c8ac46c1 100644 --- a/packages/extension/scripts/fetch_opencode.mjs +++ b/packages/extension/scripts/fetch_opencode.mjs @@ -1,35 +1,42 @@ #!/usr/bin/env node // Download-at-build vendoring of the opencode chat-server binary, pinned by // opencode.lock.json (spec §2/§3). Importable module + CLI in one file. -import { createHash } from 'node:crypto' -import { execFileSync } from 'node:child_process' +import { createHash } from "node:crypto"; +import { execFileSync } from "node:child_process"; import { - chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, - renameSync, rmSync, writeFileSync, -} from 'node:fs' -import { dirname, join } from 'node:path' -import { fileURLToPath, pathToFileURL } from 'node:url' + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; -const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') +const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); export function loadManifest(root = PKG_ROOT) { - const m = JSON.parse(readFileSync(join(root, 'opencode.lock.json'), 'utf8')) - if (typeof m.version !== 'string' || m.version === '') throw new Error('manifest: version must be a non-empty string') - const platforms = m.platforms ?? {} - if (Object.keys(platforms).length === 0) throw new Error('manifest: platforms missing') + const m = JSON.parse(readFileSync(join(root, "opencode.lock.json"), "utf8")); + if (typeof m.version !== "string" || m.version === "") + throw new Error("manifest: version must be a non-empty string"); + const platforms = m.platforms ?? {}; + if (Object.keys(platforms).length === 0) throw new Error("manifest: platforms missing"); for (const [key, p] of Object.entries(platforms)) { - if (typeof p.asset !== 'string' || p.asset === '') throw new Error(`manifest: ${key}.asset missing`) - if (!/^[0-9a-f]{64}$/.test(p.sha256 ?? '')) throw new Error(`manifest: ${key}.sha256 must be 64 hex chars`) + if (typeof p.asset !== "string" || p.asset === "") throw new Error(`manifest: ${key}.asset missing`); + if (!/^[0-9a-f]{64}$/.test(p.sha256 ?? "")) throw new Error(`manifest: ${key}.sha256 must be 64 hex chars`); } - return m + return m; } export function resolvePlatform(manifest, flag) { - const key = flag ?? `${process.platform}-${process.arch}` + const key = flag ?? `${process.platform}-${process.arch}`; if (!(key in manifest.platforms)) { - throw new Error(`platform ${key} not supported (supported: ${Object.keys(manifest.platforms).join(', ')})`) + throw new Error(`platform ${key} not supported (supported: ${Object.keys(manifest.platforms).join(", ")})`); } - return key + return key; } /** Release coordinates: default = upstream sst/opencode at v; a manifest @@ -37,98 +44,114 @@ export function resolvePlatform(manifest, flag) { * private — downloads go through the authenticated `gh` path in that case). */ export function releaseCoords(manifest) { return { - repo: manifest.repo ?? 'sst/opencode', + repo: manifest.repo ?? "sst/opencode", tag: manifest.tag ?? `v${manifest.version}`, - private: manifest.repo != null, // our mirror is private; upstream is not - } + private: manifest.repo != null, // our mirror is private; upstream is not + }; } export function assetUrl(manifest, platform) { - const { repo, tag } = releaseCoords(manifest) - return `https://github.com/${repo}/releases/download/${tag}/${manifest.platforms[platform].asset}` + const { repo, tag } = releaseCoords(manifest); + return `https://github.com/${repo}/releases/download/${tag}/${manifest.platforms[platform].asset}`; } -export const sha256 = (buf) => createHash('sha256').update(buf).digest('hex') +export const sha256 = (buf) => createHash("sha256").update(buf).digest("hex"); async function defaultDownload(url) { - let r - try { r = await fetch(url) } catch (e) { - throw new Error(`download failed: ${e.message} for ${url}`) // spec §6: URL on connection failures too + let r; + try { + r = await fetch(url); + } catch (e) { + throw new Error(`download failed: ${e.message} for ${url}`); // spec §6: URL on connection failures too } - if (!r.ok) throw new Error(`download failed: HTTP ${r.status} for ${url}`) - return Buffer.from(await r.arrayBuffer()) + if (!r.ok) throw new Error(`download failed: HTTP ${r.status} for ${url}`); + return Buffer.from(await r.arrayBuffer()); } /** Private-release download via the gh CLI (the team's auth path for our * private repos). Plain fetch 404s on private assets — gh handles the token. */ function ghDownload(repo, tag, asset) { - const work = mkdtempSync(join(PKG_ROOT, '.ghdl-')) + const work = mkdtempSync(join(PKG_ROOT, ".ghdl-")); try { - execFileSync('gh', ['release', 'download', tag, '--repo', repo, '--pattern', asset, '--dir', work], - { stdio: ['ignore', 'ignore', 'inherit'] }) - return readFileSync(join(work, asset)) + execFileSync("gh", ["release", "download", tag, "--repo", repo, "--pattern", asset, "--dir", work], { + stdio: ["ignore", "ignore", "inherit"], + }); + return readFileSync(join(work, asset)); } catch (e) { - throw new Error(`gh release download failed for ${repo}@${tag} ${asset}: ${e.message} — is \`gh\` installed and authed for ${repo}?`) + throw new Error( + `gh release download failed for ${repo}@${tag} ${asset}: ${e.message} — is \`gh\` installed and authed for ${repo}?`, + ); } finally { - rmSync(work, { recursive: true, force: true }) + rmSync(work, { recursive: true, force: true }); } } export async function fetchOpencode({ root = PKG_ROOT, platform, download = defaultDownload } = {}) { - const manifest = loadManifest(root) - const key = resolvePlatform(manifest, platform) - const { asset, sha256: want } = manifest.platforms[key] - const destDir = join(root, 'vendor', 'opencode', key) - const bin = join(destDir, 'opencode') - const stamp = join(destDir, '.sha256') + const manifest = loadManifest(root); + const key = resolvePlatform(manifest, platform); + const { asset, sha256: want } = manifest.platforms[key]; + const destDir = join(root, "vendor", "opencode", key); + const bin = join(destDir, "opencode"); + const stamp = join(destDir, ".sha256"); - if (existsSync(bin) && existsSync(stamp) && readFileSync(stamp, 'utf8').trim() === want) { - return { skipped: true, path: bin } // offline repeat builds + if (existsSync(bin) && existsSync(stamp) && readFileSync(stamp, "utf8").trim() === want) { + return { skipped: true, path: bin }; // offline repeat builds } - const coords = releaseCoords(manifest) - const bytes = coords.private && download === defaultDownload - ? ghDownload(coords.repo, coords.tag, asset) - : await download(assetUrl(manifest, key)) - const got = sha256(bytes) + const coords = releaseCoords(manifest); + const bytes = + coords.private && download === defaultDownload + ? ghDownload(coords.repo, coords.tag, asset) + : await download(assetUrl(manifest, key)); + const got = sha256(bytes); if (got !== want) { // Possible supply-chain signal: no retry, no override (spec §3 step 4). - throw new Error(`SHA256 mismatch for ${asset}: expected ${want}, actual ${got}`) + throw new Error(`SHA256 mismatch for ${asset}: expected ${want}, actual ${got}`); } - mkdirSync(destDir, { recursive: true }) - const work = mkdtempSync(join(destDir, '.unpack-')) // same fs → rename is atomic + mkdirSync(destDir, { recursive: true }); + const work = mkdtempSync(join(destDir, ".unpack-")); // same fs → rename is atomic try { - const archive = join(work, asset) - writeFileSync(archive, bytes) - if (asset.endsWith('.zip')) execFileSync('unzip', ['-oq', archive, '-d', work]) - else execFileSync('tar', ['-xzf', archive, '-C', work]) - if (!existsSync(join(work, 'opencode'))) throw new Error(`archive ${asset} did not contain a flat 'opencode' binary`) - renameSync(join(work, 'opencode'), bin) - chmodSync(bin, 0o755) - writeFileSync(stamp, got + '\n') // stamp last (spec §3 step 5) + const archive = join(work, asset); + writeFileSync(archive, bytes); + if (asset.endsWith(".zip")) execFileSync("unzip", ["-oq", archive, "-d", work]); + else execFileSync("tar", ["-xzf", archive, "-C", work]); + if (!existsSync(join(work, "opencode"))) + throw new Error(`archive ${asset} did not contain a flat 'opencode' binary`); + renameSync(join(work, "opencode"), bin); + chmodSync(bin, 0o755); + writeFileSync(stamp, got + "\n"); // stamp last (spec §3 step 5) } finally { - rmSync(work, { recursive: true, force: true }) + rmSync(work, { recursive: true, force: true }); } - return { skipped: false, path: bin } + return { skipped: false, path: bin }; } async function main(argv) { - const flagIdx = argv.indexOf('--platform') - const platform = flagIdx >= 0 ? argv[flagIdx + 1] : undefined - if (argv.includes('--record')) { // pin-time only (spec §3 step 6) - const manifest = loadManifest() + const flagIdx = argv.indexOf("--platform"); + const platform = flagIdx >= 0 ? argv[flagIdx + 1] : undefined; + if (argv.includes("--record")) { + // pin-time only (spec §3 step 6) + const manifest = loadManifest(); for (const key of Object.keys(manifest.platforms)) { - const bytes = await defaultDownload(assetUrl(manifest, key)) - console.log(`${key} ${sha256(bytes)}`) + const bytes = await defaultDownload(assetUrl(manifest, key)); + console.log(`${key} ${sha256(bytes)}`); } - return 0 + return 0; } - const r = await fetchOpencode({ platform }) - console.log(r.skipped ? `[fetch-opencode] up to date: ${r.path}` : `[fetch-opencode] installed: ${r.path}`) - return 0 + const r = await fetchOpencode({ platform }); + console.log(r.skipped ? `[fetch-opencode] up to date: ${r.path}` : `[fetch-opencode] installed: ${r.path}`); + return 0; } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - main(process.argv.slice(2)).then(c => { process.exitCode = c }, e => { console.error(`[fetch-opencode] ${e.message}`); process.exitCode = 1 }) + main(process.argv.slice(2)).then( + (c) => { + process.exitCode = c; + }, + (e) => { + console.error(`[fetch-opencode] ${e.message}`); + process.exitCode = 1; + }, + ); } diff --git a/packages/extension/scripts/healthcheck.mjs b/packages/extension/scripts/healthcheck.mjs index 40973e60..dc889395 100644 --- a/packages/extension/scripts/healthcheck.mjs +++ b/packages/extension/scripts/healthcheck.mjs @@ -1,46 +1,66 @@ #!/usr/bin/env node // Amicode healthcheck — exit 0 iff julia+project, opencode /event, amico-run, // and LLM creds all resolve; else non-zero with a precise ✗ line per failure. -import { execFileSync } from 'node:child_process' -import { existsSync, realpathSync } from 'node:fs' -import { homedir } from 'node:os' -import { dirname, join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { bootOpencodeAndProbe } from './opencode_probe.mjs' +import { execFileSync } from "node:child_process"; +import { existsSync, realpathSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { bootOpencodeAndProbe } from "./opencode_probe.mjs"; -const EXT_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') -const JULIA_PROJECT = join(homedir(), '.amico', 'julia') // absolute — '~' is NOT expanded in flags -const CHECK_ORDER = ['julia', 'opencode', 'amicorun', 'creds'] -const LABEL = { julia: 'julia+project', opencode: 'opencode /event', amicorun: 'amico-run', creds: 'LLM creds' } +const EXT_ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const JULIA_PROJECT = join(homedir(), ".amico", "julia"); // absolute — '~' is NOT expanded in flags +const CHECK_ORDER = ["julia", "opencode", "amicorun", "creds"]; +const LABEL = { julia: "julia+project", opencode: "opencode /event", amicorun: "amico-run", creds: "LLM creds" }; /** PURE: results = { [name]: {ok} | {ok:false,reason,fix} } → { exitCode, lines }. Unit-tested. */ export function resolveChecks(results) { - const lines = [] - let failed = 0 + const lines = []; + let failed = 0; for (const name of CHECK_ORDER) { - const r = results[name] ?? { ok: false, reason: 'not run', fix: 'internal' } - if (r.ok) lines.push(`✓ ${LABEL[name]}`) - else { failed++; lines.push(`✗ ${LABEL[name]}: ${r.reason} → ${r.fix}`) } + const r = results[name] ?? { ok: false, reason: "not run", fix: "internal" }; + if (r.ok) lines.push(`✓ ${LABEL[name]}`); + else { + failed++; + lines.push(`✗ ${LABEL[name]}: ${r.reason} → ${r.fix}`); + } } - lines.push(failed === 0 ? `\nAll ${CHECK_ORDER.length} checks passed.` : `\n${failed} check(s) failed.`) - return { exitCode: failed === 0 ? 0 : 1, lines } + lines.push(failed === 0 ? `\nAll ${CHECK_ORDER.length} checks passed.` : `\n${failed} check(s) failed.`); + return { exitCode: failed === 0 ? 0 : 1, lines }; } // ---- probe implementations (impure; run only when executed as CLI) ---- function probeJulia() { - if (!existsSync(JULIA_PROJECT)) return { ok: false, reason: `no julia project at ${JULIA_PROJECT}`, fix: 'run scripts/install.sh' } - try { execFileSync('julia', [`--project=${JULIA_PROJECT}`, '-e', 'using Piccolo'], { stdio: 'ignore', timeout: 300_000 }); return { ok: true } } - catch (e) { return { ok: false, reason: `julia/Piccolo load failed (${(e.message || '').slice(0, 80)})`, fix: 'run scripts/install.sh to instantiate' } } + if (!existsSync(JULIA_PROJECT)) + return { ok: false, reason: `no julia project at ${JULIA_PROJECT}`, fix: "run scripts/install.sh" }; + try { + execFileSync("julia", [`--project=${JULIA_PROJECT}`, "-e", "using Piccolo"], { stdio: "ignore", timeout: 300_000 }); + return { ok: true }; + } catch (e) { + return { + ok: false, + reason: `julia/Piccolo load failed (${(e.message || "").slice(0, 80)})`, + fix: "run scripts/install.sh to instantiate", + }; + } } function probeAmicorun() { - for (const dir of [join(EXT_ROOT, 'bin', 'launcher'), join(EXT_ROOT, '..', 'amico-run', 'launcher')]) { - const p = join(dir, 'amico-run') + for (const dir of [join(EXT_ROOT, "bin", "launcher"), join(EXT_ROOT, "..", "amico-run", "launcher")]) { + const p = join(dir, "amico-run"); if (existsSync(p)) { - try { execFileSync(p, ['--help'], { stdio: 'ignore', timeout: 15_000 }); return { ok: true } } - catch (e) { return { ok: false, reason: `amico-run --help failed (${(e.message || '').slice(0, 60)})`, fix: 'rebuild amico-run / check node on PATH' } } + try { + execFileSync(p, ["--help"], { stdio: "ignore", timeout: 15_000 }); + return { ok: true }; + } catch (e) { + return { + ok: false, + reason: `amico-run --help failed (${(e.message || "").slice(0, 60)})`, + fix: "rebuild amico-run / check node on PATH", + }; + } } } - return { ok: false, reason: 'amico-run launcher not found', fix: 'pnpm -r build (stages bin/) or check the VSIX' } + return { ok: false, reason: "amico-run launcher not found", fix: "pnpm -r build (stages bin/) or check the VSIX" }; } // opencode + LLM creds (0.3): ONE boot of the vendored opencode answers both — @@ -50,30 +70,45 @@ function probeAmicorun() { // LLM call. boot.signal is key-free (stripped at the probe boundary). function opencodeChecks(boot) { if (boot.binMissing) { - const miss = { ok: false, reason: 'vendored opencode binary missing', fix: 'pnpm --filter amicode-v2 fetch:opencode' } - return { opencode: miss, creds: { ok: false, reason: 'opencode unavailable (binary missing)', fix: miss.fix } } + const miss = { + ok: false, + reason: "vendored opencode binary missing", + fix: "pnpm --filter amicode-v2 fetch:opencode", + }; + return { opencode: miss, creds: { ok: false, reason: "opencode unavailable (binary missing)", fix: miss.fix } }; } const opencode = boot.eventOk ? { ok: true } - : { ok: false, reason: `vendored opencode did not serve /event 200 (${boot.up ? `status ${boot.eventStatus}` : 'server not up'})`, fix: 'pnpm --filter amicode-v2 fetch:opencode' } - const creds = boot.signal ?? { ok: false, reason: 'opencode did not boot — creds unverifiable', fix: 'fix opencode boot first' } - return { opencode, creds } + : { + ok: false, + reason: `vendored opencode did not serve /event 200 (${boot.up ? `status ${boot.eventStatus}` : "server not up"})`, + fix: "pnpm --filter amicode-v2 fetch:opencode", + }; + const creds = boot.signal ?? { + ok: false, + reason: "opencode did not boot — creds unverifiable", + fix: "fix opencode boot first", + }; + return { opencode, creds }; } async function main() { - const boot = await bootOpencodeAndProbe({ timeoutMs: 90_000 }) - const { opencode, creds } = opencodeChecks(boot) - const results = { julia: probeJulia(), opencode, amicorun: probeAmicorun(), creds } - const { exitCode, lines } = resolveChecks(results) - console.log(lines.join('\n')) - process.exitCode = exitCode + const boot = await bootOpencodeAndProbe({ timeoutMs: 90_000 }); + const { opencode, creds } = opencodeChecks(boot); + const results = { julia: probeJulia(), opencode, amicorun: probeAmicorun(), creds }; + const { exitCode, lines } = resolveChecks(results); + console.log(lines.join("\n")); + process.exitCode = exitCode; } // realpath-compare so a symlinked invocation path (e.g. macOS /tmp→/private/tmp) // can't make this silently no-op and exit 0 — a false "healthcheck passed". function isMain() { - if (!process.argv[1]) return false - try { return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)) } - catch { return false } + if (!process.argv[1]) return false; + try { + return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); + } catch { + return false; + } } -if (isMain()) await main() +if (isMain()) await main(); diff --git a/packages/extension/scripts/opencode_probe.mjs b/packages/extension/scripts/opencode_probe.mjs index a232b4b8..459379c4 100644 --- a/packages/extension/scripts/opencode_probe.mjs +++ b/packages/extension/scripts/opencode_probe.mjs @@ -40,7 +40,8 @@ function freePort() { * remaining fields explain why. */ export async function bootOpencodeAndProbe({ bin = vendoredOpencodeBin(), timeoutMs = 30000 } = {}) { - if (!existsSync(bin)) return { binMissing: true, up: false, eventOk: false, log: `vendored binary missing at ${bin}` }; + if (!existsSync(bin)) + return { binMissing: true, up: false, eventOk: false, log: `vendored binary missing at ${bin}` }; const proj = mkdtempSync(join(tmpdir(), "amicode-probe-")); mkdirSync(join(proj, ".opencode"), { recursive: true }); @@ -53,15 +54,23 @@ export async function bootOpencodeAndProbe({ bin = vendoredOpencodeBin(), timeou const port = await freePort(); let log = ""; const child = spawn(bin, ["serve", "--port", String(port)], { cwd: proj, stdio: ["ignore", "pipe", "pipe"] }); - const onData = (d) => { log += d; }; + const onData = (d) => { + log += d; + }; child.stdout.on("data", onData); child.stderr.on("data", onData); const cleanup = () => { - try { child.kill("SIGTERM"); } catch {} + try { + child.kill("SIGTERM"); + } catch {} // .unref() the SIGKILL fallback so it can't hold `node healthcheck.mjs` open // for 3s after it's otherwise done (the child usually exits on SIGTERM). - setTimeout(() => { try { child.kill("SIGKILL"); } catch {} }, 3000).unref(); + setTimeout(() => { + try { + child.kill("SIGKILL"); + } catch {} + }, 3000).unref(); }; try { @@ -71,13 +80,17 @@ export async function bootOpencodeAndProbe({ bin = vendoredOpencodeBin(), timeou try { const r = await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(500) }); if (r.status < 500) up = true; - } catch { /* not up yet */ } + } catch { + /* not up yet */ + } if (!up) await new Promise((r) => setTimeout(r, 200)); } if (!up) return { up: false, eventOk: false, log }; // /event gate (headers only; SSE body streaming doesn't block us). - let eventStatus, eventCtype, eventOk = false; + let eventStatus, + eventCtype, + eventOk = false; try { const ev = await fetch(`http://127.0.0.1:${port}/event`, { signal: AbortSignal.timeout(10000) }); eventStatus = ev.status; diff --git a/packages/extension/scripts/plugin_exercise.ts b/packages/extension/scripts/plugin_exercise.ts index 6a3393d1..6b396529 100644 --- a/packages/extension/scripts/plugin_exercise.ts +++ b/packages/extension/scripts/plugin_exercise.ts @@ -34,17 +34,26 @@ const pack: any = await AmicodeTools({}); const tools = pack.tool; // create → pick_system → set_model → formulate → solve -const s0 = lastSentinel(await tools.amicode_problem.execute({ action: "create", name: "X gate on Q1", new_name: null })); +const s0 = lastSentinel( + await tools.amicode_problem.execute({ action: "create", name: "X gate on Q1", new_name: null }), +); assert(s0.entity === "problem" && s0.action === "created", "problem/created sentinel"); const slug: string = s0.problem; lastSentinel(await tools.amicode_pick_system.execute({ platform: "transmon", omega: 4.8, delta: -0.2, notes: null })); lastSentinel(await tools.amicode_set_model.execute({ levels: 4, drive_max: 0.2, params: null })); -lastSentinel(await tools.amicode_formulate.execute({ problem: "gate_synthesis", target: "X", objective: null, constraints: null })); +lastSentinel( + await tools.amicode_formulate.execute({ problem: "gate_synthesis", target: "X", objective: null, constraints: null }), +); const s4 = lastSentinel( await tools.amicode_solve.execute({ run_dir: "/home/u/.amico/runs/default/20260703-190412-abcd", - T: 10, N: 50, max_iter: 60, integrator: "MagnusGL4", tier: "vetted", note: "X gate", + T: 10, + N: 50, + max_iter: 60, + integrator: "MagnusGL4", + tier: "vetted", + note: "X gate", }), ); assert(s4.entity === "run", "solve emits a run sentinel"); @@ -57,22 +66,38 @@ assert(s5.entity === "run" && s5.action === "updated", "verify updates the run e // Workspace layout const ws = path.join(tmp, slug); -for (const f of ["entities/system.toml", "entities/system.json", "entities/formulation.toml", "entities/run.toml", "problem.json"]) { +for (const f of [ + "entities/system.toml", + "entities/system.json", + "entities/formulation.toml", + "entities/run.toml", + "problem.json", +]) { assert(fs.existsSync(path.join(ws, f)), `workspace file ${f}`); } // Event log: >=5 events, monotonic seq, incl. the solve-params Formulation merge -const events = fs.readFileSync(path.join(ws, "events.jsonl"), "utf8").trim().split("\n").map((l) => JSON.parse(l)); +const events = fs + .readFileSync(path.join(ws, "events.jsonl"), "utf8") + .trim() + .split("\n") + .map((l) => JSON.parse(l)); assert(events.length >= 5, `>=5 events (got ${events.length})`); events.forEach((e: any, i: number) => assert(e.seq === i + 1, `monotonic seq at index ${i} (got ${e.seq})`)); const formEvents = events.filter((e: any) => e.entity === "formulation"); assert(formEvents.length >= 2, `formulation created + solve-merge update (got ${formEvents.length})`); const sysEvents = events.filter((e: any) => e.entity === "system"); -assert(sysEvents.some((e: any) => e.hash?.startsWith("sha256:")), "system events carry a content hash"); +assert( + sysEvents.some((e: any) => e.hash?.startsWith("sha256:")), + "system events carry a content hash", +); // Run ref parsed from run_dir's last two segments const runs = JSON.parse(fs.readFileSync(path.join(ws, "runs.json"), "utf8")); -assert(runs.runs.length === 1 && runs.runs[0].run_id === "20260703-190412-abcd" && runs.runs[0].lab === "default", "runs.json ref"); +assert( + runs.runs.length === 1 && runs.runs[0].run_id === "20260703-190412-abcd" && runs.runs[0].lab === "default", + "runs.json ref", +); assert(runs.runs[0].tier === "vetted", "run ref carries tier"); console.error(`OK — ${events.length} events, ${formEvents.length} formulation events, workspace "${slug}"`); diff --git a/packages/extension/src/chat_panel.ts b/packages/extension/src/chat_panel.ts index 94cf6ee7..1a3f72c3 100644 --- a/packages/extension/src/chat_panel.ts +++ b/packages/extension/src/chat_panel.ts @@ -84,16 +84,14 @@ export class ChatPanel { // panel must not be able to sample the clipboard in the background — // reads only answer while the user can see the chat. if (!this.panel.visible) return; - void vscode.env.clipboard - .readText() - .then((text) => - this.panel.webview.postMessage({ - source: "amicode", - kind: "clipboard", - nonce: (msg as { nonce?: string }).nonce, - text, - }), - ); + void vscode.env.clipboard.readText().then((text) => + this.panel.webview.postMessage({ + source: "amicode", + kind: "clipboard", + nonce: (msg as { nonce?: string }).nonce, + text, + }), + ); return; } if ( diff --git a/packages/extension/src/executor_check.ts b/packages/extension/src/executor_check.ts index ebf5abea..57a0e2ce 100644 --- a/packages/extension/src/executor_check.ts +++ b/packages/extension/src/executor_check.ts @@ -1,9 +1,9 @@ // Type-level contract check (spec §9 AC): the extension consumes the β.1 library API. // β.5 replaces this with the real RunsManager integration. -import type { Executor, RunHandle, RunEvent } from '@amicode/amico-run' +import type { Executor, RunHandle, RunEvent } from "@amicode/amico-run"; export type _ExecutorContract = { - submit: Executor['submit'] - handle: Pick - event: RunEvent['kind'] -} + submit: Executor["submit"]; + handle: Pick; + event: RunEvent["kind"]; +}; diff --git a/packages/extension/src/llm_creds.d.mts b/packages/extension/src/llm_creds.d.mts index 503f667e..14f2978f 100644 --- a/packages/extension/src/llm_creds.d.mts +++ b/packages/extension/src/llm_creds.d.mts @@ -4,23 +4,23 @@ /** A key-free provider entry from opencode's /config/providers (post-strip). */ export interface ProviderEntry { - id: string - source?: string + id: string; + source?: string; } /** ok | not-ok signal shared by the healthcheck and the chat-not-ready gate. */ export type LlmCredsSignal = | { ok: true; provider: string; source?: string } - | { ok: false; reason: string; fix: string } + | { ok: false; reason: string; fix: string }; /** PURE: compute the signal from opencode's resolved providers + configured model. */ -export function resolveLlmCreds(args: { providers: ProviderEntry[]; model?: string }): LlmCredsSignal +export function resolveLlmCreds(args: { providers: ProviderEntry[]; model?: string }): LlmCredsSignal; /** No-leak boundary: strip the raw /config/providers JSON to key-free {id, source}. */ -export function stripProviders(providersJson: unknown): ProviderEntry[] +export function stripProviders(providersJson: unknown): ProviderEntry[]; /** Async: query a running opencode server for the provider signal (no key ever returned). */ export function fetchProviderSignal( baseUrl: string, opts?: { fetchImpl?: typeof fetch; timeoutMs?: number }, -): Promise +): Promise; diff --git a/packages/extension/src/llm_creds.mjs b/packages/extension/src/llm_creds.mjs index 40a5398f..abafceba 100644 --- a/packages/extension/src/llm_creds.mjs +++ b/packages/extension/src/llm_creds.mjs @@ -60,8 +60,7 @@ export function resolveLlmCreds({ providers, model }) { * carries provider keys) is touched; nothing but {id, source} escapes. */ export function stripProviders(providersJson) { - const arr = - providersJson && Array.isArray(providersJson.providers) ? providersJson.providers : []; + const arr = providersJson && Array.isArray(providersJson.providers) ? providersJson.providers : []; return arr .map((p) => ({ id: p && p.id, source: p && p.source })) .filter((p) => typeof p.id === "string" && p.id.length > 0); diff --git a/packages/extension/src/opencode_binary.ts b/packages/extension/src/opencode_binary.ts index 126949a0..b6f96c82 100644 --- a/packages/extension/src/opencode_binary.ts +++ b/packages/extension/src/opencode_binary.ts @@ -3,7 +3,10 @@ import { join } from "node:path"; export class OpencodeMissingError extends Error {} -export interface ResolvedBinary { path: string; source: "config-override" | "vendored" } +export interface ResolvedBinary { + path: string; + source: "config-override" | "vendored"; +} const SUPPORTED = ["darwin-arm64", "linux-x64"] as const; diff --git a/packages/extension/src/opencode_config.ts b/packages/extension/src/opencode_config.ts index bb1c6a5b..9d329091 100644 --- a/packages/extension/src/opencode_config.ts +++ b/packages/extension/src/opencode_config.ts @@ -7,9 +7,21 @@ import { readLocalEntitlements, filterRepertoire, packageAllowlist } from "./sco import { buildRouterSection } from "./scores/router"; import { compileScore, spliceIntoAgentsMd, compileChainedScore, chainManifest } from "./scores/compiler"; import { - resolveLibrarySkills, resolvePackageSkills, buildSkillIndexSection, stageOpencodeSkills, type SkillIndexEntry, + resolveLibrarySkills, + resolvePackageSkills, + buildSkillIndexSection, + stageOpencodeSkills, + type SkillIndexEntry, } from "./scores/package_skills"; -import { resolvePersonalVault, defaultVaultsRoot, readProfileMd, readKnowledgeLines, readDemoLines, hasOnboardingCompleted, onboardingDir } from "./substrate/vault_store"; +import { + resolvePersonalVault, + defaultVaultsRoot, + readProfileMd, + readKnowledgeLines, + readDemoLines, + hasOnboardingCompleted, + onboardingDir, +} from "./substrate/vault_store"; import { buildAboutUserSection, buildRecentProblemsSection, buildReferenceDemosSection } from "./substrate/user_splice"; // ============================================================================ @@ -98,7 +110,7 @@ export function resolveJuliaProject(configValue: string): string { * plugin wrote (the plugin's own fs writes are host-process calls and need * no grant). Must stay derivation-identical to problemsDir() in * opencode-plugin/problems.ts. */ -const SCRATCH_DIR = "/tmp/amicode-work"; // matches AGENTS.md step 2/3 +const SCRATCH_DIR = "/tmp/amicode-work"; // matches AGENTS.md step 2/3 /** Root of the amicode_* Problem workspaces — MUST match problemsDir() in * opencode-plugin/problems.ts ($AMICODE_PROBLEMS_DIR override included, so the @@ -164,12 +176,27 @@ export function writeAuthoringConfig( const registry = AUTHORING_ASSETS.registry; const allowlist = packageAllowlist(entitlementsTablePath(scoresRoot), ents.entitlements); let tolerance = 0.01; - let support: string[] = ["JLD2", "CairoMakie", "Makie", "TOML", "Printf", "LinearAlgebra", "Random", "Statistics", "SparseArrays"]; + let support: string[] = [ + "JLD2", + "CairoMakie", + "Makie", + "TOML", + "Printf", + "LinearAlgebra", + "Random", + "Statistics", + "SparseArrays", + ]; try { - const reg = parseToml(fs.readFileSync(registry, "utf8")) as { verify_tolerance?: number; support?: { packages?: string[] } }; + const reg = parseToml(fs.readFileSync(registry, "utf8")) as { + verify_tolerance?: number; + support?: { packages?: string[] }; + }; if (typeof reg.verify_tolerance === "number") tolerance = reg.verify_tolerance; if (Array.isArray(reg.support?.packages)) support = reg.support!.packages!; - } catch { /* keep defaults */ } + } catch { + /* keep defaults */ + } const file = authoringFilePath(); fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync( @@ -236,14 +263,14 @@ export function buildOpencodeConfigContent( bash: "allow", edit: "allow", external_directory: { - [templatePath]: "allow", // exact template file the agent reads - [`${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 - [`${runsRoot}/**`]: "allow", // run read-backs: FINISHED/result.toml/run.log - [`${problemsRoot()}/**`]: "allow", // amicode_* problem workspaces the agent reads back - [`${scoresRoot}/**`]: "allow", // score templates + memory hooks ([Why?]) the agent reads - ...skillGrants, // per-indexed-skill dirs (spec §3, least-privilege) + [templatePath]: "allow", // exact template file the agent reads + [`${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 + [`${runsRoot}/**`]: "allow", // run read-backs: FINISHED/result.toml/run.log + [`${problemsRoot()}/**`]: "allow", // amicode_* problem workspaces the agent reads back + [`${scoresRoot}/**`]: "allow", // score templates + memory hooks ([Why?]) the agent reads + ...skillGrants, // per-indexed-skill dirs (spec §3, least-privilege) // User-memory substrate (spec-20260705-002847 §6): the interview reads // problem/environment cards on demand. Read-only BY CONTRACT — vault // writes are distiller-only (its own config); the permission surface @@ -254,7 +281,6 @@ export function buildOpencodeConfigContent( }); } - export interface OpencodeConfigOptions { /** Absolute path to packages/extension/AGENTS.md to substitute + write into the project dir. */ agentsSrc: string; @@ -380,7 +406,10 @@ export function prepareOpencodeProject(opts: OpencodeConfigOptions): OpencodePro const scoresRoot = opts.scoresRoot ?? DEFAULT_SCORES_ROOT; const allow = packageAllowlist(entitlementsTablePath(scoresRoot), readLocalEntitlements(entsDir).entitlements); skillEntries = [ - ...resolveLibrarySkills(opts.platformSkills ?? DEFAULT_PLATFORM_SKILLS, opts.skillLibraryRoots ?? DEFAULT_LIBRARY_ROOTS), + ...resolveLibrarySkills( + opts.platformSkills ?? DEFAULT_PLATFORM_SKILLS, + opts.skillLibraryRoots ?? DEFAULT_LIBRARY_ROOTS, + ), ...resolvePackageSkills(allow, opts.skillRoots ?? DEFAULT_SKILL_ROOTS), ]; const section = buildSkillIndexSection(skillEntries); diff --git a/packages/extension/src/scores/compiler.ts b/packages/extension/src/scores/compiler.ts index 29837a1d..b7a2d513 100644 --- a/packages/extension/src/scores/compiler.ts +++ b/packages/extension/src/scores/compiler.ts @@ -10,7 +10,7 @@ import { ScoreManifest, Stage } from "./schema"; const INTERVIEW_CONTRACT = [ "**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", + '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.", ]; @@ -20,7 +20,10 @@ const INTERVIEW_CONTRACT = [ function renderStages(stages: Stage[], dir: string, start: number): string[] { const lines: string[] = []; stages.forEach((s, i) => { - const flags = [s.optional ? "(optional)" : "", s.gate ? `🔒 gate: ${s.gate} — checks must pass before entering` : ""] + const flags = [ + s.optional ? "(optional)" : "", + s.gate ? `🔒 gate: ${s.gate} — checks must pass before entering` : "", + ] .filter(Boolean) .join(" "); lines.push(`${start + i + 1}. **${s.id}**${flags ? " " + flags : ""}`); @@ -35,7 +38,8 @@ function renderStages(stages: Stage[], dir: string, start: number): string[] { : ""; 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)`); + if (q.memory_hooks?.length) + lines.push(` - [Why?] hooks: ${q.memory_hooks.join(", ")} (read \`scores/memory/.md\` on request)`); } }); return lines; diff --git a/packages/extension/src/scores/package_skills.ts b/packages/extension/src/scores/package_skills.ts index b7c94198..db5c88d6 100644 --- a/packages/extension/src/scores/package_skills.ts +++ b/packages/extension/src/scores/package_skills.ts @@ -12,7 +12,7 @@ import { parse as parseYaml } from "yaml"; // same parser as scores/loader.ts // .vsix. Errors mirror the entitlements philosophy: skip + warn, never throw. export interface SkillIndexEntry { source: "library" | "package"; // platform library (public) vs co-located package skill (gated) - package?: string; // absent for library entries (spec §3) + package?: string; // absent for library entries (spec §3) name: string; description: string; path: string; // absolute SKILL.md path @@ -42,10 +42,20 @@ export function resolvePackageSkills(allowlist: string[], roots: string[]): Skil for (const pkg of allowlist) { const skillsDir = roots .map((r) => path.join(expandHome(r), `${pkg}.jl`, "skills")) - .find((d) => { try { return fs.statSync(d).isDirectory(); } catch { return false; } }); + .find((d) => { + try { + return fs.statSync(d).isDirectory(); + } catch { + return false; + } + }); if (!skillsDir) continue; // no repo / no skills — silently skipped (spec §9) let names: string[] = []; - try { names = fs.readdirSync(skillsDir); } catch { continue; } + try { + names = fs.readdirSync(skillsDir); + } catch { + continue; + } for (const name of names.sort()) { const skillPath = path.join(skillsDir, name, "SKILL.md"); if (!fs.existsSync(skillPath)) continue; @@ -68,9 +78,7 @@ export function resolvePackageSkills(allowlist: string[], roots: string[]): Skil export function resolveLibrarySkills(names: string[], roots: string[]): SkillIndexEntry[] { const out: SkillIndexEntry[] = []; for (const name of names) { - const skillPath = roots - .map((r) => path.join(expandHome(r), name, "SKILL.md")) - .find((p) => fs.existsSync(p)); + const skillPath = roots.map((r) => path.join(expandHome(r), name, "SKILL.md")).find((p) => fs.existsSync(p)); if (!skillPath) continue; // configured-but-absent — silently skipped try { const fm = readFrontmatter(skillPath); diff --git a/packages/extension/src/scores/schema.ts b/packages/extension/src/scores/schema.ts index 419921a0..94130ff9 100644 --- a/packages/extension/src/scores/schema.ts +++ b/packages/extension/src/scores/schema.ts @@ -1,7 +1,15 @@ // 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 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; @@ -67,7 +75,8 @@ export function validateScoreManifest(raw: unknown): string[] { 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}`); + 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`); diff --git a/packages/extension/src/server_manager.ts b/packages/extension/src/server_manager.ts index 0f37304a..2b805251 100644 --- a/packages/extension/src/server_manager.ts +++ b/packages/extension/src/server_manager.ts @@ -40,9 +40,15 @@ export class ServerManager { constructor(private readonly opts: ServerOptions) {} - get port(): number | undefined { return this._port; } - get url(): URL | undefined { return this._port ? new URL(`http://127.0.0.1:${this._port}`) : undefined; } - get ready(): boolean { return this._ready; } + get port(): number | undefined { + return this._port; + } + get url(): URL | undefined { + return this._port ? new URL(`http://127.0.0.1:${this._port}`) : undefined; + } + get ready(): boolean { + return this._ready; + } async start(): Promise { if (this.child) { @@ -91,13 +97,20 @@ export class ServerManager { this._ready = false; return new Promise((resolve) => { const killTimer = setTimeout(() => { - try { c.kill("SIGKILL"); } catch {} + try { + c.kill("SIGKILL"); + } catch {} }, 3_000); c.once("exit", () => { clearTimeout(killTimer); resolve(); }); - try { c.kill("SIGTERM"); } catch { clearTimeout(killTimer); resolve(); } + try { + c.kill("SIGTERM"); + } catch { + clearTimeout(killTimer); + resolve(); + } }); } } @@ -145,4 +158,6 @@ async function fetchWithTimeout(url: string, ms: number): Promise { } } -function sleep(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)); } +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} diff --git a/packages/extension/src/sse_client.ts b/packages/extension/src/sse_client.ts index 9c21cb81..30673edf 100644 --- a/packages/extension/src/sse_client.ts +++ b/packages/extension/src/sse_client.ts @@ -43,8 +43,16 @@ export class OpencodeEventClient implements vscode.Disposable { dispose(): void { this.disposed = true; if (this.reconnectTimer) clearTimeout(this.reconnectTimer); - try { this.req?.destroy(); } catch { /* noop */ } - try { this.res?.destroy(); } catch { /* noop */ } + try { + this.req?.destroy(); + } catch { + /* noop */ + } + try { + this.res?.destroy(); + } catch { + /* noop */ + } } private openOnce(): void { @@ -113,8 +121,11 @@ export class OpencodeEventClient implements vscode.Disposable { if (dataLines.length === 0) return; const payload = dataLines.join("\n"); let event: { type?: string; properties?: Record }; - try { event = JSON.parse(payload); } - catch { return; /* opencode sometimes sends ping/comment-only blocks */ } + try { + event = JSON.parse(payload); + } catch { + return; /* opencode sometimes sends ping/comment-only blocks */ + } this.dispatch(event); } diff --git a/packages/extension/src/substrate/distiller.ts b/packages/extension/src/substrate/distiller.ts index 2fe7b228..9ec604fa 100644 --- a/packages/extension/src/substrate/distiller.ts +++ b/packages/extension/src/substrate/distiller.ts @@ -47,12 +47,12 @@ export function buildDistillerConfigContent(s: DistillerSetup): Record { - it('teaches the tiered resolve → author → --spec launch (spec C), not a single bundled template', () => { - expect(AGENTS).toMatch(/amico-run resolve/) // tier resolution step - expect(AGENTS).toMatch(/amico-run --spec/) // the gated invocation it teaches - expect(AGENTS).toMatch(/solve\.jl/) - expect(AGENTS).toMatch(/vetted/) // the three tiers named - expect(AGENTS).toMatch(/composed/) - expect(AGENTS).toMatch(/free/) - }) - it('authors into the workspace-owned solve.jl (spec A), never /tmp', () => { - expect(AGENTS).toMatch(/~\/\.amico\/problems\/\/solve\.jl/) // workspace-owned - expect(AGENTS).not.toMatch(/\/tmp\/amicode-work/) // the old scratch path is gone - expect(AGENTS).not.toMatch(/in this project dir/) - }) - it('teaches the portable detached launch (nohup + & in a subshell + watch inspector), not setsid', () => { - expect(AGENTS).toMatch(/nohup/) - expect(AGENTS).toMatch(/&\s*\)/) // backgrounded inside a subshell - expect(AGENTS).toMatch(/Run Inspector/) - expect(AGENTS).not.toMatch(/setsid/) // Linux-only; would silently break the macOS demo - }) - it('does not tell the agent to block on the solve', () => { - expect(AGENTS).not.toMatch(/wait for (the )?solve to finish/i) - }) - it('author-first: multi-qubit transmon ROUTES to the free-tier offer (unvetted, verified), never a flat decline', () => { - expect(AGENTS).toMatch(/single[- ]qubit/i) - expect(AGENTS).toMatch(/multi-qubit|two-qubit|2-qubit|CNOT/i) +describe("AGENTS.md teaches the D9/D10 script-authoring workflow", () => { + it("teaches the tiered resolve → author → --spec launch (spec C), not a single bundled template", () => { + expect(AGENTS).toMatch(/amico-run resolve/); // tier resolution step + expect(AGENTS).toMatch(/amico-run --spec/); // the gated invocation it teaches + expect(AGENTS).toMatch(/solve\.jl/); + expect(AGENTS).toMatch(/vetted/); // the three tiers named + expect(AGENTS).toMatch(/composed/); + expect(AGENTS).toMatch(/free/); + }); + it("authors into the workspace-owned solve.jl (spec A), never /tmp", () => { + expect(AGENTS).toMatch(/~\/\.amico\/problems\/\/solve\.jl/); // workspace-owned + expect(AGENTS).not.toMatch(/\/tmp\/amicode-work/); // the old scratch path is gone + expect(AGENTS).not.toMatch(/in this project dir/); + }); + it("teaches the portable detached launch (nohup + & in a subshell + watch inspector), not setsid", () => { + expect(AGENTS).toMatch(/nohup/); + expect(AGENTS).toMatch(/&\s*\)/); // backgrounded inside a subshell + expect(AGENTS).toMatch(/Run Inspector/); + expect(AGENTS).not.toMatch(/setsid/); // Linux-only; would silently break the macOS demo + }); + it("does not tell the agent to block on the solve", () => { + expect(AGENTS).not.toMatch(/wait for (the )?solve to finish/i); + }); + it("author-first: multi-qubit transmon ROUTES to the free-tier offer (unvetted, verified), never a flat decline", () => { + expect(AGENTS).toMatch(/single[- ]qubit/i); + expect(AGENTS).toMatch(/multi-qubit|two-qubit|2-qubit|CNOT/i); // spec-20260704-113005 §5: "no template → decline" is retired — it routes to // the free-tier offer with an honest unvetted caveat, not a stop. - expect(AGENTS).toMatch(/free[- ]tier/i) - expect(AGENTS).toMatch(/unvetted/i) - expect(AGENTS).not.toMatch(/say so plainly and stop/i) + expect(AGENTS).toMatch(/free[- ]tier/i); + expect(AGENTS).toMatch(/unvetted/i); + expect(AGENTS).not.toMatch(/say so plainly and stop/i); // the reconciliation: 2-qubit Rydberg CZ IS supported (composed exemplar / Piccolissimo path). // whitespace-tolerant: markdown reflow may wrap any gap in the phrase. - expect(AGENTS).toMatch(/Rydberg\s+CZ\s+is\s+the\s+exception/i) - }) - it('author-first PLATFORM intake: no coercion, records the actual platform, offers free-tier (spec §5)', () => { - expect(AGENTS).toMatch(/as stated/i) // acknowledge the platform as itself - expect(AGENTS).toMatch(/actual platform string/i) // record the real string, not "other" - expect(AGENTS).toMatch(/never coerce/i) - expect(AGENTS).toMatch(/## Skill index/) // routing keys off the dual-source index - expect(AGENTS).toMatch(/free-phase CZ path/i) // the issimo Piccolissimo recommendation - }) - it('gives regime guidance (level cap + scale N with gate time)', () => { - expect(AGENTS).toMatch(/levels/i) - expect(AGENTS).toMatch(/steps\/ns|timesteps/i) - }) - it('documents the run-dir contract the script must emit', () => { - expect(AGENTS).toMatch(/AMICODE_ITER/) - expect(AGENTS).toMatch(/iter_.*\.png/) - expect(AGENTS).toMatch(/result\.toml/) - expect(AGENTS).toMatch(/load_traj/) // corrected warm-start idiom (not load_pulse) - }) - it('does NOT teach the deleted pre-D9 flag CLI', () => { - expect(AGENTS).not.toMatch(/--gate\b/) - expect(AGENTS).not.toMatch(/--system\b/) - expect(AGENTS).not.toMatch(/load_pulse/) - }) -}) + expect(AGENTS).toMatch(/Rydberg\s+CZ\s+is\s+the\s+exception/i); + }); + it("author-first PLATFORM intake: no coercion, records the actual platform, offers free-tier (spec §5)", () => { + expect(AGENTS).toMatch(/as stated/i); // acknowledge the platform as itself + expect(AGENTS).toMatch(/actual platform string/i); // record the real string, not "other" + expect(AGENTS).toMatch(/never coerce/i); + expect(AGENTS).toMatch(/## Skill index/); // routing keys off the dual-source index + expect(AGENTS).toMatch(/free-phase CZ path/i); // the issimo Piccolissimo recommendation + }); + it("gives regime guidance (level cap + scale N with gate time)", () => { + expect(AGENTS).toMatch(/levels/i); + expect(AGENTS).toMatch(/steps\/ns|timesteps/i); + }); + it("documents the run-dir contract the script must emit", () => { + expect(AGENTS).toMatch(/AMICODE_ITER/); + expect(AGENTS).toMatch(/iter_.*\.png/); + expect(AGENTS).toMatch(/result\.toml/); + expect(AGENTS).toMatch(/load_traj/); // corrected warm-start idiom (not load_pulse) + }); + it("does NOT teach the deleted pre-D9 flag CLI", () => { + expect(AGENTS).not.toMatch(/--gate\b/); + expect(AGENTS).not.toMatch(/--system\b/); + 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('capabilities question has a curated answer: no webfetch, no engine talk', () => { - expect(AGENTS).toMatch(/## Answering "What can Amicode do\?"/) - expect(AGENTS).toMatch(/never webfetch/i) - expect(AGENTS).toMatch(/never describe the underlying engine/i) - expect(AGENTS).toMatch(/How I work \(author-first\)/) // the curated scope statement (renamed from "Today's scope" in §5) - }) - 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'] +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("capabilities question has a curated answer: no webfetch, no engine talk", () => { + expect(AGENTS).toMatch(/## Answering "What can Amicode do\?"/); + expect(AGENTS).toMatch(/never webfetch/i); + expect(AGENTS).toMatch(/never describe the underlying engine/i); + expect(AGENTS).toMatch(/How I work \(author-first\)/); // the curated scope statement (renamed from "Today's scope" in §5) + }); + 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 the Rydberg tier', () => { - expect(AGENTS).toContain('\\hat H/\\hbar') - expect(AGENTS).toMatch(/rydberg/i) + 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 the Rydberg tier", () => { + expect(AGENTS).toContain("\\hat H/\\hbar"); + expect(AGENTS).toMatch(/rydberg/i); // Rydberg authoring IS wired (composed tier, experimental) — the stale // "not wired / transmon-only follow-up" narrative must stay gone. - expect(AGENTS).toMatch(/composed/i) - expect(AGENTS).toMatch(/experimental/i) - expect(AGENTS).not.toMatch(/Rydberg solve authoring is not wired/i) - }) - it('names the amicode_* recording tools as bookkeeping, not gates, with bash still the launch mechanism', () => { + expect(AGENTS).toMatch(/composed/i); + expect(AGENTS).toMatch(/experimental/i); + expect(AGENTS).not.toMatch(/Rydberg solve authoring is not wired/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', + "amicode_ask", + "amicode_pick_system", + "amicode_set_model", + "amicode_formulate", + "amicode_solve", + "amicode_to_hardware", + "amicode_calibrate", ]) { - expect(AGENTS).toContain(t) + expect(AGENTS).toContain(t); } - expect(AGENTS).toMatch(/bookkeeping, not gates/) - expect(AGENTS).toMatch(/they never replace the bash launch/i) - }) - it('teaches the free-tier verification recording (amicode_verify) and untrusted-until-agree rule', () => { - expect(AGENTS).toContain('amicode_verify') - expect(AGENTS).toMatch(/verification\.toml/) - expect(AGENTS).toMatch(/cannot be promoted[\s\S]*until verification/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( + expect(AGENTS).toMatch(/bookkeeping, not gates/); + expect(AGENTS).toMatch(/they never replace the bash launch/i); + }); + it("teaches the free-tier verification recording (amicode_verify) and untrusted-until-agree rule", () => { + expect(AGENTS).toContain("amicode_verify"); + expect(AGENTS).toMatch(/verification\.toml/); + expect(AGENTS).toMatch(/cannot be promoted[\s\S]*until verification/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_]+\}\}/) - }) -}) + "/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 index a36441ae..c0c16f50 100644 --- a/packages/extension/test/amicode_tools.test.ts +++ b/packages/extension/test/amicode_tools.test.ts @@ -12,8 +12,8 @@ // 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 { describe, it, expect } from "vitest"; +import { parse } from "smol-toml"; import { systemToml, formulationToml, @@ -32,273 +32,292 @@ import { type SystemEntity, type FormulationEntity, type ProblemMeta, -} from '../opencode-plugin/entities' +} from "../opencode-plugin/entities"; const SYS: SystemEntity = { - platform: 'transmon', + 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'], -} + 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('accepts an arbitrary platform, rejects an empty one (opened model, spec A)', () => { - expect(() => systemToml({ ...SYS, platform: 'gkp-cavity' })).not.toThrow() - expect(() => systemToml({ ...SYS, platform: '' })).toThrow(/platform/) - }) - it('rejects levels < 2 and non-integers, but allows levels > 6 (warning, not error)', () => { - expect(() => systemToml({ ...SYS, levels: 1 })).toThrow(/levels/) - expect(() => systemToml({ ...SYS, levels: 3.5 })).toThrow(/levels/) - expect(() => systemToml({ ...SYS, levels: 7 })).not.toThrow() - }) - 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("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("accepts an arbitrary platform, rejects an empty one (opened model, spec A)", () => { + expect(() => systemToml({ ...SYS, platform: "gkp-cavity" })).not.toThrow(); + expect(() => systemToml({ ...SYS, platform: "" })).toThrow(/platform/); + }); + it("rejects levels < 2 and non-integers, but allows levels > 6 (warning, not error)", () => { + expect(() => systemToml({ ...SYS, levels: 1 })).toThrow(/levels/); + expect(() => systemToml({ ...SYS, levels: 3.5 })).toThrow(/levels/); + expect(() => systemToml({ ...SYS, levels: 7 })).not.toThrow(); + }); + 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("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: '' as any }).join(' ')).toMatch(/platform/) - expect(validateSystem({ ...SYS, levels: 1 }).join(' ')).toMatch(/levels/) - expect(validateFormulation({ ...FORM, target: '' }).join(' ')).toMatch(/target/) - }) -}) +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: "" as any }).join(" ")).toMatch(/platform/); + expect(validateSystem({ ...SYS, levels: 1 }).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: 1 })).toThrow(/levels/) - expect(() => updateSystem(SYS, { params: { omega: NaN } })).toThrow(/param/) - }) -}) +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: 1 })).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) - expect('verification' in doc.run).toBe(false) // spec C: absent until amicode_verify - }) - it('round-trips the free-tier verification sub-table (spec C)', () => { - const doc = parse(runStubToml({ - tier: 'free', - verification: { agree: false, fidelity_rerolled: 0.0004, fidelity_reported: 0.9999 }, - })) as any - expect(doc.run.tier).toBe('free') - expect(doc.run.verification.agree).toBe(false) - expect(doc.run.verification.fidelity_rerolled).toBeCloseTo(0.0004) - expect(doc.run.verification.fidelity_reported).toBeCloseTo(0.9999) - }) -}) +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); + expect("verification" in doc.run).toBe(false); // spec C: absent until amicode_verify + }); + it("round-trips the free-tier verification sub-table (spec C)", () => { + const doc = parse( + runStubToml({ + tier: "free", + verification: { agree: false, fidelity_rerolled: 0.0004, fidelity_reported: 0.9999 }, + }), + ) as any; + expect(doc.run.tier).toBe("free"); + expect(doc.run.verification.agree).toBe(false); + expect(doc.run.verification.fidelity_rerolled).toBeCloseTo(0.0004); + expect(doc.run.verification.fidelity_reported).toBeCloseTo(0.9999); + }); +}); -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("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/) - }) -}) +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/); + }); +}); -describe('opened entity model (spec A)', () => { - it('accepts an unknown platform and optional levels', () => { - expect(validateSystem({ platform: 'gkp-cavity', params: { chi: 0.5 } } as SystemEntity)).toEqual([]) - expect(validateSystem({ platform: '', params: {} } as SystemEntity)).not.toEqual([]) - }) - it('warns but does not reject levels > 6', () => { - expect(validateSystem({ platform: 'transmon', levels: 7, params: {} } as SystemEntity)).toEqual([]) - }) - it('round-trips formulation.solve through TOML', () => { +describe("opened entity model (spec A)", () => { + it("accepts an unknown platform and optional levels", () => { + expect(validateSystem({ platform: "gkp-cavity", params: { chi: 0.5 } } as SystemEntity)).toEqual([]); + expect(validateSystem({ platform: "", params: {} } as SystemEntity)).not.toEqual([]); + }); + it("warns but does not reject levels > 6", () => { + expect(validateSystem({ platform: "transmon", levels: 7, params: {} } as SystemEntity)).toEqual([]); + }); + it("round-trips formulation.solve through TOML", () => { const f: FormulationEntity = { - problem: 'min_time', target: 'CZ', objective: 'unitary infidelity', - constraints: ['amplitude bound'], solve: { T: 10, N: 50, max_iter: 60, integrator: 'MagnusGL4' }, - } - const parsed = parse(formulationToml(f)) as any - expect(parsed.formulation.solve.T).toBe(10) - expect(parsed.formulation.solve.integrator).toBe('MagnusGL4') - }) -}) + problem: "min_time", + target: "CZ", + objective: "unitary infidelity", + constraints: ["amplitude bound"], + solve: { T: 10, N: 50, max_iter: 60, integrator: "MagnusGL4" }, + }; + const parsed = parse(formulationToml(f)) as any; + expect(parsed.formulation.solve.T).toBe(10); + expect(parsed.formulation.solve.integrator).toBe("MagnusGL4"); + }); +}); -describe('canonicalJson + hash input rules', () => { - it('sorts keys and excludes recorded/notes', () => { - expect(canonicalJson({ b: 1, a: 2, recorded: 'x', notes: 'y' })).toBe('{"a":2,"b":1}') - }) - it('is stable across key order', () => { - expect(canonicalJson({ x: { b: 1, a: [1, 2] } })).toBe(canonicalJson({ x: { a: [1, 2], b: 1 } })) - }) -}) +describe("canonicalJson + hash input rules", () => { + it("sorts keys and excludes recorded/notes", () => { + expect(canonicalJson({ b: 1, a: 2, recorded: "x", notes: "y" })).toBe('{"a":2,"b":1}'); + }); + it("is stable across key order", () => { + expect(canonicalJson({ x: { b: 1, a: [1, 2] } })).toBe(canonicalJson({ x: { a: [1, 2], b: 1 } })); + }); +}); -describe('deriveSlug', () => { - it('kebab-cases and strips punctuation', () => { - expect(deriveSlug('X gate on Q1!')).toBe('x-gate-on-q1') - expect(deriveSlug('///')).toBe('untitled') - }) -}) +describe("deriveSlug", () => { + it("kebab-cases and strips punctuation", () => { + expect(deriveSlug("X gate on Q1!")).toBe("x-gate-on-q1"); + expect(deriveSlug("///")).toBe("untitled"); + }); +}); -describe('entityDiff + sentinel truncation', () => { - it('produces dotted keys for nested params and skips recorded', () => { +describe("entityDiff + sentinel truncation", () => { + it("produces dotted keys for nested params and skips recorded", () => { const d = entityDiff( { levels: 3, params: { drive_max: 0.2 } }, - { levels: 4, params: { drive_max: 0.2 }, recorded: 'x' }, - ) - expect(d).toEqual({ levels: { from: 3, to: 4 } }) - }) - it('null from on create', () => { - expect(entityDiff(undefined, { platform: 'transmon' })).toEqual({ platform: { from: null, to: 'transmon' } }) - }) - it('keeps the sentinel line under 1 KB', () => { - const big = entityDiff(undefined, { notes2: 'z'.repeat(5000) }) - const line = JSON.stringify(truncateDiffForSentinel(big)) - expect(line.length).toBeLessThanOrEqual(1024) - expect(line).toContain('…') - }) -}) + { levels: 4, params: { drive_max: 0.2 }, recorded: "x" }, + ); + expect(d).toEqual({ levels: { from: 3, to: 4 } }); + }); + it("null from on create", () => { + expect(entityDiff(undefined, { platform: "transmon" })).toEqual({ platform: { from: null, to: "transmon" } }); + }); + it("keeps the sentinel line under 1 KB", () => { + const big = entityDiff(undefined, { notes2: "z".repeat(5000) }); + const line = JSON.stringify(truncateDiffForSentinel(big)); + expect(line.length).toBeLessThanOrEqual(1024); + expect(line).toContain("…"); + }); +}); -describe('problem + run-ref serializers', () => { - it('round-trips problem.toml', () => { +describe("problem + run-ref serializers", () => { + it("round-trips problem.toml", () => { const meta: ProblemMeta = { - name: 'X gate on Q1', slug: 'x-gate-q1', created: '2026-07-03T00:00:00Z', - status: 'designing', score: { id: 'pulse-designer', version: 3 }, env: { kind: 'provisioned' }, - } - const parsed = parse(problemToml(meta)) as any - expect(parsed.problem.slug).toBe('x-gate-q1') - expect(parsed.problem.score.id).toBe('pulse-designer') - expect(parsed.problem.env.kind).toBe('provisioned') - }) - it('round-trips runs.toml appends', () => { - const t = runRefsToml([{ run_id: 'r1', lab: 'default', tier: 'vetted', recorded: 'x' }]) - expect((parse(t) as any).runs[0].tier).toBe('vetted') - }) -}) + name: "X gate on Q1", + slug: "x-gate-q1", + created: "2026-07-03T00:00:00Z", + status: "designing", + score: { id: "pulse-designer", version: 3 }, + env: { kind: "provisioned" }, + }; + const parsed = parse(problemToml(meta)) as any; + expect(parsed.problem.slug).toBe("x-gate-q1"); + expect(parsed.problem.score.id).toBe("pulse-designer"); + expect(parsed.problem.env.kind).toBe("provisioned"); + }); + it("round-trips runs.toml appends", () => { + const t = runRefsToml([{ run_id: "r1", lab: "default", tier: "vetted", recorded: "x" }]); + expect((parse(t) as any).runs[0].tier).toBe("vetted"); + }); +}); diff --git a/packages/extension/test/boot_smoke.mjs b/packages/extension/test/boot_smoke.mjs index d5d461d2..70ee7b54 100644 --- a/packages/extension/test/boot_smoke.mjs +++ b/packages/extension/test/boot_smoke.mjs @@ -14,15 +14,20 @@ // Boot + probe logic lives in scripts/opencode_probe.mjs (shared with the // healthcheck, which derives BOTH the /event gate and the provider signal from a // single boot); this script asserts the /event gate and exits. -import { bootOpencodeAndProbe, vendoredOpencodeBin } from '../scripts/opencode_probe.mjs' +import { bootOpencodeAndProbe, vendoredOpencodeBin } from "../scripts/opencode_probe.mjs"; -const fail = (msg, code = 1) => { console.error(`[smoke] FAIL: ${msg}`); process.exit(code) } +const fail = (msg, code = 1) => { + console.error(`[smoke] FAIL: ${msg}`); + process.exit(code); +}; -const boot = await bootOpencodeAndProbe({ timeoutMs: 30_000 }) -if (boot.binMissing) fail(`vendored binary missing at ${vendoredOpencodeBin()} — run \`pnpm --filter amicode-v2 fetch:opencode\``, 10) -if (!boot.up) fail(`server not up within 30s\n--- server output ---\n${boot.log}`) -console.log(`[smoke] GET /event → ${boot.eventStatus} (${boot.eventCtype})`) -if (boot.eventStatus !== 200) fail(`/event status ${boot.eventStatus}, want 200\n--- server output ---\n${boot.log}`) -if (!(boot.eventCtype ?? '').includes('text/event-stream')) fail(`/event content-type "${boot.eventCtype}", want text/event-stream`) -console.log('[smoke] PASS') -process.exit(0) +const boot = await bootOpencodeAndProbe({ timeoutMs: 30_000 }); +if (boot.binMissing) + fail(`vendored binary missing at ${vendoredOpencodeBin()} — run \`pnpm --filter amicode-v2 fetch:opencode\``, 10); +if (!boot.up) fail(`server not up within 30s\n--- server output ---\n${boot.log}`); +console.log(`[smoke] GET /event → ${boot.eventStatus} (${boot.eventCtype})`); +if (boot.eventStatus !== 200) fail(`/event status ${boot.eventStatus}, want 200\n--- server output ---\n${boot.log}`); +if (!(boot.eventCtype ?? "").includes("text/event-stream")) + fail(`/event content-type "${boot.eventCtype}", want text/event-stream`); +console.log("[smoke] PASS"); +process.exit(0); diff --git a/packages/extension/test/corpus/fake-julia b/packages/extension/test/corpus/fake-julia index 5d40f65d..77ebdfc8 100755 --- a/packages/extension/test/corpus/fake-julia +++ b/packages/extension/test/corpus/fake-julia @@ -18,47 +18,54 @@ // # AMICODE_SMOKE iters= drives= knots= fidelity= [dt=] [delay_ms=] [exit=] // exit≠0 makes a failure-lane fixture (no result.toml, nonzero exit). -'use strict'; -const fs = require('node:fs'); +"use strict"; +const fs = require("node:fs"); const script = process.argv[process.argv.length - 1]; -const src = fs.readFileSync(script, 'utf8'); +const src = fs.readFileSync(script, "utf8"); const m = src.match(/^#\s*AMICODE_SMOKE\s+(.+)$/m); -if (!m) { process.stderr.write(`fake-julia: no AMICODE_SMOKE directive in ${script}\n`); process.exit(2); } +if (!m) { + process.stderr.write(`fake-julia: no AMICODE_SMOKE directive in ${script}\n`); + process.exit(2); +} const d = {}; for (const kv of m[1].trim().split(/\s+/)) { - const [k, v] = kv.split('='); + const [k, v] = kv.split("="); d[k] = Number(v); } -const iters = d.iters ?? 3, drives = d.drives ?? 1, knots = d.knots ?? 4; -const fidelity = d.fidelity ?? 0.999, dt = d.dt ?? 0.2; -const delayMs = d.delay_ms ?? 40, exitCode = d.exit ?? 0; +const iters = d.iters ?? 3, + drives = d.drives ?? 1, + knots = d.knots ?? 4; +const fidelity = d.fidelity ?? 0.999, + dt = d.dt ?? 0.2; +const delayMs = d.delay_ms ?? 40, + exitCode = d.exit ?? 0; const bound = 0.2; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); -const say = (line) => process.stdout.write(line + '\n'); +const say = (line) => process.stdout.write(line + "\n"); (async () => { - const labels = Array.from({ length: drives }, (_, i) => `"a_${i + 1}"`).join(','); - const bounds = Array.from({ length: drives }, () => `${-bound}:${bound}`).join(','); + const labels = Array.from({ length: drives }, (_, i) => `"a_${i + 1}"`).join(","); + const bounds = Array.from({ length: drives }, () => `${-bound}:${bound}`).join(","); say(`AMICODE_PULSE_META drives=${drives} knots=${knots} labels=${labels} bounds=${bounds}`); for (let k = 0; k <= iters; k++) { // Deterministic, iteration-varying values inside the bounds band. const row = (di) => - Array.from({ length: knots }, (_, j) => - (bound * 0.9 * Math.sin((j + 1) * (k + 1) + di)).toFixed(6)).join(','); - const vals = Array.from({ length: drives }, (_, di) => row(di)).join(';'); + Array.from({ length: knots }, (_, j) => (bound * 0.9 * Math.sin((j + 1) * (k + 1) + di)).toFixed(6)).join(","); + const vals = Array.from({ length: drives }, (_, di) => row(di)).join(";"); const f = 50 * Math.exp(-k) + (1 - fidelity); - say(`AMICODE_ITER iter=${k} f=${f.toExponential(6)} inf_pr=${(1e-3 * Math.exp(-k)).toExponential(3)} inf_du=${(1e-2 * Math.exp(-k)).toExponential(3)}`); + say( + `AMICODE_ITER iter=${k} f=${f.toExponential(6)} inf_pr=${(1e-3 * Math.exp(-k)).toExponential(3)} inf_du=${(1e-2 * Math.exp(-k)).toExponential(3)}`, + ); say(`AMICODE_PULSE iter=${k} dt=${dt} a=${vals}`); await sleep(delayMs); } if (exitCode === 0) { // Same shape the template writes (result schema: schema_version/fidelity/iterations). - fs.writeFileSync('result.toml', - `schema_version = "1"\nfidelity = ${fidelity}\niterations = ${iters}\n`); + fs.writeFileSync("result.toml", `schema_version = "1"\nfidelity = ${fidelity}\niterations = ${iters}\n`); } process.exit(exitCode); })(); diff --git a/packages/extension/test/demo_replay.test.ts b/packages/extension/test/demo_replay.test.ts index 7d31f7c3..7468c8dd 100644 --- a/packages/extension/test/demo_replay.test.ts +++ b/packages/extension/test/demo_replay.test.ts @@ -1,35 +1,37 @@ -import { describe, it, expect } from 'vitest' -import { mkdtempSync, writeFileSync, readFileSync, readlinkSync, existsSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { parse } from 'smol-toml' -import { validateManifest, validateFinished } from '@amicode/amico-run' -import { stageDemoRun } from '../src/demo_replay' +import { describe, it, expect } from "vitest"; +import { mkdtempSync, writeFileSync, readFileSync, readlinkSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parse } from "smol-toml"; +import { validateManifest, validateFinished } from "@amicode/amico-run"; +import { stageDemoRun } from "../src/demo_replay"; function fakeDemo(): string { - const d = mkdtempSync(join(tmpdir(), 'demo-')) - writeFileSync(join(d, 'run.toml'), - `schema_version = "1"\nrun_id = "rDEMO"\nscript_path = "/demo.jl"\nlab = "default"\nlab_id = "default"\ncreated_at = "2026-06-17T00:00:00Z"\norchestrator_version = "0.1.0"\n[julia]\nbinary = "julia"\n`) - writeFileSync(join(d, 'run.log'), 'AMICODE_ITER iter=10 f=0.1 inf_pr=1e-8 inf_du=1e-6\n') - writeFileSync(join(d, 'iter_0010.png'), 'PNG') - writeFileSync(join(d, 'result.toml'), 'schema_version = "1"\nfidelity = 0.9999\niterations = 10\n') - writeFileSync(join(d, 'FINISHED'), 'status = "completed"\nexit_code = 0\n') - return d + const d = mkdtempSync(join(tmpdir(), "demo-")); + writeFileSync( + join(d, "run.toml"), + `schema_version = "1"\nrun_id = "rDEMO"\nscript_path = "/demo.jl"\nlab = "default"\nlab_id = "default"\ncreated_at = "2026-06-17T00:00:00Z"\norchestrator_version = "0.1.0"\n[julia]\nbinary = "julia"\n`, + ); + writeFileSync(join(d, "run.log"), "AMICODE_ITER iter=10 f=0.1 inf_pr=1e-8 inf_du=1e-6\n"); + writeFileSync(join(d, "iter_0010.png"), "PNG"); + writeFileSync(join(d, "result.toml"), 'schema_version = "1"\nfidelity = 0.9999\niterations = 10\n'); + writeFileSync(join(d, "FINISHED"), 'status = "completed"\nexit_code = 0\n'); + return d; } -describe('stageDemoRun', () => { - it('stages the demo into a fresh runId, rewrites manifest run_id, swings latest', () => { - const demo = fakeDemo() - const runsRoot = mkdtempSync(join(tmpdir(), 'runs-')) - const runDir = stageDemoRun(demo, runsRoot) - const runId = runDir.split('/').pop()! - expect(runId).toMatch(/^r\d{8}-\d{6}Z-[0-9a-f]{4}$/) // β.1 runId format - expect(existsSync(join(runDir, 'iter_0010.png'))).toBe(true) - const m = parse(readFileSync(join(runDir, 'run.toml'), 'utf8')) as Record - expect(validateManifest(m).ok).toBe(true) - expect(m.run_id).toBe(runId) // rewritten to match the dir - expect(validateFinished(parse(readFileSync(join(runDir, 'FINISHED'), 'utf8'))).ok).toBe(true) - expect(readlinkSync(join(runsRoot, 'latest'))).toBe(runId) // the watcher will follow this - expect(readFileSync(join(runsRoot, 'index'), 'utf8')).toContain(runId) // appended to the index - }) -}) +describe("stageDemoRun", () => { + it("stages the demo into a fresh runId, rewrites manifest run_id, swings latest", () => { + const demo = fakeDemo(); + const runsRoot = mkdtempSync(join(tmpdir(), "runs-")); + const runDir = stageDemoRun(demo, runsRoot); + const runId = runDir.split("/").pop()!; + expect(runId).toMatch(/^r\d{8}-\d{6}Z-[0-9a-f]{4}$/); // β.1 runId format + expect(existsSync(join(runDir, "iter_0010.png"))).toBe(true); + const m = parse(readFileSync(join(runDir, "run.toml"), "utf8")) as Record; + expect(validateManifest(m).ok).toBe(true); + expect(m.run_id).toBe(runId); // rewritten to match the dir + expect(validateFinished(parse(readFileSync(join(runDir, "FINISHED"), "utf8"))).ok).toBe(true); + expect(readlinkSync(join(runsRoot, "latest"))).toBe(runId); // the watcher will follow this + expect(readFileSync(join(runsRoot, "index"), "utf8")).toContain(runId); // appended to the index + }); +}); diff --git a/packages/extension/test/fetch_opencode.test.ts b/packages/extension/test/fetch_opencode.test.ts index 5d45f4ac..5c58596e 100644 --- a/packages/extension/test/fetch_opencode.test.ts +++ b/packages/extension/test/fetch_opencode.test.ts @@ -1,100 +1,112 @@ -import { describe, it, expect } from 'vitest' -import { execFileSync } from 'node:child_process' -import { chmodSync, existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { fetchOpencode, loadManifest, resolvePlatform, sha256 } from '../scripts/fetch_opencode.mjs' +import { describe, it, expect } from "vitest"; +import { execFileSync } from "node:child_process"; +import { chmodSync, existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fetchOpencode, loadManifest, resolvePlatform, sha256 } from "../scripts/fetch_opencode.mjs"; function rootWith(manifest: unknown): string { - const root = mkdtempSync(join(tmpdir(), 'oc-test-')) - writeFileSync(join(root, 'opencode.lock.json'), JSON.stringify(manifest)) - return root + const root = mkdtempSync(join(tmpdir(), "oc-test-")); + writeFileSync(join(root, "opencode.lock.json"), JSON.stringify(manifest)); + return root; } const GOOD = { - version: '1.17.3', + version: "1.17.3", platforms: { - 'darwin-arm64': { asset: 'a.zip', sha256: 'ab'.repeat(32) }, - 'linux-x64': { asset: 'a.tar.gz', sha256: 'cd'.repeat(32) }, + "darwin-arm64": { asset: "a.zip", sha256: "ab".repeat(32) }, + "linux-x64": { asset: "a.tar.gz", sha256: "cd".repeat(32) }, }, -} +}; -describe('loadManifest', () => { - it('accepts a well-formed manifest', () => { - expect(loadManifest(rootWith(GOOD)).version).toBe('1.17.3') - }) - it('the COMMITTED manifest parses and pins exactly the two supported platforms', () => { - const m = loadManifest() // defaults to the real packages/extension root - expect(Object.keys(m.platforms).sort()).toEqual(['darwin-arm64', 'linux-x64']) - }) - it('rejects missing version and short hashes', () => { - expect(() => loadManifest(rootWith({ ...GOOD, version: '' }))).toThrow(/version/) - expect(() => loadManifest(rootWith({ - ...GOOD, platforms: { ...GOOD.platforms, 'linux-x64': { asset: 'a', sha256: 'beef' } }, - }))).toThrow(/sha256/) - }) -}) +describe("loadManifest", () => { + it("accepts a well-formed manifest", () => { + expect(loadManifest(rootWith(GOOD)).version).toBe("1.17.3"); + }); + it("the COMMITTED manifest parses and pins exactly the two supported platforms", () => { + const m = loadManifest(); // defaults to the real packages/extension root + expect(Object.keys(m.platforms).sort()).toEqual(["darwin-arm64", "linux-x64"]); + }); + it("rejects missing version and short hashes", () => { + expect(() => loadManifest(rootWith({ ...GOOD, version: "" }))).toThrow(/version/); + expect(() => + loadManifest( + rootWith({ + ...GOOD, + platforms: { ...GOOD.platforms, "linux-x64": { asset: "a", sha256: "beef" } }, + }), + ), + ).toThrow(/sha256/); + }); +}); -describe('resolvePlatform', () => { - it('honors an explicit valid key and rejects unknown ones', () => { - expect(resolvePlatform(GOOD, 'linux-x64')).toBe('linux-x64') - expect(() => resolvePlatform(GOOD, 'windows-x64')).toThrow(/supported/) - }) - it('detects the current machine when no flag given', () => { - const key = `${process.platform}-${process.arch}` - if (key in GOOD.platforms) expect(resolvePlatform(GOOD)).toBe(key) - else expect(() => resolvePlatform(GOOD)).toThrow(/supported/) - }) -}) +describe("resolvePlatform", () => { + it("honors an explicit valid key and rejects unknown ones", () => { + expect(resolvePlatform(GOOD, "linux-x64")).toBe("linux-x64"); + expect(() => resolvePlatform(GOOD, "windows-x64")).toThrow(/supported/); + }); + it("detects the current machine when no flag given", () => { + const key = `${process.platform}-${process.arch}`; + if (key in GOOD.platforms) expect(resolvePlatform(GOOD)).toBe(key); + else expect(() => resolvePlatform(GOOD)).toThrow(/supported/); + }); +}); function fixtureArchive(): { bytes: Buffer; hash: string } { - const dir = mkdtempSync(join(tmpdir(), 'oc-fixture-')) - writeFileSync(join(dir, 'opencode'), '#!/bin/sh\necho fake-opencode\n') - chmodSync(join(dir, 'opencode'), 0o755) - execFileSync('tar', ['-czf', join(dir, 'a.tar.gz'), '-C', dir, 'opencode']) - const bytes = readFileSync(join(dir, 'a.tar.gz')) - return { bytes, hash: sha256(bytes) } + const dir = mkdtempSync(join(tmpdir(), "oc-fixture-")); + writeFileSync(join(dir, "opencode"), "#!/bin/sh\necho fake-opencode\n"); + chmodSync(join(dir, "opencode"), 0o755); + execFileSync("tar", ["-czf", join(dir, "a.tar.gz"), "-C", dir, "opencode"]); + const bytes = readFileSync(join(dir, "a.tar.gz")); + return { bytes, hash: sha256(bytes) }; } -describe('fetchOpencode', () => { - it('downloads, verifies, unpacks, stamps — then skips on re-run', async () => { - const { bytes, hash } = fixtureArchive() - const root = rootWith({ version: '9.9.9', platforms: { 'linux-x64': { asset: 'a.tar.gz', sha256: hash } } }) - let calls = 0 - const download = async () => { calls++; return bytes } - const r1 = await fetchOpencode({ root, platform: 'linux-x64', download }) - expect(r1.skipped).toBe(false) - const bin = join(root, 'vendor', 'opencode', 'linux-x64', 'opencode') - expect(existsSync(bin)).toBe(true) - expect(readFileSync(join(root, 'vendor', 'opencode', 'linux-x64', '.sha256'), 'utf8').trim()).toBe(hash) - const r2 = await fetchOpencode({ root, platform: 'linux-x64', download }) - expect(r2.skipped).toBe(true) - expect(calls).toBe(1) // idempotent: no second download - }) - it('hard-fails on hash mismatch, printing expected vs actual, installing nothing', async () => { - const { bytes } = fixtureArchive() - const root = rootWith({ version: '9.9.9', platforms: { 'linux-x64': { asset: 'a.tar.gz', sha256: 'ee'.repeat(32) } } }) - await expect(fetchOpencode({ root, platform: 'linux-x64', download: async () => bytes })) - .rejects.toThrow(/expected ee.*actual/s) - expect(existsSync(join(root, 'vendor', 'opencode', 'linux-x64', 'opencode'))).toBe(false) - }) -}) +describe("fetchOpencode", () => { + it("downloads, verifies, unpacks, stamps — then skips on re-run", async () => { + const { bytes, hash } = fixtureArchive(); + const root = rootWith({ version: "9.9.9", platforms: { "linux-x64": { asset: "a.tar.gz", sha256: hash } } }); + let calls = 0; + const download = async () => { + calls++; + return bytes; + }; + const r1 = await fetchOpencode({ root, platform: "linux-x64", download }); + expect(r1.skipped).toBe(false); + const bin = join(root, "vendor", "opencode", "linux-x64", "opencode"); + expect(existsSync(bin)).toBe(true); + expect(readFileSync(join(root, "vendor", "opencode", "linux-x64", ".sha256"), "utf8").trim()).toBe(hash); + const r2 = await fetchOpencode({ root, platform: "linux-x64", download }); + expect(r2.skipped).toBe(true); + expect(calls).toBe(1); // idempotent: no second download + }); + it("hard-fails on hash mismatch, printing expected vs actual, installing nothing", async () => { + const { bytes } = fixtureArchive(); + const root = rootWith({ + version: "9.9.9", + platforms: { "linux-x64": { asset: "a.tar.gz", sha256: "ee".repeat(32) } }, + }); + await expect(fetchOpencode({ root, platform: "linux-x64", download: async () => bytes })).rejects.toThrow( + /expected ee.*actual/s, + ); + expect(existsSync(join(root, "vendor", "opencode", "linux-x64", "opencode"))).toBe(false); + }); +}); -describe('releaseCoords — fork-mirror pinning', async () => { - const { releaseCoords, assetUrl } = await import('../scripts/fetch_opencode.mjs') - const platforms = { 'linux-x64': { asset: 'opencode-linux-x64.tar.gz', sha256: 'a'.repeat(64) } } - it('defaults to upstream at v, public', () => { - const m = { version: '1.17.3', platforms } - expect(releaseCoords(m)).toEqual({ repo: 'sst/opencode', tag: 'v1.17.3', private: false }) - expect(assetUrl(m, 'linux-x64')).toBe( - 'https://github.com/sst/opencode/releases/download/v1.17.3/opencode-linux-x64.tar.gz', - ) - }) - it('repo+tag repoint to the private mirror', () => { - const m = { version: '1.17.3', repo: 'harmoniqs/opencode', tag: 'v1.17.3-amicode.1', platforms } - expect(releaseCoords(m)).toEqual({ repo: 'harmoniqs/opencode', tag: 'v1.17.3-amicode.1', private: true }) - expect(assetUrl(m, 'linux-x64')).toBe( - 'https://github.com/harmoniqs/opencode/releases/download/v1.17.3-amicode.1/opencode-linux-x64.tar.gz', - ) - }) -}) +describe("releaseCoords — fork-mirror pinning", async () => { + const { releaseCoords, assetUrl } = await import("../scripts/fetch_opencode.mjs"); + const platforms = { "linux-x64": { asset: "opencode-linux-x64.tar.gz", sha256: "a".repeat(64) } }; + it("defaults to upstream at v, public", () => { + const m = { version: "1.17.3", platforms }; + expect(releaseCoords(m)).toEqual({ repo: "sst/opencode", tag: "v1.17.3", private: false }); + expect(assetUrl(m, "linux-x64")).toBe( + "https://github.com/sst/opencode/releases/download/v1.17.3/opencode-linux-x64.tar.gz", + ); + }); + it("repo+tag repoint to the private mirror", () => { + const m = { version: "1.17.3", repo: "harmoniqs/opencode", tag: "v1.17.3-amicode.1", platforms }; + expect(releaseCoords(m)).toEqual({ repo: "harmoniqs/opencode", tag: "v1.17.3-amicode.1", private: true }); + expect(assetUrl(m, "linux-x64")).toBe( + "https://github.com/harmoniqs/opencode/releases/download/v1.17.3-amicode.1/opencode-linux-x64.tar.gz", + ); + }); +}); diff --git a/packages/extension/test/hashes.test.ts b/packages/extension/test/hashes.test.ts index ad74dfa8..e7c03461 100644 --- a/packages/extension/test/hashes.test.ts +++ b/packages/extension/test/hashes.test.ts @@ -3,19 +3,19 @@ // hashes.ts uses node:crypto — it is NOT importable into entities.ts (which is // dependency-free / dual-runtime). It follows the score_guard.ts sibling rules: // node: builtins allowed, named exports fine. Exercised here as plain functions. -import { describe, it, expect } from 'vitest' -import { sha256Hex, entityHash } from '../opencode-plugin/hashes' +import { describe, it, expect } from "vitest"; +import { sha256Hex, entityHash } from "../opencode-plugin/hashes"; -describe('sha256Hex', () => { +describe("sha256Hex", () => { it('matches the known vector for "abc"', () => { - expect(sha256Hex('abc')).toBe('ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad') - }) -}) + expect(sha256Hex("abc")).toBe("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); + }); +}); -describe('entityHash', () => { - it('is prefixed with sha256: and stable across key order + excluded keys', () => { - const h = entityHash({ b: 1, a: 2, recorded: 'x' }) - expect(h.startsWith('sha256:')).toBe(true) - expect(entityHash({ a: 2, b: 1 })).toBe(h) - }) -}) +describe("entityHash", () => { + it("is prefixed with sha256: and stable across key order + excluded keys", () => { + const h = entityHash({ b: 1, a: 2, recorded: "x" }); + expect(h.startsWith("sha256:")).toBe(true); + expect(entityHash({ a: 2, b: 1 })).toBe(h); + }); +}); diff --git a/packages/extension/test/healthcheck.test.ts b/packages/extension/test/healthcheck.test.ts index a419f1e0..ef545bef 100644 --- a/packages/extension/test/healthcheck.test.ts +++ b/packages/extension/test/healthcheck.test.ts @@ -1,23 +1,22 @@ -import { describe, it, expect } from 'vitest' -import { resolveChecks } from '../scripts/healthcheck.mjs' +import { describe, it, expect } from "vitest"; +import { resolveChecks } from "../scripts/healthcheck.mjs"; -const ok = { ok: true } -const bad = (reason: string, fix: string) => ({ ok: false, reason, fix }) +const ok = { ok: true }; +const bad = (reason: string, fix: string) => ({ ok: false, reason, fix }); -describe('resolveChecks', () => { - it('exit 0 when all four pass', () => { - const r = resolveChecks({ julia: ok, opencode: ok, amicorun: ok, creds: ok }) - expect(r.exitCode).toBe(0) - expect(r.lines.filter((l: string) => l.startsWith('✓'))).toHaveLength(4) - }) - it('non-zero + precise line when one fails', () => { - const r = resolveChecks({ julia: ok, opencode: bad('no /event 200', 'check opencode'), - amicorun: ok, creds: ok }) - expect(r.exitCode).not.toBe(0) - expect(r.lines.join('\n')).toMatch(/✗ opencode \/event: no \/event 200 → check opencode/) - }) - it('reports every failing check, not just the first', () => { - const r = resolveChecks({ julia: bad('a', 'x'), opencode: ok, amicorun: bad('b', 'y'), creds: ok }) - expect(r.lines.filter((l: string) => l.startsWith('✗'))).toHaveLength(2) - }) -}) +describe("resolveChecks", () => { + it("exit 0 when all four pass", () => { + const r = resolveChecks({ julia: ok, opencode: ok, amicorun: ok, creds: ok }); + expect(r.exitCode).toBe(0); + expect(r.lines.filter((l: string) => l.startsWith("✓"))).toHaveLength(4); + }); + it("non-zero + precise line when one fails", () => { + const r = resolveChecks({ julia: ok, opencode: bad("no /event 200", "check opencode"), amicorun: ok, creds: ok }); + expect(r.exitCode).not.toBe(0); + expect(r.lines.join("\n")).toMatch(/✗ opencode \/event: no \/event 200 → check opencode/); + }); + it("reports every failing check, not just the first", () => { + const r = resolveChecks({ julia: bad("a", "x"), opencode: ok, amicorun: bad("b", "y"), creds: ok }); + expect(r.lines.filter((l: string) => l.startsWith("✗"))).toHaveLength(2); + }); +}); diff --git a/packages/extension/test/lab_config.test.ts b/packages/extension/test/lab_config.test.ts index 176d4f47..7e8cc55a 100644 --- a/packages/extension/test/lab_config.test.ts +++ b/packages/extension/test/lab_config.test.ts @@ -21,21 +21,18 @@ function errs(content: string): string[] { const has = (es: string[], needle: string) => es.some((e) => e.includes(needle)); describe("resolveLabTomlPath", () => { - it("defaults to ~/.amico/lab.toml", () => - expect(resolveLabTomlPath("")).toBe(join(homedir(), ".amico", "lab.toml"))); + it("defaults to ~/.amico/lab.toml", () => expect(resolveLabTomlPath("")).toBe(join(homedir(), ".amico", "lab.toml"))); it("expands a leading ~", () => { expect(resolveLabTomlPath("~")).toBe(homedir()); expect(resolveLabTomlPath("~/x/lab.toml")).toBe(join(homedir(), "x", "lab.toml")); }); - it("uses an explicit path, trimmed", () => - expect(resolveLabTomlPath(" /a/lab.toml ")).toBe("/a/lab.toml")); + it("uses an explicit path, trimmed", () => expect(resolveLabTomlPath(" /a/lab.toml ")).toBe("/a/lab.toml")); }); describe("checkLabToml", () => { it("a missing file is `absent`, not an error (a lab may be provisioned later)", () => expect(checkLabToml(join(tmpdir(), "definitely-absent-lab-dir", "lab.toml")).state).toBe("absent")); - it("a conforming lab.toml is `valid`", () => - expect(checkLabToml(writeLab(VALID)).state).toBe("valid")); + it("a conforming lab.toml is `valid`", () => expect(checkLabToml(writeLab(VALID)).state).toBe("valid")); // field-precise negative matrix (#16 ACs / S17) it("missing required key → names the absent key + path", () => @@ -49,24 +46,32 @@ describe("checkLabToml", () => { it("absent schema_version → field-precise required error", () => expect(has(errs(VALID.replace('schema_version = "1"\n', "")), 'missing required key "schema_version"')).toBe(true)); it("unrecognized schema_version → version-specific error", () => - expect(has(errs(VALID.replace('schema_version = "1"', 'schema_version = "9"')), "/schema_version: unrecognized version")).toBe(true)); + expect( + has(errs(VALID.replace('schema_version = "1"', 'schema_version = "9"')), "/schema_version: unrecognized version"), + ).toBe(true)); it("hardware range bounds are field-precise (#29: omega/drive_max/delta) + name minLength", () => { - expect(has(errs(VALID.replace("omega_GHz = 5.0", "omega_GHz = 999")), "/transmon/omega_GHz: must be <= 100")).toBe(true); - expect(has(errs(VALID.replace("drive_max_GHz = 0.2", "drive_max_GHz = 50")), "/transmon/drive_max_GHz: must be <= 10")).toBe(true); - expect(has(errs(VALID.replace("delta_GHz = 0.2", "delta_GHz = 25")), "/transmon/delta_GHz: must be <= 2")).toBe(true); // garbage anharmonicity - expect(has(errs(VALID.replace('name = "demo-lab"', 'name = ""')), "/lab/name")).toBe(true); // minLength + expect(has(errs(VALID.replace("omega_GHz = 5.0", "omega_GHz = 999")), "/transmon/omega_GHz: must be <= 100")).toBe( + true, + ); + expect( + has(errs(VALID.replace("drive_max_GHz = 0.2", "drive_max_GHz = 50")), "/transmon/drive_max_GHz: must be <= 10"), + ).toBe(true); + expect(has(errs(VALID.replace("delta_GHz = 0.2", "delta_GHz = 25")), "/transmon/delta_GHz: must be <= 2")).toBe( + true, + ); // garbage anharmonicity + expect(has(errs(VALID.replace('name = "demo-lab"', 'name = ""')), "/lab/name")).toBe(true); // minLength }); it("parity over a corpus: checkLabToml === @amicode/schema.validateFile on every input (no second path) [#16]", () => { const corpus = [ - VALID, // valid - VALID.replace("drive_max_GHz = 0.2\n", ""), // missing required - VALID.replace("levels = 3", 'levels = "three"'), // wrong type - VALID.replace("levels = 3", "levels = 99"), // out of range - VALID.replace("delta_GHz = 0.2", "delta_GHz = 25"), // out of range (delta) - VALID + "rogue = 1\n", // unknown key - VALID.replace('schema_version = "1"\n', ""), // absent version + VALID, // valid + VALID.replace("drive_max_GHz = 0.2\n", ""), // missing required + VALID.replace("levels = 3", 'levels = "three"'), // wrong type + VALID.replace("levels = 3", "levels = 99"), // out of range + VALID.replace("delta_GHz = 0.2", "delta_GHz = 25"), // out of range (delta) + VALID + "rogue = 1\n", // unknown key + VALID.replace('schema_version = "1"\n', ""), // absent version VALID.replace('schema_version = "1"', 'schema_version = "9"'), // unrecognized version ]; for (const content of corpus) { @@ -84,7 +89,8 @@ describe("valid lab profiles conform (demo + Schuster)", () => { it("a Schuster-profile lab (negative-convention δ, 4 levels) validates clean", () => { // Distinct from demo-lab: negative anharmonicity convention + a 4-level model, // exercising the schema's range tolerance on a second real-shaped profile. - const schuster = 'schema_version = "1"\n[lab]\nname = "schuster-transmon"\n' + + const schuster = + 'schema_version = "1"\n[lab]\nname = "schuster-transmon"\n' + "[transmon]\nomega_GHz = 4.8\ndelta_GHz = -0.33\nlevels = 4\ndrive_max_GHz = 0.1\n"; expect(checkLabToml(writeLab(schuster)).state).toBe("valid"); }); diff --git a/packages/extension/test/llm_creds.test.ts b/packages/extension/test/llm_creds.test.ts index aee1c442..5fa6f6d9 100644 --- a/packages/extension/test/llm_creds.test.ts +++ b/packages/extension/test/llm_creds.test.ts @@ -1,113 +1,118 @@ -import { describe, it, expect } from 'vitest' -import { resolveLlmCreds, stripProviders, fetchProviderSignal } from '../src/llm_creds.mjs' +import { describe, it, expect } from "vitest"; +import { resolveLlmCreds, stripProviders, fetchProviderSignal } from "../src/llm_creds.mjs"; // 0.3 — the LLM-provider SIGNAL: amico stores/injects no credential; opencode // owns the secret, and amico computes the configured/missing/mismatch signal // from opencode's OWN live /config/providers. Tests cover the pure signal, the // no-leak strip boundary, and the async fetch against a stubbed endpoint. -describe('resolveLlmCreds — pure signal from opencode-resolved providers', () => { - it('not configured → ONE explicit signal when opencode resolves no provider', () => { - const r = resolveLlmCreds({ providers: [] }) - expect(r.ok).toBe(false) +describe("resolveLlmCreds — pure signal from opencode-resolved providers", () => { + it("not configured → ONE explicit signal when opencode resolves no provider", () => { + const r = resolveLlmCreds({ providers: [] }); + expect(r.ok).toBe(false); if (!r.ok) { - expect(r.reason).toMatch(/not configured/i) - expect(r.fix).toMatch(/provider|RUNBOOK/i) + expect(r.reason).toMatch(/not configured/i); + expect(r.fix).toMatch(/provider|RUNBOOK/i); } - }) - it('configured → ok when a provider resolves and no model pins one', () => { - const r = resolveLlmCreds({ providers: [{ id: 'anthropic', source: 'env' }] }) - expect(r).toMatchObject({ ok: true, provider: 'anthropic', source: 'env' }) - }) - it('configured → ok when the model provider is among the resolved ones', () => { + }); + it("configured → ok when a provider resolves and no model pins one", () => { + const r = resolveLlmCreds({ providers: [{ id: "anthropic", source: "env" }] }); + expect(r).toMatchObject({ ok: true, provider: "anthropic", source: "env" }); + }); + it("configured → ok when the model provider is among the resolved ones", () => { const r = resolveLlmCreds({ - providers: [{ id: 'amazon-bedrock', source: 'config' }, { id: 'anthropic', source: 'env' }], - model: 'anthropic/claude-sonnet-4-6', - }) - expect(r).toMatchObject({ ok: true, provider: 'anthropic', source: 'env' }) - }) - it('mismatch → explicit fail when the model points at an unresolved provider', () => { + providers: [ + { id: "amazon-bedrock", source: "config" }, + { id: "anthropic", source: "env" }, + ], + model: "anthropic/claude-sonnet-4-6", + }); + expect(r).toMatchObject({ ok: true, provider: "anthropic", source: "env" }); + }); + it("mismatch → explicit fail when the model points at an unresolved provider", () => { const r = resolveLlmCreds({ - providers: [{ id: 'anthropic', source: 'env' }], - model: 'amazon-bedrock/us.anthropic.claude-sonnet-4-6', - }) - expect(r.ok).toBe(false) + providers: [{ id: "anthropic", source: "env" }], + model: "amazon-bedrock/us.anthropic.claude-sonnet-4-6", + }); + expect(r.ok).toBe(false); if (!r.ok) { - expect(r.reason).toMatch(/amazon-bedrock/) - expect(r.reason).toMatch(/no resolved credentials|resolved:/i) + expect(r.reason).toMatch(/amazon-bedrock/); + expect(r.reason).toMatch(/no resolved credentials|resolved:/i); } - }) + }); it('ignores a model with no provider prefix (falls back to "any resolved")', () => { - const r = resolveLlmCreds({ providers: [{ id: 'openai', source: 'env' }], model: 'weird-model-no-slash' }) - expect(r).toMatchObject({ ok: true, provider: 'openai' }) - }) -}) + const r = resolveLlmCreds({ providers: [{ id: "openai", source: "env" }], model: "weird-model-no-slash" }); + expect(r).toMatchObject({ ok: true, provider: "openai" }); + }); +}); -describe('stripProviders — the no-leak boundary', () => { - it('keeps only {id, source} and DROPS the plaintext key + everything else', () => { +describe("stripProviders — the no-leak boundary", () => { + it("keeps only {id, source} and DROPS the plaintext key + everything else", () => { const raw = { providers: [ - { id: 'anthropic', source: 'env', key: 'sk-ant-SECRET', models: { a: {} }, options: {} }, - { id: 'amazon-bedrock', source: 'config', env: ['AWS_ACCESS_KEY_ID'] }, + { id: "anthropic", source: "env", key: "sk-ant-SECRET", models: { a: {} }, options: {} }, + { id: "amazon-bedrock", source: "config", env: ["AWS_ACCESS_KEY_ID"] }, ], - } - const stripped = stripProviders(raw) + }; + const stripped = stripProviders(raw); expect(stripped).toEqual([ - { id: 'anthropic', source: 'env' }, - { id: 'amazon-bedrock', source: 'config' }, - ]) + { id: "anthropic", source: "env" }, + { id: "amazon-bedrock", source: "config" }, + ]); // The secret must not survive the strip — in ANY field. - expect(JSON.stringify(stripped)).not.toContain('sk-ant-SECRET') - }) - it('tolerates a missing/empty providers array', () => { - expect(stripProviders({})).toEqual([]) - expect(stripProviders(null)).toEqual([]) - expect(stripProviders({ providers: [] })).toEqual([]) - }) -}) + expect(JSON.stringify(stripped)).not.toContain("sk-ant-SECRET"); + }); + it("tolerates a missing/empty providers array", () => { + expect(stripProviders({})).toEqual([]); + expect(stripProviders(null)).toEqual([]); + expect(stripProviders({ providers: [] })).toEqual([]); + }); +}); -describe('fetchProviderSignal — async, against a stubbed opencode server', () => { - const SECRET = 'sk-ant-DO-NOT-LEAK' +describe("fetchProviderSignal — async, against a stubbed opencode server", () => { + const SECRET = "sk-ant-DO-NOT-LEAK"; const stub = (routes: Record, status = 200) => (async (url: string) => { - const path = url.replace(/^https?:\/\/[^/]+/, '') - if (!(path in routes)) return { ok: false, status: 404, json: async () => ({}) } as Response - return { ok: status < 400, status, json: async () => routes[path] } as Response - }) as unknown as typeof fetch + const path = url.replace(/^https?:\/\/[^/]+/, ""); + if (!(path in routes)) return { ok: false, status: 404, json: async () => ({}) } as Response; + return { ok: status < 400, status, json: async () => routes[path] } as Response; + }) as unknown as typeof fetch; - it('ok + which-provider when the live server resolves one, and NEVER returns a key', async () => { + it("ok + which-provider when the live server resolves one, and NEVER returns a key", async () => { const fetchImpl = stub({ - '/config/providers': { providers: [{ id: 'anthropic', source: 'env', key: SECRET }] }, - '/config': { model: 'anthropic/claude-sonnet-4-6' }, - }) - const sig = await fetchProviderSignal('http://127.0.0.1:9', { fetchImpl }) - expect(sig).toMatchObject({ ok: true, provider: 'anthropic', source: 'env' }) + "/config/providers": { providers: [{ id: "anthropic", source: "env", key: SECRET }] }, + "/config": { model: "anthropic/claude-sonnet-4-6" }, + }); + const sig = await fetchProviderSignal("http://127.0.0.1:9", { fetchImpl }); + expect(sig).toMatchObject({ ok: true, provider: "anthropic", source: "env" }); // AC6: the secret in the raw response must not appear anywhere in the signal. - expect(JSON.stringify(sig)).not.toContain(SECRET) - }) - it('not configured when the live server resolves zero providers', async () => { - const fetchImpl = stub({ '/config/providers': { providers: [] }, '/config': {} }) - const sig = await fetchProviderSignal('http://127.0.0.1:9', { fetchImpl }) - expect(sig.ok).toBe(false) - if (!sig.ok) expect(sig.reason).toMatch(/not configured/i) - }) - it('mismatch surfaces through the async path too', async () => { + expect(JSON.stringify(sig)).not.toContain(SECRET); + }); + it("not configured when the live server resolves zero providers", async () => { + const fetchImpl = stub({ "/config/providers": { providers: [] }, "/config": {} }); + const sig = await fetchProviderSignal("http://127.0.0.1:9", { fetchImpl }); + expect(sig.ok).toBe(false); + if (!sig.ok) expect(sig.reason).toMatch(/not configured/i); + }); + it("mismatch surfaces through the async path too", async () => { const fetchImpl = stub({ - '/config/providers': { providers: [{ id: 'anthropic', source: 'env' }] }, - '/config': { model: 'amazon-bedrock/x' }, - }) - const sig = await fetchProviderSignal('http://127.0.0.1:9', { fetchImpl }) - expect(sig.ok).toBe(false) - }) - it('not-ok (not a throw) when /config/providers is unreachable', async () => { - const fetchImpl = (async () => { throw new Error('ECONNREFUSED') }) as unknown as typeof fetch - const sig = await fetchProviderSignal('http://127.0.0.1:9', { fetchImpl }) - expect(sig.ok).toBe(false) - if (!sig.ok) expect(sig.reason).toMatch(/could not query|providers/i) - }) - it('still ok when /config (model) is unavailable — model check is optional', async () => { - const fetchImpl = stub({ '/config/providers': { providers: [{ id: 'openai', source: 'env' }] } }) // no /config route → 404 - const sig = await fetchProviderSignal('http://127.0.0.1:9', { fetchImpl }) - expect(sig).toMatchObject({ ok: true, provider: 'openai' }) - }) -}) + "/config/providers": { providers: [{ id: "anthropic", source: "env" }] }, + "/config": { model: "amazon-bedrock/x" }, + }); + const sig = await fetchProviderSignal("http://127.0.0.1:9", { fetchImpl }); + expect(sig.ok).toBe(false); + }); + it("not-ok (not a throw) when /config/providers is unreachable", async () => { + const fetchImpl = (async () => { + throw new Error("ECONNREFUSED"); + }) as unknown as typeof fetch; + const sig = await fetchProviderSignal("http://127.0.0.1:9", { fetchImpl }); + expect(sig.ok).toBe(false); + if (!sig.ok) expect(sig.reason).toMatch(/could not query|providers/i); + }); + it("still ok when /config (model) is unavailable — model check is optional", async () => { + const fetchImpl = stub({ "/config/providers": { providers: [{ id: "openai", source: "env" }] } }); // no /config route → 404 + const sig = await fetchProviderSignal("http://127.0.0.1:9", { fetchImpl }); + expect(sig).toMatchObject({ ok: true, provider: "openai" }); + }); +}); diff --git a/packages/extension/test/opencode_binary.test.ts b/packages/extension/test/opencode_binary.test.ts index fe3a3af9..5276d3e0 100644 --- a/packages/extension/test/opencode_binary.test.ts +++ b/packages/extension/test/opencode_binary.test.ts @@ -1,34 +1,36 @@ -import { describe, it, expect } from 'vitest' -import { chmodSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { resolveOpencodeBinary, OpencodeMissingError } from '../src/opencode_binary' +import { describe, it, expect } from "vitest"; +import { chmodSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { resolveOpencodeBinary, OpencodeMissingError } from "../src/opencode_binary"; -const platformKey = `${process.platform}-${process.arch}` +const platformKey = `${process.platform}-${process.arch}`; function rootWithVendored(): string { - const root = mkdtempSync(join(tmpdir(), 'ocbin-')) - const dir = join(root, 'vendor', 'opencode', platformKey) - mkdirSync(dir, { recursive: true }) - writeFileSync(join(dir, 'opencode'), '#!/bin/sh\n') - chmodSync(join(dir, 'opencode'), 0o755) - return root + const root = mkdtempSync(join(tmpdir(), "ocbin-")); + const dir = join(root, "vendor", "opencode", platformKey); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "opencode"), "#!/bin/sh\n"); + chmodSync(join(dir, "opencode"), 0o755); + return root; } -describe('resolveOpencodeBinary', () => { - it('config override wins, verbatim', () => { - expect(resolveOpencodeBinary(rootWithVendored(), '/custom/opencode')) - .toEqual({ path: '/custom/opencode', source: 'config-override' }) - }) - it('falls through to the vendored binary when config is empty', () => { - const root = rootWithVendored() - const r = resolveOpencodeBinary(root, '') - expect(r.source).toBe('vendored') - expect(r.path).toBe(join(root, 'vendor', 'opencode', platformKey, 'opencode')) - }) - it('missing vendored binary → actionable hard error, never $PATH', () => { - const empty = mkdtempSync(join(tmpdir(), 'ocbin-empty-')) - expect(() => resolveOpencodeBinary(empty, '')).toThrow(OpencodeMissingError) - expect(() => resolveOpencodeBinary(empty, '')).toThrow(/fetch:opencode|reinstall/) - }) -}) +describe("resolveOpencodeBinary", () => { + it("config override wins, verbatim", () => { + expect(resolveOpencodeBinary(rootWithVendored(), "/custom/opencode")).toEqual({ + path: "/custom/opencode", + source: "config-override", + }); + }); + it("falls through to the vendored binary when config is empty", () => { + const root = rootWithVendored(); + const r = resolveOpencodeBinary(root, ""); + expect(r.source).toBe("vendored"); + expect(r.path).toBe(join(root, "vendor", "opencode", platformKey, "opencode")); + }); + it("missing vendored binary → actionable hard error, never $PATH", () => { + const empty = mkdtempSync(join(tmpdir(), "ocbin-empty-")); + expect(() => resolveOpencodeBinary(empty, "")).toThrow(OpencodeMissingError); + expect(() => resolveOpencodeBinary(empty, "")).toThrow(/fetch:opencode|reinstall/); + }); +}); diff --git a/packages/extension/test/opencode_config.test.ts b/packages/extension/test/opencode_config.test.ts index 043cb0f5..6c2ed9f8 100644 --- a/packages/extension/test/opencode_config.test.ts +++ b/packages/extension/test/opencode_config.test.ts @@ -1,118 +1,128 @@ -import { describe, it, expect } from 'vitest' -import { existsSync, mkdtempSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs' -import { tmpdir, homedir } from 'node:os' -import { join, isAbsolute } from 'node:path' -import { execFileSync } from 'node:child_process' -import { prepareOpencodeProject, resolveJuliaProject, buildOpencodeConfigContent } from '../src/opencode_config' +import { describe, it, expect } from "vitest"; +import { existsSync, mkdtempSync, mkdirSync, writeFileSync, readFileSync } from "node:fs"; +import { tmpdir, homedir } from "node:os"; +import { join, isAbsolute } from "node:path"; +import { execFileSync } from "node:child_process"; +import { prepareOpencodeProject, resolveJuliaProject, buildOpencodeConfigContent } from "../src/opencode_config"; function fakeExtRoot(): string { - const root = mkdtempSync(join(tmpdir(), 'extroot-')) - writeFileSync(join(root, 'AGENTS.md'), '# A\nproject: {{JULIA_PROJECT}}\ntemplate: {{TEMPLATE_PATH}}\n') - mkdirSync(join(root, 'templates')) - writeFileSync(join(root, 'templates', 'solve_template.jl'), '# template\n') - return root + const root = mkdtempSync(join(tmpdir(), "extroot-")); + writeFileSync(join(root, "AGENTS.md"), "# A\nproject: {{JULIA_PROJECT}}\ntemplate: {{TEMPLATE_PATH}}\n"); + mkdirSync(join(root, "templates")); + writeFileSync(join(root, "templates", "solve_template.jl"), "# template\n"); + return root; } -describe('resolveJuliaProject', () => { - const def = join(homedir(), '.amico', 'julia') - it('defaults to ~/.amico/julia when empty or whitespace', () => { - expect(resolveJuliaProject('')).toBe(def) - expect(resolveJuliaProject(' ')).toBe(def) - }) - it('uses a configured value, trimmed', () => { - expect(resolveJuliaProject('/opt/piccolo')).toBe('/opt/piccolo') - expect(resolveJuliaProject(' /opt/p ')).toBe('/opt/p') - }) - it('expands a leading ~ (parity with resolveRunsRoot)', () => { - expect(resolveJuliaProject('~')).toBe(homedir()) - expect(resolveJuliaProject('~/foo/bar')).toBe(join(homedir(), 'foo', 'bar')) - }) -}) +describe("resolveJuliaProject", () => { + const def = join(homedir(), ".amico", "julia"); + it("defaults to ~/.amico/julia when empty or whitespace", () => { + expect(resolveJuliaProject("")).toBe(def); + expect(resolveJuliaProject(" ")).toBe(def); + }); + it("uses a configured value, trimmed", () => { + expect(resolveJuliaProject("/opt/piccolo")).toBe("/opt/piccolo"); + expect(resolveJuliaProject(" /opt/p ")).toBe("/opt/p"); + }); + it("expands a leading ~ (parity with resolveRunsRoot)", () => { + expect(resolveJuliaProject("~")).toBe(homedir()); + expect(resolveJuliaProject("~/foo/bar")).toBe(join(homedir(), "foo", "bar")); + }); +}); -describe('buildOpencodeConfigContent', () => { - const TPL = '/ext/templates/solve_template.jl' - it('emits valid JSON whose instructions points at the (absolute) agents file', () => { - const cfg = JSON.parse(buildOpencodeConfigContent('/abs/AGENTS.md', TPL, '/home/u/.amico/runs/default')) - expect(cfg.instructions).toEqual(['/abs/AGENTS.md']) - }) - it('scopes external_directory to the template + scratch + runs roots (least privilege), drops webfetch', () => { - const cfg = JSON.parse(buildOpencodeConfigContent('/abs/AGENTS.md', TPL, '/home/u/.amico/runs/default')) - const ed = cfg.permission.external_directory - expect(typeof ed).toBe('object') // path-scoped, NOT a blanket "allow" - expect(ed[TPL]).toBe('allow') // the template file the agent reads - expect(ed['/ext/templates/**']).toBe('allow') // its dir (belt-and-suspenders) - expect(ed['/tmp/amicode-work/**']).toBe('allow') // scratch it writes solve.jl into - expect(ed['/private/tmp/amicode-work/**']).toBe('allow') // macOS: /tmp → /private/tmp +describe("buildOpencodeConfigContent", () => { + const TPL = "/ext/templates/solve_template.jl"; + it("emits valid JSON whose instructions points at the (absolute) agents file", () => { + const cfg = JSON.parse(buildOpencodeConfigContent("/abs/AGENTS.md", TPL, "/home/u/.amico/runs/default")); + expect(cfg.instructions).toEqual(["/abs/AGENTS.md"]); + }); + it("scopes external_directory to the template + scratch + runs roots (least privilege), drops webfetch", () => { + const cfg = JSON.parse(buildOpencodeConfigContent("/abs/AGENTS.md", TPL, "/home/u/.amico/runs/default")); + const ed = cfg.permission.external_directory; + expect(typeof ed).toBe("object"); // path-scoped, NOT a blanket "allow" + expect(ed[TPL]).toBe("allow"); // the template file the agent reads + expect(ed["/ext/templates/**"]).toBe("allow"); // its dir (belt-and-suspenders) + expect(ed["/tmp/amicode-work/**"]).toBe("allow"); // scratch it writes solve.jl into + expect(ed["/private/tmp/amicode-work/**"]).toBe("allow"); // macOS: /tmp → /private/tmp // The runs root: AGENTS.md tells the agent to read FINISHED/result.toml for // results and run.log for tracebacks — without this grant every such read is // an external_directory "ask" prompt (one per solve, worse on failures). - expect(ed['/home/u/.amico/runs/default/**']).toBe('allow') - expect(cfg.permission.bash).toBe('allow') // runs amico-run (compound launch) - 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, '/home/u/.amico/runs/default')) - 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, '/home/u/.amico/runs/default', '/elsewhere/amicode_tools.ts')) - expect(cfg.plugin).toEqual(['/elsewhere/amicode_tools.ts']) - }) - it('registers skills.paths only when a stage dir is given (opencode-native skills)', () => { - const without = JSON.parse(buildOpencodeConfigContent('/abs/AGENTS.md', TPL, '/home/u/.amico/runs/default')) - expect(without.skills).toBeUndefined() // no stage dir → no skills key at all + expect(ed["/home/u/.amico/runs/default/**"]).toBe("allow"); + expect(cfg.permission.bash).toBe("allow"); // runs amico-run (compound launch) + 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, "/home/u/.amico/runs/default")); + 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, "/home/u/.amico/runs/default", "/elsewhere/amicode_tools.ts"), + ); + expect(cfg.plugin).toEqual(["/elsewhere/amicode_tools.ts"]); + }); + it("registers skills.paths only when a stage dir is given (opencode-native skills)", () => { + const without = JSON.parse(buildOpencodeConfigContent("/abs/AGENTS.md", TPL, "/home/u/.amico/runs/default")); + expect(without.skills).toBeUndefined(); // no stage dir → no skills key at all const withStage = JSON.parse( - buildOpencodeConfigContent('/abs/AGENTS.md', TPL, '/home/u/.amico/runs/default', undefined, undefined, [], '/tmp/proj/skills'), - ) - expect(withStage.skills).toEqual({ paths: ['/tmp/proj/skills'] }) // absolute per-session dir (guarded set), never a library root - }) - it('declares the pulse-designer agent whose prompt defers to the AGENTS.md interview', () => { - const cfg = JSON.parse(buildOpencodeConfigContent('/abs/AGENTS.md', TPL, '/home/u/.amico/runs/default')) - 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 problems root (default + $AMICODE_PROBLEMS_DIR override)', () => { - const defGrant = join(homedir(), '.amico', 'problems') + '/**' - const cfg = JSON.parse(buildOpencodeConfigContent('/abs/AGENTS.md', TPL, '/home/u/.amico/runs/default')) - expect(cfg.permission.external_directory[defGrant]).toBe('allow') - const prev = process.env.AMICODE_PROBLEMS_DIR - process.env.AMICODE_PROBLEMS_DIR = '/custom/problems' + buildOpencodeConfigContent( + "/abs/AGENTS.md", + TPL, + "/home/u/.amico/runs/default", + undefined, + undefined, + [], + "/tmp/proj/skills", + ), + ); + expect(withStage.skills).toEqual({ paths: ["/tmp/proj/skills"] }); // absolute per-session dir (guarded set), never a library root + }); + it("declares the pulse-designer agent whose prompt defers to the AGENTS.md interview", () => { + const cfg = JSON.parse(buildOpencodeConfigContent("/abs/AGENTS.md", TPL, "/home/u/.amico/runs/default")); + 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 problems root (default + $AMICODE_PROBLEMS_DIR override)", () => { + const defGrant = join(homedir(), ".amico", "problems") + "/**"; + const cfg = JSON.parse(buildOpencodeConfigContent("/abs/AGENTS.md", TPL, "/home/u/.amico/runs/default")); + expect(cfg.permission.external_directory[defGrant]).toBe("allow"); + const prev = process.env.AMICODE_PROBLEMS_DIR; + process.env.AMICODE_PROBLEMS_DIR = "/custom/problems"; try { - const cfg2 = JSON.parse(buildOpencodeConfigContent('/abs/AGENTS.md', TPL, '/home/u/.amico/runs/default')) - expect(cfg2.permission.external_directory['/custom/problems/**']).toBe('allow') // grant follows the plugin + const cfg2 = JSON.parse(buildOpencodeConfigContent("/abs/AGENTS.md", TPL, "/home/u/.amico/runs/default")); + expect(cfg2.permission.external_directory["/custom/problems/**"]).toBe("allow"); // grant follows the plugin } finally { - if (prev === undefined) delete process.env.AMICODE_PROBLEMS_DIR - else process.env.AMICODE_PROBLEMS_DIR = prev + if (prev === undefined) delete process.env.AMICODE_PROBLEMS_DIR; + else process.env.AMICODE_PROBLEMS_DIR = prev; } - }) - it('never embeds a credential in the config content (D11 no-store/no-inject regression guard)', () => { + }); + 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. // Guards against a future edit that starts sourcing a key into the config. - const SENTINEL = 'sk-ant-LEAK5ENTINEL0000000000000000' - const prev = process.env.ANTHROPIC_API_KEY - process.env.ANTHROPIC_API_KEY = SENTINEL + const SENTINEL = "sk-ant-LEAK5ENTINEL0000000000000000"; + const prev = process.env.ANTHROPIC_API_KEY; + process.env.ANTHROPIC_API_KEY = SENTINEL; try { - const content = buildOpencodeConfigContent('/abs/AGENTS.md', TPL, '/home/u/.amico/runs/default') - expect(content).not.toContain(SENTINEL) // no env-sourced key leaks in - expect(content).not.toMatch(/sk-[A-Za-z0-9-]{16,}/) // no key-shaped string at all - expect(content.toLowerCase()).not.toMatch(/"(apikey|api_key|authorization|bearer|token)"\s*:/) + const content = buildOpencodeConfigContent("/abs/AGENTS.md", TPL, "/home/u/.amico/runs/default"); + expect(content).not.toContain(SENTINEL); // no env-sourced key leaks in + expect(content).not.toMatch(/sk-[A-Za-z0-9-]{16,}/); // no key-shaped string at all + expect(content.toLowerCase()).not.toMatch(/"(apikey|api_key|authorization|bearer|token)"\s*:/); } finally { - if (prev === undefined) delete process.env.ANTHROPIC_API_KEY - else process.env.ANTHROPIC_API_KEY = prev + if (prev === undefined) delete process.env.ANTHROPIC_API_KEY; + else process.env.ANTHROPIC_API_KEY = prev; } - }) -}) + }); +}); // Integration (#25): boots the REAL opencode binary (`opencode debug config` // resolves + dumps the merged config, equivalent to GET /config) with the REAL @@ -129,60 +139,79 @@ describe('buildOpencodeConfigContent', () => { // Uses the real builder (no transcribed copy → no drift; boot_smoke.mjs can't // import the TS builder, which is why this lives here). Skipped when the vendored // binary isn't present (e.g. minimal CI before `fetch:opencode`). -const OC_BIN = join(__dirname, '..', 'vendor', 'opencode', `${process.platform}-${process.arch}`, 'opencode') -describe.skipIf(!existsSync(OC_BIN))('opencode config injection + merge (1.17.3)', () => { - it('injects instructions/permission AND preserves the user global model + permission', () => { - const home = mkdtempSync(join(tmpdir(), 'ochome-')) - mkdirSync(join(home, '.config', 'opencode'), { recursive: true }) +const OC_BIN = join(__dirname, "..", "vendor", "opencode", `${process.platform}-${process.arch}`, "opencode"); +describe.skipIf(!existsSync(OC_BIN))("opencode config injection + merge (1.17.3)", () => { + it("injects instructions/permission AND preserves the user global model + permission", () => { + const home = mkdtempSync(join(tmpdir(), "ochome-")); + mkdirSync(join(home, ".config", "opencode"), { recursive: true }); // A user global config with a distinctive model + permission key — both must // survive the deep-merge under OPENCODE_CONFIG_CONTENT. - writeFileSync(join(home, '.config', 'opencode', 'opencode.json'), - JSON.stringify({ model: 'anthropic/claude-sonnet-4-6', permission: { doom_loop: 'deny' } })) - const agentsPath = join(home, 'AGENTS.md') // the exact file our `instructions` must point at - writeFileSync(agentsPath, '# amico\n') - const out = execFileSync(OC_BIN, ['debug', 'config'], { - encoding: 'utf8', - env: { ...process.env, HOME: home, XDG_CONFIG_HOME: join(home, '.config'), - OPENCODE_CONFIG_CONTENT: buildOpencodeConfigContent(agentsPath, '/ext/templates/solve_template.jl', join(home, '.amico', 'runs', 'default')) }, - }) - const cfg = JSON.parse(out) + writeFileSync( + join(home, ".config", "opencode", "opencode.json"), + JSON.stringify({ model: "anthropic/claude-sonnet-4-6", permission: { doom_loop: "deny" } }), + ); + const agentsPath = join(home, "AGENTS.md"); // the exact file our `instructions` must point at + writeFileSync(agentsPath, "# amico\n"); + const out = execFileSync(OC_BIN, ["debug", "config"], { + encoding: "utf8", + env: { + ...process.env, + HOME: home, + XDG_CONFIG_HOME: join(home, ".config"), + OPENCODE_CONFIG_CONTENT: buildOpencodeConfigContent( + agentsPath, + "/ext/templates/solve_template.jl", + join(home, ".amico", "runs", "default"), + ), + }, + }); + const cfg = JSON.parse(out); // our injection landed (the false-green boot_smoke couldn't catch): - expect(cfg.instructions).toContain(agentsPath) // the AGENTS.md instruction injection - expect(typeof cfg.permission.external_directory).toBe('object') // our injected permission key + expect(cfg.instructions).toContain(agentsPath); // the AGENTS.md instruction injection + expect(typeof cfg.permission.external_directory).toBe("object"); // our injected permission key // the runs-root grant survives the real deep-merge — the agent's post-solve // FINISHED/result.toml/run.log read-backs must not "ask" on every run: - expect(cfg.permission.external_directory[join(home, '.amico', 'runs', 'default') + '/**']).toBe('allow') + expect(cfg.permission.external_directory[join(home, ".amico", "runs", "default") + "/**"]).toBe("allow"); // 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) + 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') - }) -}) + 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"); + }); +}); -describe('prepareOpencodeProject', () => { - it('substitutes the julia project AND the absolute template path, leaving no placeholders', () => { - const ext = fakeExtRoot() - const templateSrc = join(ext, 'templates', 'solve_template.jl') - const p = prepareOpencodeProject({ agentsSrc: join(ext, 'AGENTS.md'), templateSrc, juliaProject: '/opt/piccolo', vaultDir: '' }) - const agents = readFileSync(p.agentsPath, 'utf8') - expect(agents).toContain('/opt/piccolo') - expect(agents).toContain(templateSrc) // {{TEMPLATE_PATH}} → the absolute bundled template - expect(agents).not.toMatch(/\{\{.*?\}\}/) // no residual placeholders - expect(p.templatePath).toBe(templateSrc) // points at the bundled source, not a copy - }) - it('does NOT copy the template or write a vestigial .opencode/opencode.json into the session dir', () => { - const ext = fakeExtRoot() - const p = prepareOpencodeProject({ agentsSrc: join(ext, 'AGENTS.md'), - templateSrc: join(ext, 'templates', 'solve_template.jl'), juliaProject: '/opt/piccolo', vaultDir: '' }) - expect(existsSync(join(p.projectDir, 'solve_template.jl'))).toBe(false) - expect(existsSync(join(p.projectDir, '.opencode', 'opencode.json'))).toBe(false) - }) -}) +describe("prepareOpencodeProject", () => { + it("substitutes the julia project AND the absolute template path, leaving no placeholders", () => { + const ext = fakeExtRoot(); + const templateSrc = join(ext, "templates", "solve_template.jl"); + const p = prepareOpencodeProject({ + agentsSrc: join(ext, "AGENTS.md"), + templateSrc, + juliaProject: "/opt/piccolo", + vaultDir: "", + }); + const agents = readFileSync(p.agentsPath, "utf8"); + expect(agents).toContain("/opt/piccolo"); + expect(agents).toContain(templateSrc); // {{TEMPLATE_PATH}} → the absolute bundled template + expect(agents).not.toMatch(/\{\{.*?\}\}/); // no residual placeholders + expect(p.templatePath).toBe(templateSrc); // points at the bundled source, not a copy + }); + it("does NOT copy the template or write a vestigial .opencode/opencode.json into the session dir", () => { + const ext = fakeExtRoot(); + const p = prepareOpencodeProject({ + agentsSrc: join(ext, "AGENTS.md"), + templateSrc: join(ext, "templates", "solve_template.jl"), + juliaProject: "/opt/piccolo", + vaultDir: "", + }); + expect(existsSync(join(p.projectDir, "solve_template.jl"))).toBe(false); + expect(existsSync(join(p.projectDir, ".opencode", "opencode.json"))).toBe(false); + }); +}); diff --git a/packages/extension/test/opencode_paths.test.ts b/packages/extension/test/opencode_paths.test.ts index f365766f..5a140f27 100644 --- a/packages/extension/test/opencode_paths.test.ts +++ b/packages/extension/test/opencode_paths.test.ts @@ -1,43 +1,45 @@ -import { describe, it, expect } from 'vitest' -import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs' -import { homedir, tmpdir } from 'node:os' -import { join } from 'node:path' -import { resolveAmicoRunBinDir, resolveRunsRoot, inspectorResourceRootDirs } from '../src/opencode_paths' +import { describe, it, expect } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { join } from "node:path"; +import { resolveAmicoRunBinDir, resolveRunsRoot, inspectorResourceRootDirs } from "../src/opencode_paths"; -describe('resolveAmicoRunBinDir', () => { - it('prefers the staged bin/launcher when present (packaged VSIX)', () => { - const ext = mkdtempSync(join(tmpdir(), 'ext-')) - mkdirSync(join(ext, 'bin', 'launcher'), { recursive: true }) - writeFileSync(join(ext, 'bin', 'launcher', 'amico-run'), '#!/usr/bin/env bash\n') - expect(resolveAmicoRunBinDir(ext)).toBe(join(ext, 'bin', 'launcher')) - }) - it('falls back to the workspace sibling launcher (dev Extension Host)', () => { - const pkgs = mkdtempSync(join(tmpdir(), 'pkgs-')) - const ext = join(pkgs, 'extension'); mkdirSync(ext, { recursive: true }) - const sib = join(pkgs, 'amico-run', 'launcher'); mkdirSync(sib, { recursive: true }) - writeFileSync(join(sib, 'amico-run'), '#!/usr/bin/env bash\n') - expect(resolveAmicoRunBinDir(ext)).toBe(sib) - }) - it('returns undefined when neither exists', () => { - expect(resolveAmicoRunBinDir(mkdtempSync(join(tmpdir(), 'none-')))).toBeUndefined() - }) -}) +describe("resolveAmicoRunBinDir", () => { + it("prefers the staged bin/launcher when present (packaged VSIX)", () => { + const ext = mkdtempSync(join(tmpdir(), "ext-")); + mkdirSync(join(ext, "bin", "launcher"), { recursive: true }); + writeFileSync(join(ext, "bin", "launcher", "amico-run"), "#!/usr/bin/env bash\n"); + expect(resolveAmicoRunBinDir(ext)).toBe(join(ext, "bin", "launcher")); + }); + it("falls back to the workspace sibling launcher (dev Extension Host)", () => { + const pkgs = mkdtempSync(join(tmpdir(), "pkgs-")); + const ext = join(pkgs, "extension"); + mkdirSync(ext, { recursive: true }); + const sib = join(pkgs, "amico-run", "launcher"); + mkdirSync(sib, { recursive: true }); + writeFileSync(join(sib, "amico-run"), "#!/usr/bin/env bash\n"); + expect(resolveAmicoRunBinDir(ext)).toBe(sib); + }); + it("returns undefined when neither exists", () => { + expect(resolveAmicoRunBinDir(mkdtempSync(join(tmpdir(), "none-")))).toBeUndefined(); + }); +}); -describe('resolveRunsRoot', () => { - it('defaults to ~/.amico/runs/default computed via homedir', () => { - expect(resolveRunsRoot('')).toBe(join(homedir(), '.amico', 'runs', 'default')) - }) - it('expands a leading ~ in a configured value', () => { - expect(resolveRunsRoot('~/custom/runs')).toBe(join(homedir(), 'custom', 'runs')) - }) - it('passes an absolute path through', () => { - expect(resolveRunsRoot('/var/runs')).toBe('/var/runs') - }) -}) +describe("resolveRunsRoot", () => { + it("defaults to ~/.amico/runs/default computed via homedir", () => { + expect(resolveRunsRoot("")).toBe(join(homedir(), ".amico", "runs", "default")); + }); + it("expands a leading ~ in a configured value", () => { + expect(resolveRunsRoot("~/custom/runs")).toBe(join(homedir(), "custom", "runs")); + }); + it("passes an absolute path through", () => { + expect(resolveRunsRoot("/var/runs")).toBe("/var/runs"); + }); +}); -describe('inspectorResourceRootDirs', () => { - it('grants extension assets only — no run-dir roots (the view renders from message data)', () => { - const roots = inspectorResourceRootDirs('/ext') - expect(roots).toEqual(['/ext/dist', '/ext/media']) - }) -}) +describe("inspectorResourceRootDirs", () => { + it("grants extension assets only — no run-dir roots (the view renders from message data)", () => { + const roots = inspectorResourceRootDirs("/ext"); + expect(roots).toEqual(["/ext/dist", "/ext/media"]); + }); +}); diff --git a/packages/extension/test/packaging.test.ts b/packages/extension/test/packaging.test.ts index 098530ef..7e008020 100644 --- a/packages/extension/test/packaging.test.ts +++ b/packages/extension/test/packaging.test.ts @@ -1,41 +1,41 @@ -import { describe, it, expect } from 'vitest' -import { execFileSync } from 'node:child_process' -import { existsSync } from 'node:fs' -import { join } from 'node:path' +import { describe, it, expect } from "vitest"; +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; -const VSIX = join(__dirname, '..', 'amicode.vsix') +const VSIX = join(__dirname, "..", "amicode.vsix"); const REQUIRED = [ - 'extension/bin/dist/amico-run.js', - 'extension/bin/launcher/amico-run', - 'extension/templates/solve_template.jl', + "extension/bin/dist/amico-run.js", + "extension/bin/launcher/amico-run", + "extension/templates/solve_template.jl", // spec C authoring assets — the tiered resolver + verification chain break // silently if any of these is dropped from the vsix. - 'extension/templates/registry.toml', // tier-1 template registry + support set + sandbox uuid map - 'extension/templates/skeleton_free.jl', // tier-3 free-authoring skeleton (contract + verify snapshot) - 'extension/exemplars/EXEMPLARS.toml', // tier-2 seed (build input) - 'extension/exemplars/index.json', // tier-2 index (the artifact amico-run reads) - 'extension/exemplars/rydberg-cz/script.jl', // the seeded exemplar script the index points at - 'extension/julia/verify_rollout.jl', // fixed re-rollout harness — the tier-3 trust anchor - 'extension/julia/Project.toml', - 'extension/julia/Manifest.toml', - 'extension/AGENTS.md', - 'extension/demo/run/run.toml', - 'extension/demo/run/FINISHED', - '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 + "extension/templates/registry.toml", // tier-1 template registry + support set + sandbox uuid map + "extension/templates/skeleton_free.jl", // tier-3 free-authoring skeleton (contract + verify snapshot) + "extension/exemplars/EXEMPLARS.toml", // tier-2 seed (build input) + "extension/exemplars/index.json", // tier-2 index (the artifact amico-run reads) + "extension/exemplars/rydberg-cz/script.jl", // the seeded exemplar script the index points at + "extension/julia/verify_rollout.jl", // fixed re-rollout harness — the tier-3 trust anchor + "extension/julia/Project.toml", + "extension/julia/Manifest.toml", + "extension/AGENTS.md", + "extension/demo/run/run.toml", + "extension/demo/run/FINISHED", + "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 // amicode_* plugin (Bun-transpiled .ts, loaded by absolute path) — every sibling // is load-bearing: a dropped file silently reverts the session to vanilla opencode. - 'extension/opencode-plugin/amicode_tools.ts', - 'extension/opencode-plugin/entities.ts', - 'extension/opencode-plugin/problems.ts', - 'extension/opencode-plugin/hashes.ts', - 'extension/opencode-plugin/score_guard.ts', -] + "extension/opencode-plugin/amicode_tools.ts", + "extension/opencode-plugin/entities.ts", + "extension/opencode-plugin/problems.ts", + "extension/opencode-plugin/hashes.ts", + "extension/opencode-plugin/score_guard.ts", +]; // Guards against a silently-dropped runtime asset (the β.2 .gitignore-fallback // trap, generalized). Locally: inert without a built .vsix (run after @@ -43,14 +43,14 @@ const REQUIRED = [ // vsix-gate job (#45) sets AMICODE_REQUIRE_VSIX=1, under which the suite can // NEVER self-skip — a missing .vsix is a hard failure there, closing the // perennial "2 skip" false-green. -const REQUIRE_VSIX = process.env.AMICODE_REQUIRE_VSIX === '1' -describe.skipIf(!existsSync(VSIX) && !REQUIRE_VSIX)('packaged VSIX contains runtime assets', () => { - it('the .vsix exists (hard requirement under AMICODE_REQUIRE_VSIX=1)', () => { - expect(existsSync(VSIX), `no ${VSIX} — run: pnpm --filter amicode-v2 package`).toBe(true) - }) - it('includes amico-run, template, julia project, AGENTS.md + a vendored opencode', () => { - const listing = execFileSync('unzip', ['-Z1', VSIX], { encoding: 'utf8' }) - for (const p of REQUIRED) expect(listing, `missing ${p}`).toContain(p) - expect(/extension\/vendor\/opencode\/.+\/opencode/.test(listing), 'missing vendored opencode').toBe(true) - }) -}) +const REQUIRE_VSIX = process.env.AMICODE_REQUIRE_VSIX === "1"; +describe.skipIf(!existsSync(VSIX) && !REQUIRE_VSIX)("packaged VSIX contains runtime assets", () => { + it("the .vsix exists (hard requirement under AMICODE_REQUIRE_VSIX=1)", () => { + expect(existsSync(VSIX), `no ${VSIX} — run: pnpm --filter amicode-v2 package`).toBe(true); + }); + it("includes amico-run, template, julia project, AGENTS.md + a vendored opencode", () => { + const listing = execFileSync("unzip", ["-Z1", VSIX], { encoding: "utf8" }); + for (const p of REQUIRED) expect(listing, `missing ${p}`).toContain(p); + expect(/extension\/vendor\/opencode\/.+\/opencode/.test(listing), "missing vendored opencode").toBe(true); + }); +}); diff --git a/packages/extension/test/problems.test.ts b/packages/extension/test/problems.test.ts index 326e4170..43e005ca 100644 --- a/packages/extension/test/problems.test.ts +++ b/packages/extension/test/problems.test.ts @@ -3,11 +3,11 @@ // problems.ts uses node: builtins (fs/path/os) — sibling-module rules, not the // dependency-free entities.ts. Every test points AMICODE_PROBLEMS_DIR at a fresh // temp dir so nothing touches the real ~/.amico. Reads go through .json sidecars. -import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import * as fs from 'node:fs' -import * as os from 'node:os' -import * as path from 'node:path' -import { parse } from 'smol-toml' +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { parse } from "smol-toml"; import { problemsDir, problemDir, @@ -24,218 +24,230 @@ import { writeEntityFiles, lastEventSeq, migrateLegacyEntities, -} from '../opencode-plugin/problems' +} from "../opencode-plugin/problems"; -let tmp: string -let prevEnv: string | undefined +let tmp: string; +let prevEnv: string | undefined; beforeEach(() => { - prevEnv = process.env.AMICODE_PROBLEMS_DIR - tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'amicode-problems-')) - process.env.AMICODE_PROBLEMS_DIR = tmp -}) + prevEnv = process.env.AMICODE_PROBLEMS_DIR; + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "amicode-problems-")); + process.env.AMICODE_PROBLEMS_DIR = tmp; +}); afterEach(() => { - if (prevEnv === undefined) delete process.env.AMICODE_PROBLEMS_DIR - else process.env.AMICODE_PROBLEMS_DIR = prevEnv - fs.rmSync(tmp, { recursive: true, force: true }) -}) - -describe('problemsDir / problemDir', () => { - it('honors AMICODE_PROBLEMS_DIR', () => { - expect(problemsDir()).toBe(tmp) - expect(problemDir('x-gate')).toBe(path.join(tmp, 'x-gate')) - }) -}) - -describe('createProblem', () => { - it('writes problem.toml + .json + entities/ and sets active', () => { - const meta = createProblem('X gate on Q1') - expect(meta.slug).toBe('x-gate-on-q1') - expect(meta.status).toBe('designing') - const dir = problemDir('x-gate-on-q1') - expect(fs.existsSync(path.join(dir, 'problem.toml'))).toBe(true) - expect(fs.existsSync(path.join(dir, 'problem.json'))).toBe(true) - expect(fs.existsSync(path.join(dir, 'entities'))).toBe(true) - expect(readActiveSlug()).toBe('x-gate-on-q1') - const doc = parse(fs.readFileSync(path.join(dir, 'problem.toml'), 'utf8')) as any - expect(doc.problem.name).toBe('X gate on Q1') - }) - it('auto-suffixes a colliding slug', () => { - createProblem('X gate') - const second = createProblem('X gate') - expect(second.slug).toBe('x-gate-2') - }) - it('records a problem/created lifecycle event', () => { - const meta = createProblem('X gate') - const lines = fs.readFileSync(path.join(problemDir(meta.slug), 'events.jsonl'), 'utf8').trim().split('\n') - const evt = JSON.parse(lines[0]) - expect(evt).toMatchObject({ seq: 1, entity: 'problem', action: 'created' }) - expect(Number.isNaN(Date.parse(evt.ts))).toBe(false) - }) -}) - -describe('openProblem', () => { - it('opens by exact slug and by fuzzy name, sets active', () => { - createProblem('X gate on Q1') - createProblem('Y gate on Q2') - expect(openProblem('x-gate-on-q1')?.slug).toBe('x-gate-on-q1') - expect(readActiveSlug()).toBe('x-gate-on-q1') - expect(openProblem('gate on q2')?.slug).toBe('y-gate-on-q2') - expect(openProblem('nonexistent')).toBeUndefined() - }) - it('excludes archived from fuzzy match but still opens by exact slug', () => { - createProblem('X gate on Q1') - archiveProblem('x-gate-on-q1') - expect(openProblem('gate on q1')).toBeUndefined() - expect(openProblem('x-gate-on-q1')?.slug).toBe('x-gate-on-q1') - }) -}) - -describe('renameProblem', () => { - it('renames name only for an established (non-untitled) slug', () => { - createProblem('X gate') - const meta = renameProblem('x-gate', 'X gate on transmon Q1') - expect(meta.slug).toBe('x-gate') // slug immutable - expect(meta.name).toBe('X gate on transmon Q1') - expect(fs.existsSync(problemDir('x-gate'))).toBe(true) - }) - it('re-slugs and renames the dir for an untitled slug, updating active', () => { - const u = ensureActiveProblem() // untitled-* - expect(u.slug.startsWith('untitled')).toBe(true) - const meta = renameProblem(u.slug, 'X gate on Q1') - expect(meta.slug).toBe('x-gate-on-q1') - expect(fs.existsSync(problemDir('x-gate-on-q1'))).toBe(true) - expect(fs.existsSync(problemDir(u.slug))).toBe(false) - expect(readActiveSlug()).toBe('x-gate-on-q1') - }) -}) - -describe('ensureActiveProblem', () => { - it('auto-creates an untitled problem when no active pointer exists', () => { - expect(readActiveSlug()).toBeUndefined() - const meta = ensureActiveProblem() - expect(meta.slug.startsWith('untitled')).toBe(true) - expect(readActiveSlug()).toBe(meta.slug) - }) - it('auto-creates when the active pointer is dangling', () => { - setActiveSlug('deleted-slug') // points at a dir that never existed - const meta = ensureActiveProblem() - expect(meta.slug).not.toBe('deleted-slug') - expect(fs.existsSync(problemDir(meta.slug))).toBe(true) - }) - it('returns the existing active problem when present', () => { - const created = createProblem('X gate') - const active = ensureActiveProblem() - expect(active.slug).toBe(created.slug) - }) -}) - -describe('appendEvent', () => { - it('returns a monotonic seq and writes valid JSONL', () => { - const meta = createProblem('X gate') // seq 1 = created - const s2 = appendEvent(meta.slug, { entity: 'system', action: 'created', diff: { platform: { from: null, to: 'transmon' } }, hash: 'sha256:abc', source: { tool: 'amicode_pick_system', stage: 'platform' } }) - const s3 = appendEvent(meta.slug, { entity: 'system', action: 'updated', diff: { levels: { from: 3, to: 4 } } }) - expect(s2).toBe(2) - expect(s3).toBe(3) - const lines = fs.readFileSync(path.join(problemDir(meta.slug), 'events.jsonl'), 'utf8').trim().split('\n') - expect(lines).toHaveLength(3) - const e2 = JSON.parse(lines[1]) - expect(e2).toMatchObject({ seq: 2, entity: 'system', action: 'created', hash: 'sha256:abc', provenance: null }) - expect(e2.source.tool).toBe('amicode_pick_system') - }) -}) - -describe('lastEventSeq', () => { - it('returns 0 before any event and the highest seq after', () => { - const meta = createProblem('X gate') // created event = seq 1 - expect(lastEventSeq(meta.slug)).toBe(1) - appendEvent(meta.slug, { entity: 'system', action: 'created' }) - expect(lastEventSeq(meta.slug)).toBe(2) - }) -}) - -describe('appendRunRef', () => { - it('appends to both runs.toml and runs.json', () => { - const meta = createProblem('X gate') - appendRunRef(meta.slug, { run_id: '20260703-190412-abcd', lab: 'default', tier: 'vetted', recorded: 't1' }) - appendRunRef(meta.slug, { run_id: '20260703-191500-efgh', lab: 'default', tier: 'free', recorded: 't2' }) - const toml = parse(fs.readFileSync(path.join(problemDir(meta.slug), 'runs.toml'), 'utf8')) as any - expect(toml.runs).toHaveLength(2) - expect(toml.runs[1].tier).toBe('free') - const json = JSON.parse(fs.readFileSync(path.join(problemDir(meta.slug), 'runs.json'), 'utf8')) - expect(json.runs).toHaveLength(2) - expect(json.runs[0].run_id).toBe('20260703-190412-abcd') - }) -}) - -describe('writeEntityFiles', () => { - it('writes entities/.toml + .json', () => { - const meta = createProblem('X gate') - writeEntityFiles(meta.slug, 'system', '[system]\nplatform = "transmon"\n', '{"platform":"transmon"}\n') - const dir = path.join(problemDir(meta.slug), 'entities') - expect(fs.readFileSync(path.join(dir, 'system.toml'), 'utf8')).toContain('transmon') - expect(JSON.parse(fs.readFileSync(path.join(dir, 'system.json'), 'utf8')).platform).toBe('transmon') - }) -}) - -describe('listProblems', () => { - it('lists all problems with status', () => { - createProblem('X gate') - createProblem('Y gate') - archiveProblem('y-gate') - const all = listProblems() - expect(all.map((p) => p.slug).sort()).toEqual(['x-gate', 'y-gate']) - expect(all.find((p) => p.slug === 'y-gate')?.status).toBe('archived') - }) -}) - -describe('migrateLegacyEntities (injectable roots — env-skip lives at the call site)', () => { + if (prevEnv === undefined) delete process.env.AMICODE_PROBLEMS_DIR; + else process.env.AMICODE_PROBLEMS_DIR = prevEnv; + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +describe("problemsDir / problemDir", () => { + it("honors AMICODE_PROBLEMS_DIR", () => { + expect(problemsDir()).toBe(tmp); + expect(problemDir("x-gate")).toBe(path.join(tmp, "x-gate")); + }); +}); + +describe("createProblem", () => { + it("writes problem.toml + .json + entities/ and sets active", () => { + const meta = createProblem("X gate on Q1"); + expect(meta.slug).toBe("x-gate-on-q1"); + expect(meta.status).toBe("designing"); + const dir = problemDir("x-gate-on-q1"); + expect(fs.existsSync(path.join(dir, "problem.toml"))).toBe(true); + expect(fs.existsSync(path.join(dir, "problem.json"))).toBe(true); + expect(fs.existsSync(path.join(dir, "entities"))).toBe(true); + expect(readActiveSlug()).toBe("x-gate-on-q1"); + const doc = parse(fs.readFileSync(path.join(dir, "problem.toml"), "utf8")) as any; + expect(doc.problem.name).toBe("X gate on Q1"); + }); + it("auto-suffixes a colliding slug", () => { + createProblem("X gate"); + const second = createProblem("X gate"); + expect(second.slug).toBe("x-gate-2"); + }); + it("records a problem/created lifecycle event", () => { + const meta = createProblem("X gate"); + const lines = fs + .readFileSync(path.join(problemDir(meta.slug), "events.jsonl"), "utf8") + .trim() + .split("\n"); + const evt = JSON.parse(lines[0]); + expect(evt).toMatchObject({ seq: 1, entity: "problem", action: "created" }); + expect(Number.isNaN(Date.parse(evt.ts))).toBe(false); + }); +}); + +describe("openProblem", () => { + it("opens by exact slug and by fuzzy name, sets active", () => { + createProblem("X gate on Q1"); + createProblem("Y gate on Q2"); + expect(openProblem("x-gate-on-q1")?.slug).toBe("x-gate-on-q1"); + expect(readActiveSlug()).toBe("x-gate-on-q1"); + expect(openProblem("gate on q2")?.slug).toBe("y-gate-on-q2"); + expect(openProblem("nonexistent")).toBeUndefined(); + }); + it("excludes archived from fuzzy match but still opens by exact slug", () => { + createProblem("X gate on Q1"); + archiveProblem("x-gate-on-q1"); + expect(openProblem("gate on q1")).toBeUndefined(); + expect(openProblem("x-gate-on-q1")?.slug).toBe("x-gate-on-q1"); + }); +}); + +describe("renameProblem", () => { + it("renames name only for an established (non-untitled) slug", () => { + createProblem("X gate"); + const meta = renameProblem("x-gate", "X gate on transmon Q1"); + expect(meta.slug).toBe("x-gate"); // slug immutable + expect(meta.name).toBe("X gate on transmon Q1"); + expect(fs.existsSync(problemDir("x-gate"))).toBe(true); + }); + it("re-slugs and renames the dir for an untitled slug, updating active", () => { + const u = ensureActiveProblem(); // untitled-* + expect(u.slug.startsWith("untitled")).toBe(true); + const meta = renameProblem(u.slug, "X gate on Q1"); + expect(meta.slug).toBe("x-gate-on-q1"); + expect(fs.existsSync(problemDir("x-gate-on-q1"))).toBe(true); + expect(fs.existsSync(problemDir(u.slug))).toBe(false); + expect(readActiveSlug()).toBe("x-gate-on-q1"); + }); +}); + +describe("ensureActiveProblem", () => { + it("auto-creates an untitled problem when no active pointer exists", () => { + expect(readActiveSlug()).toBeUndefined(); + const meta = ensureActiveProblem(); + expect(meta.slug.startsWith("untitled")).toBe(true); + expect(readActiveSlug()).toBe(meta.slug); + }); + it("auto-creates when the active pointer is dangling", () => { + setActiveSlug("deleted-slug"); // points at a dir that never existed + const meta = ensureActiveProblem(); + expect(meta.slug).not.toBe("deleted-slug"); + expect(fs.existsSync(problemDir(meta.slug))).toBe(true); + }); + it("returns the existing active problem when present", () => { + const created = createProblem("X gate"); + const active = ensureActiveProblem(); + expect(active.slug).toBe(created.slug); + }); +}); + +describe("appendEvent", () => { + it("returns a monotonic seq and writes valid JSONL", () => { + const meta = createProblem("X gate"); // seq 1 = created + const s2 = appendEvent(meta.slug, { + entity: "system", + action: "created", + diff: { platform: { from: null, to: "transmon" } }, + hash: "sha256:abc", + source: { tool: "amicode_pick_system", stage: "platform" }, + }); + const s3 = appendEvent(meta.slug, { entity: "system", action: "updated", diff: { levels: { from: 3, to: 4 } } }); + expect(s2).toBe(2); + expect(s3).toBe(3); + const lines = fs + .readFileSync(path.join(problemDir(meta.slug), "events.jsonl"), "utf8") + .trim() + .split("\n"); + expect(lines).toHaveLength(3); + const e2 = JSON.parse(lines[1]); + expect(e2).toMatchObject({ seq: 2, entity: "system", action: "created", hash: "sha256:abc", provenance: null }); + expect(e2.source.tool).toBe("amicode_pick_system"); + }); +}); + +describe("lastEventSeq", () => { + it("returns 0 before any event and the highest seq after", () => { + const meta = createProblem("X gate"); // created event = seq 1 + expect(lastEventSeq(meta.slug)).toBe(1); + appendEvent(meta.slug, { entity: "system", action: "created" }); + expect(lastEventSeq(meta.slug)).toBe(2); + }); +}); + +describe("appendRunRef", () => { + it("appends to both runs.toml and runs.json", () => { + const meta = createProblem("X gate"); + appendRunRef(meta.slug, { run_id: "20260703-190412-abcd", lab: "default", tier: "vetted", recorded: "t1" }); + appendRunRef(meta.slug, { run_id: "20260703-191500-efgh", lab: "default", tier: "free", recorded: "t2" }); + const toml = parse(fs.readFileSync(path.join(problemDir(meta.slug), "runs.toml"), "utf8")) as any; + expect(toml.runs).toHaveLength(2); + expect(toml.runs[1].tier).toBe("free"); + const json = JSON.parse(fs.readFileSync(path.join(problemDir(meta.slug), "runs.json"), "utf8")); + expect(json.runs).toHaveLength(2); + expect(json.runs[0].run_id).toBe("20260703-190412-abcd"); + }); +}); + +describe("writeEntityFiles", () => { + it("writes entities/.toml + .json", () => { + const meta = createProblem("X gate"); + writeEntityFiles(meta.slug, "system", '[system]\nplatform = "transmon"\n', '{"platform":"transmon"}\n'); + const dir = path.join(problemDir(meta.slug), "entities"); + expect(fs.readFileSync(path.join(dir, "system.toml"), "utf8")).toContain("transmon"); + expect(JSON.parse(fs.readFileSync(path.join(dir, "system.json"), "utf8")).platform).toBe("transmon"); + }); +}); + +describe("listProblems", () => { + it("lists all problems with status", () => { + createProblem("X gate"); + createProblem("Y gate"); + archiveProblem("y-gate"); + const all = listProblems(); + expect(all.map((p) => p.slug).sort()).toEqual(["x-gate", "y-gate"]); + expect(all.find((p) => p.slug === "y-gate")?.status).toBe("archived"); + }); +}); + +describe("migrateLegacyEntities (injectable roots — env-skip lives at the call site)", () => { function legacyFixture(): string { - const legacy = fs.mkdtempSync(path.join(os.tmpdir(), 'amicode-legacy-')) - fs.writeFileSync(path.join(legacy, 'system.toml'), '[system]\nplatform = "transmon"\n') - fs.writeFileSync(path.join(legacy, 'system.json'), '{"platform":"transmon"}') - fs.writeFileSync(path.join(legacy, 'formulation.toml'), '[formulation]\nproblem = "gate_synthesis"\n') - fs.writeFileSync(path.join(legacy, 'score_manifest.json'), '{"manifest":{}}') - fs.writeFileSync(path.join(legacy, 'interview_state.json'), '{}') - fs.writeFileSync(path.join(legacy, 'usage.jsonl'), '{}\n') - return legacy + const legacy = fs.mkdtempSync(path.join(os.tmpdir(), "amicode-legacy-")); + fs.writeFileSync(path.join(legacy, "system.toml"), '[system]\nplatform = "transmon"\n'); + fs.writeFileSync(path.join(legacy, "system.json"), '{"platform":"transmon"}'); + fs.writeFileSync(path.join(legacy, "formulation.toml"), '[formulation]\nproblem = "gate_synthesis"\n'); + fs.writeFileSync(path.join(legacy, "score_manifest.json"), '{"manifest":{}}'); + fs.writeFileSync(path.join(legacy, "interview_state.json"), "{}"); + fs.writeFileSync(path.join(legacy, "usage.jsonl"), "{}\n"); + return legacy; } - it('reshapes a flat legacy dir into an archived problem workspace + sets active', () => { - const legacy = legacyFixture() - const root = path.join(tmp, 'fresh-problems') // does not exist yet - migrateLegacyEntities(legacy, root) - const dirs = fs.readdirSync(root).filter((d) => d.startsWith('legacy-')) - expect(dirs).toHaveLength(1) - const ws = path.join(root, dirs[0]) + it("reshapes a flat legacy dir into an archived problem workspace + sets active", () => { + const legacy = legacyFixture(); + const root = path.join(tmp, "fresh-problems"); // does not exist yet + migrateLegacyEntities(legacy, root); + const dirs = fs.readdirSync(root).filter((d) => d.startsWith("legacy-")); + expect(dirs).toHaveLength(1); + const ws = path.join(root, dirs[0]); // entity files reshaped under entities/ - expect(fs.existsSync(path.join(ws, 'entities', 'system.toml'))).toBe(true) - expect(fs.existsSync(path.join(ws, 'entities', 'system.json'))).toBe(true) - expect(fs.existsSync(path.join(ws, 'entities', 'formulation.toml'))).toBe(true) + expect(fs.existsSync(path.join(ws, "entities", "system.toml"))).toBe(true); + expect(fs.existsSync(path.join(ws, "entities", "system.json"))).toBe(true); + expect(fs.existsSync(path.join(ws, "entities", "formulation.toml"))).toBe(true); // score-state files at the workspace root - expect(fs.existsSync(path.join(ws, 'score_manifest.json'))).toBe(true) - expect(fs.existsSync(path.join(ws, 'interview_state.json'))).toBe(true) - expect(fs.existsSync(path.join(ws, 'usage.jsonl'))).toBe(true) + expect(fs.existsSync(path.join(ws, "score_manifest.json"))).toBe(true); + expect(fs.existsSync(path.join(ws, "interview_state.json"))).toBe(true); + expect(fs.existsSync(path.join(ws, "usage.jsonl"))).toBe(true); // synthesized archived meta + active set (no other problem) - const meta = JSON.parse(fs.readFileSync(path.join(ws, 'problem.json'), 'utf8')) - expect(meta.status).toBe('archived') - expect(fs.readFileSync(path.join(root, 'active'), 'utf8').trim()).toBe(dirs[0]) - fs.rmSync(legacy, { recursive: true, force: true }) - }) - - it('no-ops when problemsRoot already exists', () => { - const legacy = legacyFixture() - const root = path.join(tmp, 'existing-problems') - fs.mkdirSync(root, { recursive: true }) - migrateLegacyEntities(legacy, root) - expect(fs.readdirSync(root).filter((d) => d.startsWith('legacy-'))).toHaveLength(0) - fs.rmSync(legacy, { recursive: true, force: true }) - }) - - it('no-ops when legacySrc is absent', () => { - const root = path.join(tmp, 'root-no-legacy') - migrateLegacyEntities(path.join(tmp, 'does-not-exist'), root) - expect(fs.existsSync(root)).toBe(false) - }) -}) + const meta = JSON.parse(fs.readFileSync(path.join(ws, "problem.json"), "utf8")); + expect(meta.status).toBe("archived"); + expect(fs.readFileSync(path.join(root, "active"), "utf8").trim()).toBe(dirs[0]); + fs.rmSync(legacy, { recursive: true, force: true }); + }); + + it("no-ops when problemsRoot already exists", () => { + const legacy = legacyFixture(); + const root = path.join(tmp, "existing-problems"); + fs.mkdirSync(root, { recursive: true }); + migrateLegacyEntities(legacy, root); + expect(fs.readdirSync(root).filter((d) => d.startsWith("legacy-"))).toHaveLength(0); + fs.rmSync(legacy, { recursive: true, force: true }); + }); + + it("no-ops when legacySrc is absent", () => { + const root = path.join(tmp, "root-no-legacy"); + migrateLegacyEntities(path.join(tmp, "does-not-exist"), root); + expect(fs.existsSync(root)).toBe(false); + }); +}); diff --git a/packages/extension/test/run_dir_reader_stopped.test.ts b/packages/extension/test/run_dir_reader_stopped.test.ts index 7ddb7f6c..21b9248e 100644 --- a/packages/extension/test/run_dir_reader_stopped.test.ts +++ b/packages/extension/test/run_dir_reader_stopped.test.ts @@ -34,8 +34,14 @@ describe("ingestRunDir — stopped relabel", () => { const runs: Array<{ status: string; fidelity?: number }> = []; const promotes: unknown[] = []; return { - runs, promotes, - sink: { iter() {}, pulse() {}, run: (r: never) => runs.push(r as never), promote: (p: never) => promotes.push(p) }, + runs, + promotes, + sink: { + iter() {}, + pulse() {}, + run: (r: never) => runs.push(r as never), + promote: (p: never) => promotes.push(p), + }, }; } diff --git a/packages/extension/test/scores/allowlist_production.test.ts b/packages/extension/test/scores/allowlist_production.test.ts index 42998a7e..e737096b 100644 --- a/packages/extension/test/scores/allowlist_production.test.ts +++ b/packages/extension/test/scores/allowlist_production.test.ts @@ -18,9 +18,9 @@ describe("production-path entitlement allowlist (bundled assets)", () => { }); it("bundled scores/entitlements.toml carries the [packages] table", () => { - const parsed = parseToml( - fs.readFileSync(path.join(DEFAULT_SCORES_ROOT, "entitlements.toml"), "utf8"), - ) as { packages?: { default?: string[]; issimo?: string[] } }; + const parsed = parseToml(fs.readFileSync(path.join(DEFAULT_SCORES_ROOT, "entitlements.toml"), "utf8")) as { + packages?: { default?: string[]; issimo?: string[] }; + }; expect(parsed.packages?.default).toContain("Piccolo"); expect(parsed.packages?.issimo).toContain("Piccolissimo"); }); diff --git a/packages/extension/test/scores/entitlements_router.test.ts b/packages/extension/test/scores/entitlements_router.test.ts index 03515223..21c99f87 100644 --- a/packages/extension/test/scores/entitlements_router.test.ts +++ b/packages/extension/test/scores/entitlements_router.test.ts @@ -9,9 +9,17 @@ 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, + 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}`, @@ -115,7 +123,11 @@ describe("packageAllowlist (spec C entitlement → package tiers)", () => { it("no entitlements → the five public packages", () => { expect(packageAllowlist(registry, [])).toEqual([ - "Piccolo", "Legato", "Intonato", "NamedTrajectories", "DirectTrajOpt", + "Piccolo", + "Legato", + "Intonato", + "NamedTrajectories", + "DirectTrajOpt", ]); }); it("issimo entitlement → adds the three gated packages", () => { @@ -125,7 +137,11 @@ describe("packageAllowlist (spec C entitlement → package tiers)", () => { }); it("missing file / malformed [packages] → public defaults, never throws", () => { expect(packageAllowlist(path.join(dir, "nope.toml"), ["issimo"])).toEqual([ - "Piccolo", "Legato", "Intonato", "NamedTrajectories", "DirectTrajOpt", + "Piccolo", + "Legato", + "Intonato", + "NamedTrajectories", + "DirectTrajOpt", ]); }); }); diff --git a/packages/extension/test/scores/guard.test.ts b/packages/extension/test/scores/guard.test.ts index 757d884c..a23308ec 100644 --- a/packages/extension/test/scores/guard.test.ts +++ b/packages/extension/test/scores/guard.test.ts @@ -46,7 +46,12 @@ describe("checkStagePrereqs — entity dependencies, not conversation order", () }); 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"] }); + 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 @@ -60,11 +65,19 @@ describe("checkStagePrereqs — entity dependencies, not conversation order", () 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"); + 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"); + 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)", () => { @@ -76,7 +89,10 @@ 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 } })); + 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(); diff --git a/packages/extension/test/scores/overture_routing.test.ts b/packages/extension/test/scores/overture_routing.test.ts index 63113a97..da14ca59 100644 --- a/packages/extension/test/scores/overture_routing.test.ts +++ b/packages/extension/test/scores/overture_routing.test.ts @@ -72,12 +72,32 @@ describe("overture routing predicate (spec §3)", () => { describe("compileChainedScore / chainManifest (unit)", () => { const head: Score = { - manifest: { type: "score", schema_version: 1, id: "overture", version: 1, derived_from: null, name: "O", outcome: "", audience: [], stages: [{ id: "identity" }, { id: "handoff" }] } as never, + manifest: { + type: "score", + schema_version: 1, + id: "overture", + version: 1, + derived_from: null, + name: "O", + outcome: "", + audience: [], + stages: [{ id: "identity" }, { id: "handoff" }], + } as never, body: "OVERTURE BODY", dir: "/scores/overture", }; const tail: Score = { - manifest: { type: "score", schema_version: 1, id: "pulse-designer", version: 3, derived_from: null, name: "P", outcome: "", audience: [], stages: [{ id: "platform" }, { id: "solve", template: "templates/solve.jl" }] } as never, + manifest: { + type: "score", + schema_version: 1, + id: "pulse-designer", + version: 3, + derived_from: null, + name: "P", + outcome: "", + audience: [], + stages: [{ id: "platform" }, { id: "solve", template: "templates/solve.jl" }], + } as never, body: "PULSE BODY", dir: "/scores/pulse-designer", }; diff --git a/packages/extension/test/scores/package_skills.test.ts b/packages/extension/test/scores/package_skills.test.ts index 9d54485f..4e680bd8 100644 --- a/packages/extension/test/scores/package_skills.test.ts +++ b/packages/extension/test/scores/package_skills.test.ts @@ -2,7 +2,12 @@ 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 { resolvePackageSkills, resolveLibrarySkills, buildSkillIndexSection, stageOpencodeSkills } from "../../src/scores/package_skills"; +import { + resolvePackageSkills, + resolveLibrarySkills, + buildSkillIndexSection, + stageOpencodeSkills, +} from "../../src/scores/package_skills"; function mkRoot(): string { return fs.mkdtempSync(path.join(os.tmpdir(), "amicode-skillroot-")); @@ -45,7 +50,8 @@ describe("resolvePackageSkills (spec-20260704-113005 §3)", () => { expect(idx.map((e) => e.name)).toEqual(["authoring"]); }); it("first root containing

.jl/skills wins", () => { - const r1 = mkRoot(), r2 = mkRoot(); + const r1 = mkRoot(), + r2 = mkRoot(); writeSkill(r1, "Piccolissimo", "authoring", "from r1"); writeSkill(r2, "Piccolissimo", "authoring", "from r2"); const idx = resolvePackageSkills(["Piccolissimo"], [r1, r2]); @@ -86,7 +92,13 @@ describe("buildSkillIndexSection", () => { it("renders both entry kinds, the heading, and the invoke-before-authoring instruction", () => { const s = buildSkillIndexSection([ { source: "library", name: "atoms", description: "Rydberg physics", path: "/lib/atoms/SKILL.md" }, - { source: "package", package: "Piccolissimo", name: "authoring", description: "Author solves", path: "/abs/SKILL.md" }, + { + source: "package", + package: "Piccolissimo", + name: "authoring", + description: "Author solves", + path: "/abs/SKILL.md", + }, ]); expect(s).toContain("## Skill index"); // registered opencode skills (platform + package) expect(s).toContain("atoms"); diff --git a/packages/extension/test/scores/prep_integration.test.ts b/packages/extension/test/scores/prep_integration.test.ts index 6cf82322..31554351 100644 --- a/packages/extension/test/scores/prep_integration.test.ts +++ b/packages/extension/test/scores/prep_integration.test.ts @@ -52,16 +52,20 @@ function mkPkgSkillRoot(): string { const root = fs.mkdtempSync(path.join(os.tmpdir(), "pkgskill-")); const d = path.join(root, "Piccolissimo.jl", "skills", "authoring"); fs.mkdirSync(d, { recursive: true }); - fs.writeFileSync(path.join(d, "SKILL.md"), - "---\nname: piccolissimo-authoring\ndescription: author piccolissimo solves\nagents: [experimenter]\n---\n# body\n"); + fs.writeFileSync( + path.join(d, "SKILL.md"), + "---\nname: piccolissimo-authoring\ndescription: author piccolissimo solves\nagents: [experimenter]\n---\n# body\n", + ); return root; } function mkLibRoot(): string { const root = fs.mkdtempSync(path.join(os.tmpdir(), "libskill-")); const d = path.join(root, "atoms"); fs.mkdirSync(d, { recursive: true }); - fs.writeFileSync(path.join(d, "SKILL.md"), - "---\nname: atoms\ndescription: rydberg physics\nagents: [experimenter]\n---\n# body\n"); + fs.writeFileSync( + path.join(d, "SKILL.md"), + "---\nname: atoms\ndescription: rydberg physics\nagents: [experimenter]\n---\n# body\n", + ); return root; } function entitledDir(): string { @@ -120,7 +124,7 @@ describe("prepareOpencodeProject × scores (spec §6)", () => { expect(authoring.schema_version).toBe(1); expect(authoring.allowlist).toEqual(["Piccolo", "Legato", "Intonato", "NamedTrajectories", "DirectTrajOpt"]); expect(authoring.support_set).toEqual(expect.arrayContaining(["JLD2", "CairoMakie", "TOML"])); - expect(authoring.verify_tolerance).toBe(0.001); // spec-20260704-113005 §6 (resolves spec-C open q1) + expect(authoring.verify_tolerance).toBe(0.001); // spec-20260704-113005 §6 (resolves spec-C open q1) // the paths point at REAL bundled assets (Task 9 shipped them) expect(path.isAbsolute(authoring.registry) && fs.existsSync(authoring.registry)).toBe(true); expect(path.isAbsolute(authoring.exemplars) && fs.existsSync(authoring.exemplars)).toBe(true); @@ -150,8 +154,11 @@ describe("buildOpencodeConfigContent × scores", () => { it("grants each indexed skill's OWN dir only — NOT a library root (spec §3, least-privilege)", () => { const cfg = JSON.parse( buildOpencodeConfigContent( - "/abs/AGENTS.md", "/abs/templates/solve_template.jl", "/home/u/.amico/runs/default", - undefined, undefined, + "/abs/AGENTS.md", + "/abs/templates/solve_template.jl", + "/home/u/.amico/runs/default", + undefined, + undefined, ["/lib/atoms/SKILL.md", "/pkgs/Piccolissimo.jl/skills/authoring/SKILL.md"], ), ); @@ -204,7 +211,7 @@ describe("prepareOpencodeProject × skill index (spec §3, Rev 2 — dual-source }); const agents = fs.readFileSync(proj.agentsPath, "utf8"); expect(agents).toContain("Stages, in order:"); // score compile failed → fallback interview - expect(agents).toContain("## Skill index"); // yet the skill index is STILL present + expect(agents).toContain("## Skill index"); // yet the skill index is STILL present const skills = readSkills(); expect(libNames(skills)).toContain("atoms"); expect(pkgNames(skills)).toContain("Piccolissimo"); diff --git a/packages/extension/test/scores/repertoire_lint.test.ts b/packages/extension/test/scores/repertoire_lint.test.ts index 81ab81ba..933d7037 100644 --- a/packages/extension/test/scores/repertoire_lint.test.ts +++ b/packages/extension/test/scores/repertoire_lint.test.ts @@ -9,10 +9,16 @@ 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[] } = {}) { +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 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"), @@ -87,7 +93,9 @@ describe("lintRepertoire", () => { }); it("the REAL shipped repertoire lints clean", () => { - const registry = parseToml(fs.readFileSync(path.join(REAL_SCORES, "entitlements.toml"), "utf8")) as { known: string[] }; + 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 index f8cd87f3..7bf7e9c8 100644 --- a/packages/extension/test/scores/schema.test.ts +++ b/packages/extension/test/scores/schema.test.ts @@ -2,13 +2,23 @@ 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"], + 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: "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" }, @@ -18,27 +28,33 @@ const VALID = { 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"]; + 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"; + 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; + 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; + 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); + 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" }]; + 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", () => { @@ -47,14 +63,24 @@ describe("validateScoreManifest", () => { 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 }; + 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 = []; + 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"])); + expect(KNOWN_ENTITIES).toEqual([ + "circuit", + "system", + "formulation", + "pulse", + "run", + "device_session", + "knowledge", + ])); }); diff --git a/packages/extension/test/slow/interview_e2e.test.ts b/packages/extension/test/slow/interview_e2e.test.ts index 62298ea0..c9f60cdc 100644 --- a/packages/extension/test/slow/interview_e2e.test.ts +++ b/packages/extension/test/slow/interview_e2e.test.ts @@ -1,9 +1,9 @@ -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' +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. @@ -23,19 +23,19 @@ import { buildOpencodeConfigContent, prepareOpencodeProject, resolveJuliaProject // 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 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') +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 + 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 + return Object.keys(JSON.parse(readFileSync(AUTH_JSON, "utf8"))).length > 0; } catch { - return false + return false; } } @@ -43,184 +43,212 @@ function hasCreds(): boolean { * 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'), join(homedir(), '.amico', 'runs', 'default')) + return buildOpencodeConfigContent( + agentsPath, + join(EXT, "templates", "solve_template.jl"), + join(homedir(), ".amico", "runs", "default"), + ); } -interface Server { child: ChildProcess; url: string; log: () => string } -const servers: ChildProcess[] = [] +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 + 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') } + 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 } + 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 + 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)) + 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 } + return { child, url, log: () => buf }; } afterAll(() => { for (const c of servers) { - c.kill('SIGTERM') + 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)', () => { +}); + +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 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 } + 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/) + 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/) + 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) + 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)', + 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 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 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 } + 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') - } + 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) + 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) + 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) + 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 + 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 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) + 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). Entities live in the // active problem workspace now (spec A), not the old global _entities dir. - const problemsRoot = join(homedir(), '.amico', 'problems') - const activeFile = join(problemsRoot, 'active') - const activeSlug = existsSync(activeFile) ? readFileSync(activeFile, 'utf8').trim() : '' - const sysToml = activeSlug ? join(problemsRoot, activeSlug, 'entities', 'system.toml') : '' + const problemsRoot = join(homedir(), ".amico", "problems"); + const activeFile = join(problemsRoot, "active"); + const activeSlug = existsSync(activeFile) ? readFileSync(activeFile, "utf8").trim() : ""; + const sysToml = activeSlug ? join(problemsRoot, activeSlug, "entities", "system.toml") : ""; if (!sysToml || !existsSync(sysToml)) { - console.warn('[tier D] amicode_pick_system was not called — record as prompt-strength finding') + console.warn("[tier D] amicode_pick_system was not called — record as prompt-strength finding"); } }, - ) -}) + ); +}); diff --git a/packages/extension/test/slow/template.test.ts b/packages/extension/test/slow/template.test.ts index 484421e9..3f1e5d39 100644 --- a/packages/extension/test/slow/template.test.ts +++ b/packages/extension/test/slow/template.test.ts @@ -1,26 +1,29 @@ -import { describe, it, expect } from 'vitest' -import { execFileSync } from 'node:child_process' -import { mkdtempSync, readdirSync, existsSync, readFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { parse } from 'smol-toml' +import { describe, it, expect } from "vitest"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readdirSync, existsSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parse } from "smol-toml"; -const PROJECT = process.env.AMICO_TEST_JULIA_PROJECT -const RUN = join(__dirname, '..', '..', '..', 'amico-run', 'dist', 'amico-run.js') // β.1 bundle -const TEMPLATE = join(__dirname, '..', '..', 'templates', 'solve_template.jl') +const PROJECT = process.env.AMICO_TEST_JULIA_PROJECT; +const RUN = join(__dirname, "..", "..", "..", "amico-run", "dist", "amico-run.js"); // β.1 bundle +const TEMPLATE = join(__dirname, "..", "..", "templates", "solve_template.jl"); -describe.skipIf(!PROJECT)('slow: solve_template.jl through amico-run (β.3 AC)', () => { - it('unmodified template → FINISHED{completed} + pulse + iter PNG + AMICODE_ITER', () => { - const root = mkdtempSync(join(tmpdir(), 'tmpl-vet-')) - const stdout = execFileSync('node', [RUN, TEMPLATE, '--runs-root', join(root, 'runs'), - '--project', PROJECT!, '--lab', 'devlab'], { encoding: 'utf8', timeout: 600_000 }) - expect(stdout).toMatch(/AMICODE_ITER iter=/) - expect(stdout).toMatch(/AMICODE_FINISHED status=completed exitCode=0 runDir=/) // mirror β.1 - const runDir = stdout.match(/AMICODE_FINISHED .*runDir=(.+)/)![1].trim() // anchored capture - expect(parse(readFileSync(join(runDir, 'FINISHED'), 'utf8')).status).toBe('completed') - expect(existsSync(join(runDir, 'pulse.jld2'))).toBe(true) - expect(readdirSync(runDir).some(f => /^iter_\d+\.png$/.test(f))).toBe(true) - const r = parse(readFileSync(join(runDir, 'result.toml'), 'utf8')) as Record - expect(r.fidelity as number).toBeGreaterThan(0.99) - }, 600_000) -}) +describe.skipIf(!PROJECT)("slow: solve_template.jl through amico-run (β.3 AC)", () => { + it("unmodified template → FINISHED{completed} + pulse + iter PNG + AMICODE_ITER", () => { + const root = mkdtempSync(join(tmpdir(), "tmpl-vet-")); + const stdout = execFileSync( + "node", + [RUN, TEMPLATE, "--runs-root", join(root, "runs"), "--project", PROJECT!, "--lab", "devlab"], + { encoding: "utf8", timeout: 600_000 }, + ); + expect(stdout).toMatch(/AMICODE_ITER iter=/); + expect(stdout).toMatch(/AMICODE_FINISHED status=completed exitCode=0 runDir=/); // mirror β.1 + const runDir = stdout.match(/AMICODE_FINISHED .*runDir=(.+)/)![1].trim(); // anchored capture + expect(parse(readFileSync(join(runDir, "FINISHED"), "utf8")).status).toBe("completed"); + expect(existsSync(join(runDir, "pulse.jld2"))).toBe(true); + expect(readdirSync(runDir).some((f) => /^iter_\d+\.png$/.test(f))).toBe(true); + const r = parse(readFileSync(join(runDir, "result.toml"), "utf8")) as Record; + expect(r.fidelity as number).toBeGreaterThan(0.99); + }, 600_000); +}); diff --git a/packages/extension/test/slow/verify_harness.test.ts b/packages/extension/test/slow/verify_harness.test.ts index 9c8d0a73..e7848567 100644 --- a/packages/extension/test/slow/verify_harness.test.ts +++ b/packages/extension/test/slow/verify_harness.test.ts @@ -1,9 +1,9 @@ -import { describe, it, expect } from 'vitest' -import { execFileSync } from 'node:child_process' -import { mkdtempSync, writeFileSync, existsSync, readFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { parse } from 'smol-toml' +import { describe, it, expect } from "vitest"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, writeFileSync, existsSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parse } from "smol-toml"; // Live golden test for the tier-3 re-rollout harness (spec C). Gated on a real // Julia+Piccolo project (same gate the template slow test uses). Builds a @@ -12,8 +12,8 @@ import { parse } from 'smol-toml' // pulse so the harness's re-rollout disagrees → agree=false. This exercises the // full harness plumbing (jld2 read → QuantumSystem reconstruction → // unitary_rollout → unitary_fidelity → verification.toml). -const PROJECT = process.env.AMICO_TEST_JULIA_PROJECT -const HARNESS = join(__dirname, '..', '..', 'julia', 'verify_rollout.jl') +const PROJECT = process.env.AMICO_TEST_JULIA_PROJECT; +const HARNESS = join(__dirname, "..", "..", "julia", "verify_rollout.jl"); // Fixture builder (Julia): construct the SAME system the vetted template uses, // take a pulse, record its native-rollout fidelity, and serialize the tier-3 @@ -54,28 +54,29 @@ if scale != 1.0 end JLD2.save(joinpath(run_dir, "pulse.jld2"), "traj", traj) println("FIXTURE fid=$(fid)") -` +`; function stageAndVerify(scale: number): Record { - const runDir = mkdtempSync(join(tmpdir(), 'verify-golden-')) - writeFileSync(join(runDir, 'fixture.jl'), FIXTURE) - execFileSync('julia', [`--project=${PROJECT}`, join(runDir, 'fixture.jl'), runDir, String(scale)], - { encoding: 'utf8', timeout: 600_000 }) - execFileSync('julia', [`--project=${PROJECT}`, HARNESS, runDir, '0.01'], - { encoding: 'utf8', timeout: 600_000 }) - expect(existsSync(join(runDir, 'verification.toml'))).toBe(true) - return parse(readFileSync(join(runDir, 'verification.toml'), 'utf8')) as Record + const runDir = mkdtempSync(join(tmpdir(), "verify-golden-")); + writeFileSync(join(runDir, "fixture.jl"), FIXTURE); + execFileSync("julia", [`--project=${PROJECT}`, join(runDir, "fixture.jl"), runDir, String(scale)], { + encoding: "utf8", + timeout: 600_000, + }); + execFileSync("julia", [`--project=${PROJECT}`, HARNESS, runDir, "0.01"], { encoding: "utf8", timeout: 600_000 }); + expect(existsSync(join(runDir, "verification.toml"))).toBe(true); + return parse(readFileSync(join(runDir, "verification.toml"), "utf8")) as Record; } -describe.skipIf(!PROJECT)('slow: verify_rollout.jl golden (spec C tier-3 harness)', () => { - it('unmodified pulse round-trips with agree=true', () => { - const v = stageAndVerify(1.0) - expect(v.integrator).toBe('piccolo_unitary_rollout') - expect(v.agree).toBe(true) - expect(Math.abs((v.fidelity_rerolled as number) - (v.fidelity_reported as number))).toBeLessThanOrEqual(0.01) - }, 600_000) - it('corrupted pulse (×0.5) → re-rollout disagrees, agree=false', () => { - const v = stageAndVerify(0.5) - expect(v.agree).toBe(false) - }, 600_000) -}) +describe.skipIf(!PROJECT)("slow: verify_rollout.jl golden (spec C tier-3 harness)", () => { + it("unmodified pulse round-trips with agree=true", () => { + const v = stageAndVerify(1.0); + expect(v.integrator).toBe("piccolo_unitary_rollout"); + expect(v.agree).toBe(true); + expect(Math.abs((v.fidelity_rerolled as number) - (v.fidelity_reported as number))).toBeLessThanOrEqual(0.01); + }, 600_000); + it("corrupted pulse (×0.5) → re-rollout disagrees, agree=false", () => { + const v = stageAndVerify(0.5); + expect(v.agree).toBe(false); + }, 600_000); +}); diff --git a/packages/extension/test/slow/verify_spline_free_phase.test.ts b/packages/extension/test/slow/verify_spline_free_phase.test.ts index 49c0d13c..f34ced06 100644 --- a/packages/extension/test/slow/verify_spline_free_phase.test.ts +++ b/packages/extension/test/slow/verify_spline_free_phase.test.ts @@ -1,9 +1,9 @@ -import { describe, it, expect } from 'vitest' -import { execFileSync } from 'node:child_process' -import { mkdtempSync, existsSync, readFileSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { parse } from 'smol-toml' +import { describe, it, expect } from "vitest"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, existsSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parse } from "smol-toml"; // Live golden for the spline/free-phase harness (spec-20260704-113005 §6/§9). // make_verify_golden.jl builds a REAL qubit⊗qutrit [2,3] EmbeddedOperator (unequal @@ -12,36 +12,36 @@ import { parse } from 'smol-toml' // binary-decomposition builder — agreement validates the convention on unequal // levels — and must FAIL CLOSED when a spline solve omits the dense pulse. // (The PWC/fixed-phase path is covered by verify_harness.test.ts, unchanged.) -const PROJECT = process.env.AMICO_TEST_JULIA_PROJECT -const GEN = join(__dirname, '..', '..', 'julia', 'make_verify_golden.jl') -const HARNESS = join(__dirname, '..', '..', 'julia', 'verify_rollout.jl') +const PROJECT = process.env.AMICO_TEST_JULIA_PROJECT; +const GEN = join(__dirname, "..", "..", "julia", "make_verify_golden.jl"); +const HARNESS = join(__dirname, "..", "..", "julia", "verify_rollout.jl"); function genGolden(): string { - const dir = mkdtempSync(join(tmpdir(), 'verify-fp-')) - execFileSync('julia', [`--project=${PROJECT}`, GEN, dir], { encoding: 'utf8', timeout: 600_000 }) - return dir + const dir = mkdtempSync(join(tmpdir(), "verify-fp-")); + execFileSync("julia", [`--project=${PROJECT}`, GEN, dir], { encoding: "utf8", timeout: 600_000 }); + return dir; } function runHarness(dir: string): Record { - execFileSync('julia', [`--project=${PROJECT}`, HARNESS, dir, '0.001'], { encoding: 'utf8', timeout: 600_000 }) - expect(existsSync(join(dir, 'verification.toml'))).toBe(true) - return parse(readFileSync(join(dir, 'verification.toml'), 'utf8')) as Record + execFileSync("julia", [`--project=${PROJECT}`, HARNESS, dir, "0.001"], { encoding: "utf8", timeout: 600_000 }); + expect(existsSync(join(dir, "verification.toml"))).toBe(true); + return parse(readFileSync(join(dir, "verification.toml"), "utf8")) as Record; } -describe.skipIf(!PROJECT)('slow: verify_rollout.jl spline + free-phase (spec-20260704-113005 §6/§9)', () => { - it('dense pulse + free-phase [2,3] → agree=true via the binary-decomposition builder', () => { - const v = runHarness(genGolden()) - expect(v.integrator).toBe('piccolo_unitary_rollout_dense') - expect(v.agree).toBe(true) - expect(Math.abs((v.fidelity_rerolled as number) - (v.fidelity_reported as number))).toBeLessThanOrEqual(0.001) - }, 600_000) +describe.skipIf(!PROJECT)("slow: verify_rollout.jl spline + free-phase (spec-20260704-113005 §6/§9)", () => { + it("dense pulse + free-phase [2,3] → agree=true via the binary-decomposition builder", () => { + const v = runHarness(genGolden()); + expect(v.integrator).toBe("piccolo_unitary_rollout_dense"); + expect(v.agree).toBe(true); + expect(Math.abs((v.fidelity_rerolled as number) - (v.fidelity_reported as number))).toBeLessThanOrEqual(0.001); + }, 600_000); - it('spline solve with pulse_dense.jld2 missing → fails closed (missing_dense_pulse)', () => { - const dir = genGolden() - rmSync(join(dir, 'pulse_dense.jld2')) - const v = runHarness(dir) - expect(v.agree).toBe(false) - expect(v.error).toBe('missing_dense_pulse') - expect(v.integrator).toBe('none') - expect(v.fidelity_rerolled).toBe('nan') // string fallback convention (verify.ts writeFallback) - }, 600_000) -}) + it("spline solve with pulse_dense.jld2 missing → fails closed (missing_dense_pulse)", () => { + const dir = genGolden(); + rmSync(join(dir, "pulse_dense.jld2")); + const v = runHarness(dir); + expect(v.agree).toBe(false); + expect(v.error).toBe("missing_dense_pulse"); + expect(v.integrator).toBe("none"); + expect(v.fidelity_rerolled).toBe("nan"); // string fallback convention (verify.ts writeFallback) + }, 600_000); +}); diff --git a/packages/extension/test/sparkline.test.ts b/packages/extension/test/sparkline.test.ts index a4473a0b..ca108d88 100644 --- a/packages/extension/test/sparkline.test.ts +++ b/packages/extension/test/sparkline.test.ts @@ -9,7 +9,9 @@ describe("makeSparkBuffer", () => { }); it("reset clears", () => { const b = makeSparkBuffer(3); - b.push(1); b.push(2); b.reset(); + b.push(1); + b.push(2); + b.reset(); expect(b.values()).toEqual([]); }); it("returns a copy (caller can't mutate internal state)", () => { diff --git a/packages/extension/test/substrate/user_splice.test.ts b/packages/extension/test/substrate/user_splice.test.ts index ddd2ab5f..9d237f94 100644 --- a/packages/extension/test/substrate/user_splice.test.ts +++ b/packages/extension/test/substrate/user_splice.test.ts @@ -2,7 +2,11 @@ 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 { buildAboutUserSection, buildRecentProblemsSection, buildReferenceDemosSection } from "../../src/substrate/user_splice"; +import { + buildAboutUserSection, + buildRecentProblemsSection, + buildReferenceDemosSection, +} from "../../src/substrate/user_splice"; import { buildOpencodeConfigContent, prepareOpencodeProject } from "../../src/opencode_config"; describe("buildAboutUserSection (spec §6)", () => { @@ -85,7 +89,9 @@ describe("buildReferenceDemosSection (L1 §3)", () => { expect(buildReferenceDemosSection([])).toBe(""); }); it("renders demo lines + the precedent/medium-confidence instruction", () => { - const s = buildReferenceDemosSection(["- [stanford-bosonics-cat](demos/stanford-bosonics-cat.md) — cavity cat, N_fock=20"]); + const s = buildReferenceDemosSection([ + "- [stanford-bosonics-cat](demos/stanford-bosonics-cat.md) — cavity cat, N_fock=20", + ]); expect(s).toContain("## Reference demos"); expect(s).toContain("N_fock=20"); expect(s).toMatch(/precedent/i); diff --git a/packages/extension/test/substrate/vault_store.test.ts b/packages/extension/test/substrate/vault_store.test.ts index 602401d8..91a8f1a8 100644 --- a/packages/extension/test/substrate/vault_store.test.ts +++ b/packages/extension/test/substrate/vault_store.test.ts @@ -99,7 +99,10 @@ describe("hasOnboardingCompleted (spec §3 routing predicate, second disjunct)", it("malformed lines are skipped, not fatal", () => { const dir = path.join(mkTmp("ops-"), "onboarding"); fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(path.join(dir, "events.jsonl"), "not json\n" + JSON.stringify({ entity: "onboarding_completed" }) + "\n"); + fs.writeFileSync( + path.join(dir, "events.jsonl"), + "not json\n" + JSON.stringify({ entity: "onboarding_completed" }) + "\n", + ); expect(hasOnboardingCompleted(dir)).toBe(true); }); }); diff --git a/packages/schema/.DS_Store b/packages/schema/.DS_Store index 48a56093c13b986ccad23e14bc15e225daf3775d..56f51e04ca7c10deb9427899ca5907dbb094aab6 100644 GIT binary patch delta 47 zcmZp1XmQw}CctFYyIDhEJ|ko5A}l-r DbGi=w delta 47 zcmV+~0MP%0K!iZBCJ+KEn6oGlp8)}1lbjJ8ljISQ0w?{mb`ll=0wUy-juUVKMD?;1 F1Ppy-56A!j diff --git a/packages/schema/esbuild.config.mjs b/packages/schema/esbuild.config.mjs index 9e0bdbc8..b2a964a6 100644 --- a/packages/schema/esbuild.config.mjs +++ b/packages/schema/esbuild.config.mjs @@ -1,19 +1,19 @@ -import { build } from 'esbuild' -import { chmodSync } from 'node:fs' +import { build } from "esbuild"; +import { chmodSync } from "node:fs"; // The library is consumed as TS source (main = src/index.ts; consumers bundle it // via their own esbuild). We bundle two artifacts here: // - dist/index.js: a smoke check that the dep graph (ajv + ajv-formats + the // JSON schemas) bundles cleanly into a single ESM module. // - dist/amico-validate.js: the standalone validator CLI (0.1c). -const common = { bundle: true, platform: 'node', target: 'node20', format: 'esm', sourcemap: true, logLevel: 'info' } +const common = { bundle: true, platform: "node", target: "node20", format: "esm", sourcemap: true, logLevel: "info" }; -await build({ ...common, entryPoints: ['src/index.ts'], outfile: 'dist/index.js' }) +await build({ ...common, entryPoints: ["src/index.ts"], outfile: "dist/index.js" }); await build({ ...common, - entryPoints: ['src/cli.ts'], - outfile: 'dist/amico-validate.js', - banner: { js: '#!/usr/bin/env node' }, -}) -chmodSync('dist/amico-validate.js', 0o755) + entryPoints: ["src/cli.ts"], + outfile: "dist/amico-validate.js", + banner: { js: "#!/usr/bin/env node" }, +}); +chmodSync("dist/amico-validate.js", 0o755); diff --git a/packages/schema/package.json b/packages/schema/package.json index 0d1604a6..fa1e0c60 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -5,8 +5,12 @@ "type": "module", "main": "./src/index.ts", "types": "./src/index.ts", - "bin": { "amico-validate": "./launcher/amico-validate" }, - "engines": { "node": ">=20" }, + "bin": { + "amico-validate": "./launcher/amico-validate" + }, + "engines": { + "node": ">=20" + }, "scripts": { "build": "node esbuild.config.mjs", "typecheck": "tsc --noEmit", diff --git a/packages/schema/schemas/catalog-entry.schema.json b/packages/schema/schemas/catalog-entry.schema.json index 219b7ba8..fcb44faa 100644 --- a/packages/schema/schemas/catalog-entry.schema.json +++ b/packages/schema/schemas/catalog-entry.schema.json @@ -12,8 +12,16 @@ "lab_id": { "type": "string", "minLength": 1 }, "gate": { "type": "string", "description": "target gate label, if recorded" }, "fidelity": { "type": "number", "minimum": 0, "maximum": 1.0001 }, - "pulse_path": { "type": "string", "minLength": 1, "description": "path/ref to the promoted pulse artifact (e.g. pulse.jld2)" }, + "pulse_path": { + "type": "string", + "minLength": 1, + "description": "path/ref to the promoted pulse artifact (e.g. pulse.jld2)" + }, "created_at": { "type": "string", "minLength": 1, "format": "date-time" }, - "params": { "type": "object", "additionalProperties": true, "description": "the regime solved (self-describing), copied from result.toml" } + "params": { + "type": "object", + "additionalProperties": true, + "description": "the regime solved (self-describing), copied from result.toml" + } } } diff --git a/packages/schema/schemas/lab.schema.json b/packages/schema/schemas/lab.schema.json index d38536d0..fd5f8f99 100644 --- a/packages/schema/schemas/lab.schema.json +++ b/packages/schema/schemas/lab.schema.json @@ -21,10 +21,30 @@ "additionalProperties": false, "required": ["omega_GHz", "delta_GHz", "levels", "drive_max_GHz"], "properties": { - "omega_GHz": { "type": "number", "exclusiveMinimum": 0, "maximum": 100, "description": "qubit transition frequency (GHz)" }, - "delta_GHz": { "type": "number", "minimum": -2, "maximum": 2, "description": "anharmonicity (GHz; positive convention in the beta template). Bounded to catch garbage/sign-flipped values — physical |δ| is ~0.1–0.5 GHz; the sign convention itself isn't enforced." }, - "levels": { "type": "integer", "minimum": 2, "maximum": 10, "description": "transmon levels modeled (qubit + leakage)" }, - "drive_max_GHz": { "type": "number", "exclusiveMinimum": 0, "maximum": 10, "description": "per-quadrature drive bound (GHz)" } + "omega_GHz": { + "type": "number", + "exclusiveMinimum": 0, + "maximum": 100, + "description": "qubit transition frequency (GHz)" + }, + "delta_GHz": { + "type": "number", + "minimum": -2, + "maximum": 2, + "description": "anharmonicity (GHz; positive convention in the beta template). Bounded to catch garbage/sign-flipped values — physical |δ| is ~0.1–0.5 GHz; the sign convention itself isn't enforced." + }, + "levels": { + "type": "integer", + "minimum": 2, + "maximum": 10, + "description": "transmon levels modeled (qubit + leakage)" + }, + "drive_max_GHz": { + "type": "number", + "exclusiveMinimum": 0, + "maximum": 10, + "description": "per-quadrature drive bound (GHz)" + } } } } diff --git a/packages/schema/schemas/result.schema.json b/packages/schema/schemas/result.schema.json index d42cbe38..4799da2c 100644 --- a/packages/schema/schemas/result.schema.json +++ b/packages/schema/schemas/result.schema.json @@ -8,7 +8,12 @@ "required": ["schema_version", "fidelity", "iterations"], "properties": { "schema_version": { "enum": ["1"] }, - "fidelity": { "type": "number", "minimum": 0, "maximum": 1.0001, "description": "subspace gate fidelity (rollout-based; allow a few ulp over 1 for numerical noise, reject gross out-of-range)" }, + "fidelity": { + "type": "number", + "minimum": 0, + "maximum": 1.0001, + "description": "subspace gate fidelity (rollout-based; allow a few ulp over 1 for numerical noise, reject gross out-of-range)" + }, "iterations": { "type": "integer", "minimum": 0 }, "wall_seconds": { "type": "number", "minimum": 0 }, "pulse_kind": { @@ -20,11 +25,13 @@ "description": "Which convention `fidelity` reports. Absent = fixed. free_phase requires free_phases + subsystem_levels." }, "free_phases": { - "type": "array", "items": { "type": "number" }, + "type": "array", + "items": { "type": "number" }, "description": "Optimized virtual-Z phases (rad), one per subsystem in order; applied post-hoc by the harness — never in dynamics." }, "subsystem_levels": { - "type": "array", "items": { "type": "integer", "minimum": 2 }, + "type": "array", + "items": { "type": "integer", "minimum": 2 }, "description": "Subsystem dimensions for the phase-operator construction." }, "params": { diff --git a/packages/schema/schemas/run.schema.json b/packages/schema/schemas/run.schema.json index a0cc0763..70278afa 100644 --- a/packages/schema/schemas/run.schema.json +++ b/packages/schema/schemas/run.schema.json @@ -5,10 +5,25 @@ "description": "Per-run identity + provenance, written FIRST by amico-run (formerly manifest.toml — renamed to avoid colliding with Julia's Manifest.toml on case-insensitive filesystems). The per-run schema_version carrier for the run-dir contract.", "type": "object", "additionalProperties": false, - "required": ["schema_version", "run_id", "script_path", "lab", "lab_id", "created_at", "orchestrator_version", "julia"], + "required": [ + "schema_version", + "run_id", + "script_path", + "lab", + "lab_id", + "created_at", + "orchestrator_version", + "julia" + ], "properties": { - "schema_version": { "enum": ["1", "2"], "description": "run-dir contract version (enum = the supported-version SET; grows by one entry per bump). v2 (spec C) adds tier + [hashes] for --spec launches" }, - "tier": { "enum": ["vetted", "composed", "free"], "description": "trust tier stamped by amico-run when launched via --spec (v2)" }, + "schema_version": { + "enum": ["1", "2"], + "description": "run-dir contract version (enum = the supported-version SET; grows by one entry per bump). v2 (spec C) adds tier + [hashes] for --spec launches" + }, + "tier": { + "enum": ["vetted", "composed", "free"], + "description": "trust tier stamped by amico-run when launched via --spec (v2)" + }, "hashes": { "type": "object", "additionalProperties": false, diff --git a/packages/schema/schemas/solvespec.schema.json b/packages/schema/schemas/solvespec.schema.json index 0f70eee5..9dedc2b5 100644 --- a/packages/schema/schemas/solvespec.schema.json +++ b/packages/schema/schemas/solvespec.schema.json @@ -9,17 +9,34 @@ "properties": { "schema_version": { "enum": ["1", "2"] }, "script_path": { "type": "string", "minLength": 1, "description": "the Julia script to run" }, - "lab_id": { "type": "string", "minLength": 1, "description": "lab pointer (id or path) — physics params live in the lab.toml/script, not here" }, + "lab_id": { + "type": "string", + "minLength": 1, + "description": "lab pointer (id or path) — physics params live in the lab.toml/script, not here" + }, "gate": { "type": "string", "description": "target gate label (e.g. X, H), if known at assembly" }, - "params": { "type": "object", "additionalProperties": true, "description": "lenient solve-knob block (T, N, max_iter, …)" }, - "executor": { "enum": ["local"], "description": "whose machine runs it — per-solve and explicit (Δ10); only local exists today" }, - "tier": { "enum": ["vetted", "composed", "free"], "description": "trust tier of the authored script (spec C resolver)" }, + "params": { + "type": "object", + "additionalProperties": true, + "description": "lenient solve-knob block (T, N, max_iter, …)" + }, + "executor": { + "enum": ["local"], + "description": "whose machine runs it — per-solve and explicit (Δ10); only local exists today" + }, + "tier": { + "enum": ["vetted", "composed", "free"], + "description": "trust tier of the authored script (spec C resolver)" + }, "env": { "type": "object", "additionalProperties": false, "required": ["kind"], "properties": { - "kind": { "enum": ["provisioned", "project", "sandbox"], "description": "which Julia environment (NOT which machine — that is executor)" }, + "kind": { + "enum": ["provisioned", "project", "sandbox"], + "description": "which Julia environment (NOT which machine — that is executor)" + }, "project": { "type": "string", "description": "Julia project path for kind=project|sandbox" } } }, @@ -28,7 +45,10 @@ "additionalProperties": false, "properties": { "template_id": { "type": "string", "description": "tier-1 registry entry id" }, - "exemplar_id": { "type": "string", "description": "tier-2 exemplars-index entry id (required by the gate when tier=composed)" } + "exemplar_id": { + "type": "string", + "description": "tier-2 exemplars-index entry id (required by the gate when tier=composed)" + } } }, "hashes": { diff --git a/packages/schema/src/cli.ts b/packages/schema/src/cli.ts index 27cab38d..03a705d4 100644 --- a/packages/schema/src/cli.ts +++ b/packages/schema/src/cli.ts @@ -15,19 +15,30 @@ export function main(argv: string[]): number { let schema: string | undefined; for (let i = 0; i < argv.length; i++) { const a = argv[i]; - if (a === "--help" || a === "-h") { console.log(USAGE); return 0; } + if (a === "--help" || a === "-h") { + console.log(USAGE); + return 0; + } if (a === "--schema") { schema = argv[++i]; - if (schema === undefined) { console.error(`amico-validate: --schema requires a value\n${USAGE}`); return 64; } + if (schema === undefined) { + console.error(`amico-validate: --schema requires a value\n${USAGE}`); + return 64; + } } else if (a.startsWith("-")) { - console.error(`amico-validate: unknown flag ${a}\n${USAGE}`); return 64; + console.error(`amico-validate: unknown flag ${a}\n${USAGE}`); + return 64; } else if (file !== undefined) { - console.error(`amico-validate: multiple files given\n${USAGE}`); return 64; + console.error(`amico-validate: multiple files given\n${USAGE}`); + return 64; } else { file = a; } } - if (file === undefined) { console.error(`amico-validate: no file given\n${USAGE}`); return 64; } + if (file === undefined) { + console.error(`amico-validate: no file given\n${USAGE}`); + return 64; + } const inferred = schema ?? kindForFilename(file); if (inferred === undefined) { @@ -41,7 +52,10 @@ export function main(argv: string[]): number { const kind = inferred as SchemaKind; const r = validateFile(file, kind); - if (r.ok) { console.log(`OK ${file} (${kind})`); return 0; } + if (r.ok) { + console.log(`OK ${file} (${kind})`); + return 0; + } console.error(`INVALID ${file} (${kind}):`); for (const e of r.errors) console.error(` ${e}`); return 64; diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index 414fa44a..9b6cb926 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -48,7 +48,10 @@ export const SUPPORTED_VERSIONS_BY_KIND: Record, ]), ) as Record, string[]>; -export interface Validation { ok: boolean; errors: string[] } +export interface Validation { + ok: boolean; + errors: string[]; +} /** Resolve a schema kind from a file's basename, for the fixed-filename artifacts * (run.toml, result.toml, lab.toml, FINISHED). Returns undefined for files @@ -84,11 +87,17 @@ export function validate(artifact: unknown, kind: SchemaKind): Validation { * Parse/read failures are themselves field-precise-ish errors, never a throw. */ export function validateFile(filePath: string, kind: SchemaKind): Validation { let raw: string; - try { raw = readFileSync(filePath, "utf8"); } - catch (e) { return { ok: false, errors: [`cannot read ${filePath}: ${(e as Error).message}`] }; } + try { + raw = readFileSync(filePath, "utf8"); + } catch (e) { + return { ok: false, errors: [`cannot read ${filePath}: ${(e as Error).message}`] }; + } let parsed: unknown; - try { parsed = extname(filePath).toLowerCase() === ".json" ? JSON.parse(raw) : parseToml(raw); } - catch (e) { return { ok: false, errors: [`${filePath}: parse error — ${(e as Error).message}`] }; } + try { + parsed = extname(filePath).toLowerCase() === ".json" ? JSON.parse(raw) : parseToml(raw); + } catch (e) { + return { ok: false, errors: [`${filePath}: parse error — ${(e as Error).message}`] }; + } return validate(normalizeDates(parsed), kind); } diff --git a/packages/schema/test/cli.test.ts b/packages/schema/test/cli.test.ts index 15915286..cd43ce04 100644 --- a/packages/schema/test/cli.test.ts +++ b/packages/schema/test/cli.test.ts @@ -12,7 +12,9 @@ const validDir = join(here, "fixtures", "valid"); const invalidDir = join(here, "fixtures", "invalid"); const KINDS = ["run", "result", "lab", "solvespec", "catalog-entry", "finished"]; -beforeAll(() => { execFileSync("node", [join(pkg, "esbuild.config.mjs")], { cwd: pkg }); }); +beforeAll(() => { + execFileSync("node", [join(pkg, "esbuild.config.mjs")], { cwd: pkg }); +}); function run(args: string[]): { code: number; stdout: string; stderr: string } { try { @@ -46,7 +48,7 @@ describe("amico-validate CLI", () => { it("file-role resolution by basename for the fixed-filename schemas (no --schema)", () => { expect(run([join(validDir, "run.toml")]).code).toBe(0); expect(run([join(validDir, "result.toml")]).code).toBe(0); - expect(run([join(invalidDir, "result.toml")]).code).toBe(64); // missing schema_version + expect(run([join(invalidDir, "result.toml")]).code).toBe(64); // missing schema_version }); it("FINISHED resolves by exact basename (no extension)", () => { const f = join(mkdtempSync(join(tmpdir(), "fin-")), "FINISHED"); @@ -54,7 +56,7 @@ describe("amico-validate CLI", () => { expect(run([f]).code).toBe(0); }); it("a non-filename schema without --schema cannot infer → 64", () => { - const r = run([join(validDir, "solvespec.toml")]); // solvespec.toml is not a canonical name + const r = run([join(validDir, "solvespec.toml")]); // solvespec.toml is not a canonical name expect(r.code).toBe(64); expect(r.stderr).toContain("cannot infer"); }); @@ -63,10 +65,10 @@ describe("amico-validate CLI", () => { expect(r.stderr).toContain("/transmon/levels"); }); it("usage / bad-arg errors exit 64", () => { - expect(run([]).code).toBe(64); // no file - expect(run(["a.toml", "b.toml"]).code).toBe(64); // multiple files + expect(run([]).code).toBe(64); // no file + expect(run(["a.toml", "b.toml"]).code).toBe(64); // multiple files expect(run(["f.toml", "--schema", "bogus"]).code).toBe(64); // unknown schema - expect(run(["f.toml", "--nope"]).code).toBe(64); // unknown flag + expect(run(["f.toml", "--nope"]).code).toBe(64); // unknown flag }); it("--help exits 0", () => expect(run(["--help"]).code).toBe(0)); }); diff --git a/packages/schema/test/validate.test.ts b/packages/schema/test/validate.test.ts index fac392f1..6fec4b22 100644 --- a/packages/schema/test/validate.test.ts +++ b/packages/schema/test/validate.test.ts @@ -4,9 +4,7 @@ import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import { parse as parseToml } from "smol-toml"; -import { - validate, validateFile, SCHEMA_KINDS, SUPPORTED_VERSIONS_BY_KIND, type SchemaKind, -} from "../src/index.js"; +import { validate, validateFile, SCHEMA_KINDS, SUPPORTED_VERSIONS_BY_KIND, type SchemaKind } from "../src/index.js"; const here = dirname(fileURLToPath(import.meta.url)); const validDir = join(here, "fixtures", "valid"); @@ -27,9 +25,7 @@ describe("valid golden fixtures validate clean", () => { describe("schema set + exports", () => { it("exposes all five versioned schemas + the FINISHED sub-shape", () => { - expect(new Set(SCHEMA_KINDS)).toEqual( - new Set(["run", "result", "lab", "solvespec", "catalog-entry", "finished"]), - ); + expect(new Set(SCHEMA_KINDS)).toEqual(new Set(["run", "result", "lab", "solvespec", "catalog-entry", "finished"])); }); it("supported versions are PER-KIND: run + solvespec bumped to v2 (spec C), the rest v1", () => { expect(SUPPORTED_VERSIONS_BY_KIND).toEqual({ @@ -51,15 +47,17 @@ describe("schema set + exports", () => { describe("schema_version policy", () => { it("ABSENT version → field-precise missing-required (the five versioned schemas)", () => { for (const kind of ["run", "result", "lab", "solvespec", "catalog-entry"] as SchemaKind[]) { - const obj = load(kind); delete obj.schema_version; + const obj = load(kind); + delete obj.schema_version; const r = validate(obj, kind); expect(r.ok).toBe(false); - expect(hasErr(r.errors, "missing required key \"schema_version\"")).toBe(true); + expect(hasErr(r.errors, 'missing required key "schema_version"')).toBe(true); } }); it("UNRECOGNIZED version → distinct version-specific error (all five versioned schemas)", () => { for (const kind of ["run", "result", "lab", "solvespec", "catalog-entry"] as SchemaKind[]) { - const obj = load(kind); obj.schema_version = "99"; + const obj = load(kind); + obj.schema_version = "99"; const r = validate(obj, kind); expect(r.ok).toBe(false); expect(hasErr(r.errors, "/schema_version: unrecognized version")).toBe(true); @@ -69,9 +67,7 @@ describe("schema_version policy", () => { const schemasDir = join(here, "..", "schemas"); for (const kind of ["run", "result", "lab", "solvespec", "catalog-entry"] as const) { const schema = JSON.parse(readFileSync(join(schemasDir, `${kind}.schema.json`), "utf8")); - expect(schema.properties.schema_version.enum, `${kind} enum drift`).toEqual( - SUPPORTED_VERSIONS_BY_KIND[kind], - ); + expect(schema.properties.schema_version.enum, `${kind} enum drift`).toEqual(SUPPORTED_VERSIONS_BY_KIND[kind]); } }); it("FINISHED is a sub-shape — it carries NO schema_version and adding one is rejected", () => { @@ -85,46 +81,61 @@ describe("schema_version policy", () => { // ── field-precise negative matrix (#15 AC2, #16/#17 AC, #18 AC2/3) ── describe("field-precise negative matrix", () => { it("missing required key → names the absent key + path (top-level + nested)", () => { - const m = load("run"); delete m.run_id; + const m = load("run"); + delete m.run_id; expect(hasErr(validate(m, "run").errors, 'missing required key "run_id"')).toBe(true); - const j = load("run"); delete (j.julia as Record).binary; + const j = load("run"); + delete (j.julia as Record).binary; expect(hasErr(validate(j, "run").errors, '/julia: missing required key "binary"')).toBe(true); }); it("wrong-type and out-of-range are reported DISTINCTLY + field-precise (#18 AC3)", () => { - const wrong = load("result"); wrong.fidelity = "high"; - expect(hasErr(validate(wrong, "result").errors, "/fidelity: must be number")).toBe(true); // wrong type - const over = load("result"); over.fidelity = 1.5; - expect(hasErr(validate(over, "result").errors, "/fidelity: must be <= 1.0001")).toBe(true); // out of range — distinct - const lab = load("lab"); (lab.transmon as Record).levels = 99; + const wrong = load("result"); + wrong.fidelity = "high"; + expect(hasErr(validate(wrong, "result").errors, "/fidelity: must be number")).toBe(true); // wrong type + const over = load("result"); + over.fidelity = 1.5; + expect(hasErr(validate(over, "result").errors, "/fidelity: must be <= 1.0001")).toBe(true); // out of range — distinct + const lab = load("lab"); + (lab.transmon as Record).levels = 99; expect(hasErr(validate(lab, "lab").errors, "/transmon/levels: must be <= 10")).toBe(true); }); it("unknown key (top level) → names the offending key", () => { - const r = load("result"); r.bogus = 1; + const r = load("result"); + r.bogus = 1; expect(hasErr(validate(r, "result").errors, 'unknown key "bogus"')).toBe(true); }); it("a legitimately-converged fidelity slightly over 1.0 still validates (S1: no false-reject)", () => { - const r = load("result"); r.fidelity = 1.0000000002; + const r = load("result"); + r.fidelity = 1.0000000002; expect(validate(r, "result").ok).toBe(true); }); it("catalog-entry + solvespec negatives are field-precise (#15 AC8 / #17 AC5) [S5/S6]", () => { - const c = load("catalog-entry"); delete c.pulse_path; + const c = load("catalog-entry"); + delete c.pulse_path; expect(hasErr(validate(c, "catalog-entry").errors, 'missing required key "pulse_path"')).toBe(true); - const c2 = load("catalog-entry"); c2.fidelity = "x"; + const c2 = load("catalog-entry"); + c2.fidelity = "x"; expect(hasErr(validate(c2, "catalog-entry").errors, "/fidelity: must be number")).toBe(true); - const s = load("solvespec"); delete s.lab_id; + const s = load("solvespec"); + delete s.lab_id; expect(hasErr(validate(s, "solvespec").errors, 'missing required key "lab_id"')).toBe(true); - const s2 = load("solvespec"); s2.unexpected = 1; + const s2 = load("solvespec"); + s2.unexpected = 1; expect(hasErr(validate(s2, "solvespec").errors, 'unknown key "unexpected"')).toBe(true); }); it("lab hardware range bounds + name minLength are field-precise (#29)", () => { - const hi = load("lab"); (hi.transmon as Record).omega_GHz = 999; + const hi = load("lab"); + (hi.transmon as Record).omega_GHz = 999; expect(hasErr(validate(hi, "lab").errors, "/transmon/omega_GHz: must be <= 100")).toBe(true); - const dm = load("lab"); (dm.transmon as Record).drive_max_GHz = 50; + const dm = load("lab"); + (dm.transmon as Record).drive_max_GHz = 50; expect(hasErr(validate(dm, "lab").errors, "/transmon/drive_max_GHz: must be <= 10")).toBe(true); - const d = load("lab"); (d.transmon as Record).delta_GHz = 25; // garbage anharmonicity + const d = load("lab"); + (d.transmon as Record).delta_GHz = 25; // garbage anharmonicity expect(hasErr(validate(d, "lab").errors, "/transmon/delta_GHz: must be <= 2")).toBe(true); - const nm = load("lab"); (nm.lab as Record).name = ""; - expect(hasErr(validate(nm, "lab").errors, "/lab/name")).toBe(true); // minLength + const nm = load("lab"); + (nm.lab as Record).name = ""; + expect(hasErr(validate(nm, "lab").errors, "/lab/name")).toBe(true); // minLength }); it("FINISHED bad status → field-precise enum error", () => { const r = validate({ status: "halfway", exit_code: 0 }, "finished"); @@ -133,8 +144,8 @@ describe("field-precise negative matrix", () => { }); it("params sub-table is lenient (mixed int/float + extra keys allowed) [M2]", () => { const r = load("result"); - (r.params as Record).future_knob = 7; // unknown param OK - (r.params as Record).levels = 4.0; // float where int-ish OK + (r.params as Record).future_knob = 7; // unknown param OK + (r.params as Record).levels = 4.0; // float where int-ish OK expect(validate(r, "result").ok).toBe(true); }); }); @@ -144,8 +155,16 @@ describe("result.toml spline/free-phase fields (spec-20260704-113005 §6, additi it("accepts pulse_kind spline with free-phase declaration", () => { expect( - validate({ ...base, pulse_kind: "spline", fidelity_convention: "free_phase", - free_phases: [0.12, -1.7], subsystem_levels: [2, 3] }, "result").ok, + validate( + { + ...base, + pulse_kind: "spline", + fidelity_convention: "free_phase", + free_phases: [0.12, -1.7], + subsystem_levels: [2, 3], + }, + "result", + ).ok, ).toBe(true); }); it("accepts plain PWC results unchanged (fields all optional)", () => { @@ -167,9 +186,14 @@ describe("formalize-don't-fork: real beta.1 artifacts validate under the closed it("a beta.1 manifest (writeManifest shape) + schema_version validates clean", () => { // EXACT shape amico-run/src/run_dir.ts writeManifest emits. const m = { - schema_version: "1", run_id: "r20260101-000000Z-aaaa", script_path: "/s.jl", - lab: "default", lab_id: "default", created_at: "2026-01-01T00:00:00.000Z", - orchestrator_version: "0.1.0", julia: { binary: "julia", project: "/p", sysimage: "/img.so" }, + schema_version: "1", + run_id: "r20260101-000000Z-aaaa", + script_path: "/s.jl", + lab: "default", + lab_id: "default", + created_at: "2026-01-01T00:00:00.000Z", + orchestrator_version: "0.1.0", + julia: { binary: "julia", project: "/p", sysimage: "/img.so" }, }; expect(validate(m, "run")).toEqual({ ok: true, errors: [] }); }); @@ -187,10 +211,12 @@ describe("validateFile tolerates unquoted TOML datetimes", () => { it("an unquoted created_at validates identically to a quoted one", () => { const dir = mkdtempSync(join(tmpdir(), "labfx-")); const f = join(dir, "run.toml"); - writeFileSync(f, + writeFileSync( + f, 'schema_version = "1"\nrun_id = "r1"\nscript_path = "/s.jl"\nlab = "default"\n' + - 'lab_id = "default"\ncreated_at = 2026-06-15T00:00:00Z\norchestrator_version = "0.1.0"\n' + - '[julia]\nbinary = "julia"\n'); // NOTE: unquoted datetime + 'lab_id = "default"\ncreated_at = 2026-06-15T00:00:00Z\norchestrator_version = "0.1.0"\n' + + '[julia]\nbinary = "julia"\n', + ); // NOTE: unquoted datetime expect(validateFile(f, "run").errors).toEqual([]); }); }); @@ -209,10 +235,14 @@ describe("bundled demo run dir conforms", () => { // ── v2 (spec C): SolveSpec executor/tier/env/source/hashes + run.toml tier/hashes ── describe("v2 (spec C)", () => { const specV2 = { - schema_version: "2", script_path: "/w/solve.jl", lab_id: "default", - executor: "local", tier: "free", + schema_version: "2", + script_path: "/w/solve.jl", + lab_id: "default", + executor: "local", + tier: "free", env: { kind: "sandbox", project: "/w/env" }, - source: {}, hashes: { system_hash: "sha256:ab", formulation_hash: "sha256:cd" }, + source: {}, + hashes: { system_hash: "sha256:ab", formulation_hash: "sha256:cd" }, }; it("accepts a full v2 solvespec and still accepts v1", () => { expect(validate(specV2, "solvespec").errors).toEqual([]); @@ -225,14 +255,32 @@ describe("v2 (spec C)", () => { }); it("run v2: tier + [hashes] (all four keys) accepted; v1 manifests still valid", () => { const run1 = { - schema_version: "1", run_id: "r", script_path: "/s.jl", lab: "default", lab_id: "default", - created_at: "2026-07-03T00:00:00Z", orchestrator_version: "0.1.0", julia: { binary: "julia" }, + schema_version: "1", + run_id: "r", + script_path: "/s.jl", + lab: "default", + lab_id: "default", + created_at: "2026-07-03T00:00:00Z", + orchestrator_version: "0.1.0", + julia: { binary: "julia" }, }; expect(validate(run1, "run").ok).toBe(true); - expect(validate({ - ...run1, schema_version: "2", tier: "free", - hashes: { system_hash: "sha256:ab", formulation_hash: "sha256:cd", warm_start_hash: "sha256:ef", spec_hash: "sha256:01" }, - }, "run").errors).toEqual([]); + expect( + validate( + { + ...run1, + schema_version: "2", + tier: "free", + hashes: { + system_hash: "sha256:ab", + formulation_hash: "sha256:cd", + warm_start_hash: "sha256:ef", + spec_hash: "sha256:01", + }, + }, + "run", + ).errors, + ).toEqual([]); expect(validate({ ...run1, schema_version: "2", tier: "nope" }, "run").errors.join()).toMatch(/tier/); }); }); From ed7923c5fb41d5cacb45b59ce862a055199f9022 Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Tue, 7 Jul 2026 06:16:54 -0400 Subject: [PATCH 122/135] =?UTF-8?q?fix(config):=20fallback-only=20model=20?= =?UTF-8?q?pin=20=E2=80=94=20without=20one,=20opencode's=20default=20resol?= =?UTF-8?q?ution=20gambles=20on=20provider=20ordering=20and=20(with=20Goog?= =?UTF-8?q?le=20creds)=20picked=20a=20hanging=20preview=20model=20for=20ev?= =?UTF-8?q?ery=20headless/agent=20turn;=20anthropic=20>=20GA=20gemini=20fl?= =?UTF-8?q?ash,=20and=20a=20user's=20global=20model=20always=20wins=20(1.1?= =?UTF-8?q?7.3=20preserve=20contract)=20+=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- packages/extension/.DS_Store | Bin 10244 -> 10244 bytes packages/extension/src/extension.ts | 6 ++- packages/extension/src/opencode_config.ts | 43 ++++++++++++++++++ .../extension/test/opencode_config.test.ts | 28 +++++++++++- 4 files changed, 75 insertions(+), 2 deletions(-) diff --git a/packages/extension/.DS_Store b/packages/extension/.DS_Store index b1828c242a460f3c314e9c06329c0bb46e65d60f..c11726d7a809c8b769502d47eac302a303839463 100644 GIT binary patch delta 133 zcmZn(XbIS`T$nLpa*eRe<|D!dOtKD%3=9m+48;sZ49U6qE-pzq`AI+#4&8&wJ6a|O mib=9EJ( delta 113 zcmZn(XbIS`T$s^*a*eRe<|D!dO#J5e85kIt8HyQ-7?N}IT_z`rNl%Uw<6)hfEk7f3 da=w@}W5MKZvE58=*EV~Izu_Z6{bUdEJpjd~Ba;9C diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 92682a59..f95e2a2a 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -9,7 +9,7 @@ import { registerRunInspector } from "./run_inspector"; import { registerCatalogCard } from "./catalog_card_shell"; import { registerTrees } from "./trees"; import { StatusBarManager } from "./status_bar"; -import { prepareOpencodeProject, resolveJuliaProject, buildOpencodeConfigContent } from "./opencode_config"; +import { prepareOpencodeProject, resolveJuliaProject, buildOpencodeConfigContent, resolveModelPin } from "./opencode_config"; import { resolveAmicoRunBinDir, resolveRunsRoot } from "./opencode_paths"; import { resolveLabTomlPath, checkLabToml } from "./lab_config"; import { OpencodeEventClient } from "./sse_client"; @@ -221,6 +221,10 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { opencodeProject.skillPaths, opencodeProject.skillsStageDir, opencodeProject.vaultDir, + // Model pin (fallback-only, resolveModelPin): without it, default + // resolution gambles on provider ordering — with Google creds it + // picked a preview model that hung every headless/agent turn. + resolveModelPin(), ), }, channel: opencodeChannel, diff --git a/packages/extension/src/opencode_config.ts b/packages/extension/src/opencode_config.ts index 9d329091..bf9d8b44 100644 --- a/packages/extension/src/opencode_config.ts +++ b/packages/extension/src/opencode_config.ts @@ -223,6 +223,47 @@ export function writeAuthoringConfig( } } + +/** Default model pin for the generated config. Without one, opencode's + * default-resolution gambles on provider ordering and (with Google creds) + * lands on preview variants — gemini-3.1-pro-preview-customtools rejected or + * HUNG every turn. Preference: Anthropic if the user has creds for it, else + * the GA Gemini flash (verified: completes tool-bearing turns). The app's + * model picker still overrides per session; undefined leaves opencode's own + * default (no creds yet — nothing sane to pin). */ +export function preferredModel( + authPath: string = path.join(os.homedir(), ".local", "share", "opencode", "auth.json"), +): string | undefined { + try { + const providers = Object.keys(JSON.parse(fs.readFileSync(authPath, "utf8")) as Record); + if (providers.includes("anthropic")) return "anthropic/claude-sonnet-5"; + if (providers.includes("google")) return "google/gemini-3.5-flash"; + } catch { + /* no auth.json yet */ + } + return undefined; +} + +/** The model pin to inject, or undefined. FALLBACK-only: a model in the user's + * global opencode config wins (our injected config would override it in the + * merge — the 1.17.3 preserve-user-model contract), so we pin nothing then. */ +export function resolveModelPin( + globalConfigPath: string = path.join( + process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"), + "opencode", + "opencode.json", + ), + authPath?: string, +): string | undefined { + try { + const cfg = JSON.parse(fs.readFileSync(globalConfigPath, "utf8")) as { model?: unknown }; + if (typeof cfg.model === "string" && cfg.model) return undefined; // user chose — never override + } catch { + /* no global config — fall through to the creds-based pin */ + } + return authPath === undefined ? preferredModel() : preferredModel(authPath); +} + export function buildOpencodeConfigContent( agentsPath: string, templatePath: string, @@ -232,6 +273,7 @@ export function buildOpencodeConfigContent( skillPaths: string[] = [], skillsStageDir: string = "", vaultDir: string = "", + modelPin?: string, ): string { const templatesDir = path.dirname(templatePath); // Least-privilege read grants for the skill index (spec §3): each indexed @@ -247,6 +289,7 @@ export function buildOpencodeConfigContent( const skills = skillsStageDir ? { paths: [skillsStageDir] } : undefined; return JSON.stringify({ $schema: "https://opencode.ai/config.json", + ...(modelPin ? { model: modelPin } : {}), instructions: [agentsPath], plugin: [pluginPath], ...(skills ? { skills } : {}), diff --git a/packages/extension/test/opencode_config.test.ts b/packages/extension/test/opencode_config.test.ts index 6c2ed9f8..7b68e6cb 100644 --- a/packages/extension/test/opencode_config.test.ts +++ b/packages/extension/test/opencode_config.test.ts @@ -3,7 +3,7 @@ import { existsSync, mkdtempSync, mkdirSync, writeFileSync, readFileSync } from import { tmpdir, homedir } from "node:os"; import { join, isAbsolute } from "node:path"; import { execFileSync } from "node:child_process"; -import { prepareOpencodeProject, resolveJuliaProject, buildOpencodeConfigContent } from "../src/opencode_config"; +import { prepareOpencodeProject, resolveJuliaProject, buildOpencodeConfigContent, preferredModel, resolveModelPin } from "../src/opencode_config"; function fakeExtRoot(): string { const root = mkdtempSync(join(tmpdir(), "extroot-")); @@ -215,3 +215,29 @@ describe("prepareOpencodeProject", () => { expect(existsSync(join(p.projectDir, ".opencode", "opencode.json"))).toBe(false); }); }); + +describe("preferredModel", () => { + it("anthropic wins, google falls back to GA flash, absent auth pins nothing", () => { + const dir = mkdtempSync(join(tmpdir(), "auth-")); + const authPath = join(dir, "auth.json"); + writeFileSync(authPath, JSON.stringify({ google: { type: "api" } })); + expect(preferredModel(authPath)).toBe("google/gemini-3.5-flash"); + writeFileSync(authPath, JSON.stringify({ google: { type: "api" }, anthropic: { type: "api" } })); + expect(preferredModel(authPath)).toBe("anthropic/claude-sonnet-5"); + expect(preferredModel(join(dir, "missing.json"))).toBeUndefined(); + }); +}); + +describe("resolveModelPin (fallback-only)", () => { + it("a user global model suppresses the pin; no global model → creds-based pin", () => { + const dir = mkdtempSync(join(tmpdir(), "pin-")); + const cfgPath = join(dir, "opencode.json"); + const authPath = join(dir, "auth.json"); + writeFileSync(authPath, JSON.stringify({ google: { type: "api" } })); + writeFileSync(cfgPath, JSON.stringify({ model: "anthropic/claude-sonnet-4-6" })); + expect(resolveModelPin(cfgPath, authPath)).toBeUndefined(); + writeFileSync(cfgPath, JSON.stringify({})); + expect(resolveModelPin(cfgPath, authPath)).toBe("google/gemini-3.5-flash"); + expect(resolveModelPin(join(dir, "missing.json"), authPath)).toBe("google/gemini-3.5-flash"); + }); +}); From 51da46cca987846a9cef6db5feb99299588ba59a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:18:47 +0000 Subject: [PATCH 123/135] =?UTF-8?q?ci(vsix-gate):=20fix=20by=20splitting?= =?UTF-8?q?=20package=20step=20=E2=80=94=20explicit=20build=20+=20fetch:op?= =?UTF-8?q?encode=20+=20vsce?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe3cb610..8e655401 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,7 +70,9 @@ jobs: - uses: actions/setup-node@v4 with: { node-version: 20, cache: pnpm } - run: pnpm install --frozen-lockfile - - run: pnpm --filter amicode-v2 package # amico-run build + ext build + fetch:opencode + vsce + - run: pnpm -r run build # amico-run + extension (same as fast — ensures dist/ is in place before vsce) + - run: pnpm --filter amicode-v2 fetch:opencode # vendor the opencode binary (explicit step, matches fast/boot-smoke) + - run: pnpm --filter amicode-v2 exec vsce package --no-dependencies --allow-missing-repository -o amicode.vsix - run: AMICODE_REQUIRE_VSIX=1 pnpm --filter amicode-v2 exec vitest run test/packaging.test.ts boot-smoke: strategy: From cd95560698503a90641cdb7e97d7dc3d76339bee Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Tue, 7 Jul 2026 06:26:54 -0400 Subject: [PATCH 124/135] =?UTF-8?q?test(slow):=20live-turn=20extractor=20i?= =?UTF-8?q?s=20model-agnostic=20=E2=80=94=20Gemini=20opens=20with=20the=20?= =?UTF-8?q?amicode=5Fask=20TOOL=20CALL=20and=20no=20prose,=20so=20the=20as?= =?UTF-8?q?k=20input=20(question+options)=20counts=20as=20the=20turn=20tex?= =?UTF-8?q?t;=20production=20model=20pin=20wired=20into=20the=20e2e=20serv?= =?UTF-8?q?ers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../extension/test/slow/interview_e2e.test.ts | 44 ++++++++++++++++--- .../extension/test/slow/scores_e2e.test.ts | 27 ++++++++++-- 2 files changed, 63 insertions(+), 8 deletions(-) diff --git a/packages/extension/test/slow/interview_e2e.test.ts b/packages/extension/test/slow/interview_e2e.test.ts index c9f60cdc..01ada6a2 100644 --- a/packages/extension/test/slow/interview_e2e.test.ts +++ b/packages/extension/test/slow/interview_e2e.test.ts @@ -3,7 +3,7 @@ import { existsSync, mkdtempSync, mkdirSync, writeFileSync, readFileSync } from 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 { buildOpencodeConfigContent, prepareOpencodeProject, resolveJuliaProject, resolveModelPin } from "../../src/opencode_config"; // ============================================================================ // T13 e2e — pulse-designer interview against the REAL vendored binary. @@ -47,6 +47,14 @@ function layer0Config(agentsPath: string): string { agentsPath, join(EXT, "templates", "solve_template.jl"), join(homedir(), ".amico", "runs", "default"), + undefined, + undefined, + [], + "", + "", + // production model pin (fallback-only) — without it the live turns ride + // opencode's default resolution, which picks a hanging preview model here + resolveModelPin(), ); } @@ -146,11 +154,24 @@ describe.skipIf(!existsSync(OC_BIN) || !hasCreds())("live interview turns (creds 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 ?? []) + const msg = (await r.json()) as { + parts?: Array<{ type: string; text?: string; tool?: string; state?: { input?: Record } }>; + }; + const textOut = (msg.parts ?? []) .filter((p) => p.type === "text") .map((p) => p.text) .join("\n"); + // Model-agnostic: some models (Gemini) open with the amicode_ask TOOL CALL + // and no prose — the ask input IS the question the assertions probe for. + const askOut = (msg.parts ?? []) + .filter((p) => p.type === "tool" && p.tool === "amicode_ask") + .map((p) => { + const input = (p.state?.input ?? (p as Record).input ?? {}) as Record; + const opts = Array.isArray(input.options) ? input.options.join(" | ") : ""; + return [input.question, opts].filter(Boolean).join("\n"); + }) + .join("\n"); + return [textOut, askOut].filter(Boolean).join("\n"); }; const q1 = await turn("help me design a pulse"); @@ -189,11 +210,24 @@ describe.skipIf(!existsSync(OC_BIN) || !hasCreds())("live interview turns (creds 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 ?? []) + const msg = (await r.json()) as { + parts?: Array<{ type: string; text?: string; tool?: string; state?: { input?: Record } }>; + }; + const textOut = (msg.parts ?? []) .filter((p) => p.type === "text") .map((p) => p.text) .join("\n"); + // Model-agnostic: some models (Gemini) open with the amicode_ask TOOL CALL + // and no prose — the ask input IS the question the assertions probe for. + const askOut = (msg.parts ?? []) + .filter((p) => p.type === "tool" && p.tool === "amicode_ask") + .map((p) => { + const input = (p.state?.input ?? (p as Record).input ?? {}) as Record; + const opts = Array.isArray(input.options) ? input.options.join(" | ") : ""; + return [input.question, opts].filter(Boolean).join("\n"); + }) + .join("\n"); + return [textOut, askOut].filter(Boolean).join("\n"); }; // Keyword-routed answers — the model controls stage order, we answer whatever diff --git a/packages/extension/test/slow/scores_e2e.test.ts b/packages/extension/test/slow/scores_e2e.test.ts index b8d41301..c94686fe 100644 --- a/packages/extension/test/slow/scores_e2e.test.ts +++ b/packages/extension/test/slow/scores_e2e.test.ts @@ -3,7 +3,7 @@ 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 { buildOpencodeConfigContent, prepareOpencodeProject, resolveJuliaProject, resolveModelPin } from "../../src/opencode_config"; import { loadState } from "../../src/scores/interview_state"; import { readUsage, reconstructTraversal } from "../../src/scores/usage"; @@ -66,6 +66,14 @@ async function serveWithScores(port: number) { project.agentsPath, join(EXT, "templates", "solve_template.jl"), join(homedir(), ".amico", "runs", "default"), + undefined, + undefined, + [], + "", + "", + // production model pin (fallback-only) — without it the live turns ride + // opencode's default resolution, which picks a hanging preview model here + resolveModelPin(), ); let buf = ""; const child = spawn(OC_BIN, ["serve", "--port", String(port)], { env, stdio: ["ignore", "pipe", "pipe"] }); @@ -111,11 +119,24 @@ describe.skipIf(!existsSync(OC_BIN) || !hasCreds())("scores runtime live e2e (cr 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 ?? []) + const msg = (await r.json()) as { + parts?: Array<{ type: string; text?: string; tool?: string; state?: { input?: Record } }>; + }; + const textOut = (msg.parts ?? []) .filter((p) => p.type === "text") .map((p) => p.text) .join("\n"); + // Model-agnostic: some models (Gemini) open with the amicode_ask TOOL CALL + // and no prose — the ask input IS the question the assertions probe for. + const askOut = (msg.parts ?? []) + .filter((p) => p.type === "tool" && p.tool === "amicode_ask") + .map((p) => { + const input = (p.state?.input ?? (p as Record).input ?? {}) as Record; + const opts = Array.isArray(input.options) ? input.options.join(" | ") : ""; + return [input.question, opts].filter(Boolean).join("\n"); + }) + .join("\n"); + return [textOut, askOut].filter(Boolean).join("\n"); }; const transcript: string[] = []; From ec4484813f92233ce459e6ca3bf6ede879f9a550 Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Tue, 7 Jul 2026 10:06:39 -0400 Subject: [PATCH 125/135] =?UTF-8?q?feat(bridge):=20save-file=20lane=20?= =?UTF-8?q?=E2=80=94=20the=20run-card=20gallery's=20PNG=20export=20routes?= =?UTF-8?q?=20through=20a=20save=20dialog=20(downloads=20are=20dead=20in?= =?UTF-8?q?=20the=20framed=20app);=20PNG-only,=20basename-sanitized,=20siz?= =?UTF-8?q?e-bounded,=20relay-allowlisted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- packages/extension/.DS_Store | Bin 10244 -> 10244 bytes packages/extension/src/chat_panel.ts | 32 ++++++++++++++++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/packages/extension/.DS_Store b/packages/extension/.DS_Store index c11726d7a809c8b769502d47eac302a303839463..903981570aefad7a14b5e78115afacef42715143 100644 GIT binary patch delta 75 zcmZn(XbIR*C&=V~XL5sJ6yt);w*}8IGA2&05ti9}M7V&7$(?PpnV0}0Q@G}25Ai6* RfX$1=C-Py4PF^gY2mrki7V-c9 delta 75 zcmZn(XbIR*C&=VrH@QJDim_qyZNW2)j1iM-gk?4#5iVe2a!A~4CMLkh 24_000_000 || !name.endsWith(".png")) return; + void (async () => { + const target = await vscode.window.showSaveDialog({ + defaultUri: vscode.Uri.file(path.join(os.homedir(), "Downloads", name)), + filters: { Images: ["png"] }, + }); + if (!target) return; + await vscode.workspace.fs.writeFile(target, Buffer.from(base64, "base64")); + const pick = await vscode.window.showInformationMessage(`Amicode: saved ${path.basename(target.fsPath)}`, "Reveal"); + if (pick === "Reveal") await vscode.commands.executeCommand("revealFileInOS", target); + })(); + return; + } if ( msg && typeof msg === "object" && @@ -173,7 +203,7 @@ export class ChatPanel { // Lane 1 — iframe → extension (commands): MUST come from the opencode // origin; the extension side additionally allowlists commands. if (e.origin === ${origin}) { - if (d && d.source === "amicode" && (d.kind === "command" || d.kind === "clipboard-request" || d.kind === "open-external")) { + if (d && d.source === "amicode" && (d.kind === "command" || d.kind === "clipboard-request" || d.kind === "open-external" || d.kind === "save-file")) { vscode.postMessage(d); } return; From fbb3d31c5430b021288e861f51754527ac820a0c Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Tue, 7 Jul 2026 10:16:49 -0400 Subject: [PATCH 126/135] =?UTF-8?q?fix(config):=20google=20pin=20moves=20t?= =?UTF-8?q?o=20gemini-2.5-flash=20=E2=80=94=20the=20newest=20flash=20is=20?= =?UTF-8?q?capacity-throttled=20at=20peak=20('model=20overloaded'=20?= =?UTF-8?q?=E2=86=92=20failed=20turns=20render=20as=20'model=20undefined'?= =?UTF-8?q?=20stubs=20in=20chat);=20the=20GA=20flash=20answered=20in=201.4?= =?UTF-8?q?s=20during=20the=20same=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- packages/extension/.DS_Store | Bin 10244 -> 10244 bytes packages/extension/src/opencode_config.ts | 4 ++-- .../extension/test/opencode_config.test.ts | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/extension/.DS_Store b/packages/extension/.DS_Store index 903981570aefad7a14b5e78115afacef42715143..0184aac88e298a0f8e64bd3f438c157f82b09abd 100644 GIT binary patch delta 112 zcmZn(XbIR*C&(1Hb8~~>Ru-9X1_lOZhGK>yhUDCQ7nh`*{3M_VNA_~R+w(UEiWM?4 eojEf3hyhUDCQ7nh`*{3M_Vhwee;9W9#!#R?gj edR!(S5l>@Wu-Q=J8vDcsw$1DcdThk%a{vH7XCQe1 diff --git a/packages/extension/src/opencode_config.ts b/packages/extension/src/opencode_config.ts index bf9d8b44..b62f2dbb 100644 --- a/packages/extension/src/opencode_config.ts +++ b/packages/extension/src/opencode_config.ts @@ -228,7 +228,7 @@ export function writeAuthoringConfig( * default-resolution gambles on provider ordering and (with Google creds) * lands on preview variants — gemini-3.1-pro-preview-customtools rejected or * HUNG every turn. Preference: Anthropic if the user has creds for it, else - * the GA Gemini flash (verified: completes tool-bearing turns). The app's + * the boring-but-available GA Gemini flash (the newest flash is capacity-throttled at peak; 2.5 answered in 1.4s while 3.5 returned overloaded). The app's * model picker still overrides per session; undefined leaves opencode's own * default (no creds yet — nothing sane to pin). */ export function preferredModel( @@ -237,7 +237,7 @@ export function preferredModel( try { const providers = Object.keys(JSON.parse(fs.readFileSync(authPath, "utf8")) as Record); if (providers.includes("anthropic")) return "anthropic/claude-sonnet-5"; - if (providers.includes("google")) return "google/gemini-3.5-flash"; + if (providers.includes("google")) return "google/gemini-2.5-flash"; } catch { /* no auth.json yet */ } diff --git a/packages/extension/test/opencode_config.test.ts b/packages/extension/test/opencode_config.test.ts index 7b68e6cb..849b9b47 100644 --- a/packages/extension/test/opencode_config.test.ts +++ b/packages/extension/test/opencode_config.test.ts @@ -221,7 +221,7 @@ describe("preferredModel", () => { const dir = mkdtempSync(join(tmpdir(), "auth-")); const authPath = join(dir, "auth.json"); writeFileSync(authPath, JSON.stringify({ google: { type: "api" } })); - expect(preferredModel(authPath)).toBe("google/gemini-3.5-flash"); + expect(preferredModel(authPath)).toBe("google/gemini-2.5-flash"); writeFileSync(authPath, JSON.stringify({ google: { type: "api" }, anthropic: { type: "api" } })); expect(preferredModel(authPath)).toBe("anthropic/claude-sonnet-5"); expect(preferredModel(join(dir, "missing.json"))).toBeUndefined(); @@ -237,7 +237,7 @@ describe("resolveModelPin (fallback-only)", () => { writeFileSync(cfgPath, JSON.stringify({ model: "anthropic/claude-sonnet-4-6" })); expect(resolveModelPin(cfgPath, authPath)).toBeUndefined(); writeFileSync(cfgPath, JSON.stringify({})); - expect(resolveModelPin(cfgPath, authPath)).toBe("google/gemini-3.5-flash"); - expect(resolveModelPin(join(dir, "missing.json"), authPath)).toBe("google/gemini-3.5-flash"); + expect(resolveModelPin(cfgPath, authPath)).toBe("google/gemini-2.5-flash"); + expect(resolveModelPin(join(dir, "missing.json"), authPath)).toBe("google/gemini-2.5-flash"); }); }); From d6d099b2dcccf0511033aff35541c4d8a6156ccf Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Tue, 7 Jul 2026 10:31:57 -0400 Subject: [PATCH 127/135] =?UTF-8?q?fix(config):=20creds-free=20default=20m?= =?UTF-8?q?odel=20=E2=80=94=20opencode/deepseek-v4-flash-free=20(zen=20fre?= =?UTF-8?q?e=20tier,=20no=20user=20quota;=20answered=20a=20tool-bearing=20?= =?UTF-8?q?turn=20in=20~3s=20while=20Gemini=20kept=20capacity-throttling);?= =?UTF-8?q?=20anthropic=20still=20wins=20when=20creds=20exist,=20user=20gl?= =?UTF-8?q?obal=20model=20always=20wins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- packages/extension/.DS_Store | Bin 10244 -> 10244 bytes packages/extension/src/opencode_config.ts | 8 +++++--- .../extension/test/opencode_config.test.ts | 8 ++++---- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/extension/.DS_Store b/packages/extension/.DS_Store index 0184aac88e298a0f8e64bd3f438c157f82b09abd..ea98653f20ef3694df3968f1b1248258a18199e1 100644 GIT binary patch delta 40 wcmZn(XbIR*C&(1fwYfo1jfqKV@#Gp|naxLpo0%CSH+zWx=Vy$Z>><7f01yxjRsaA1 delta 40 wcmZn(XbIR*C&(1Hb8~~B8WWR2;^Z1(naxLpo0%CkH+zWx=V#QM>><7f03A~eZ~y=R diff --git a/packages/extension/src/opencode_config.ts b/packages/extension/src/opencode_config.ts index b62f2dbb..c3bf0a93 100644 --- a/packages/extension/src/opencode_config.ts +++ b/packages/extension/src/opencode_config.ts @@ -237,11 +237,13 @@ export function preferredModel( try { const providers = Object.keys(JSON.parse(fs.readFileSync(authPath, "utf8")) as Record); if (providers.includes("anthropic")) return "anthropic/claude-sonnet-5"; - if (providers.includes("google")) return "google/gemini-2.5-flash"; } catch { - /* no auth.json yet */ + /* no auth.json — the free default below still works */ } - return undefined; + // Creds-free default: the zen free tier rides no user quota — Gemini keys + // kept hitting capacity throttles ("model overloaded" → failed turns render + // as "model undefined" stubs), while this answered a tool-bearing turn in ~3s. + return "opencode/deepseek-v4-flash-free"; } /** The model pin to inject, or undefined. FALLBACK-only: a model in the user's diff --git a/packages/extension/test/opencode_config.test.ts b/packages/extension/test/opencode_config.test.ts index 849b9b47..ec13056c 100644 --- a/packages/extension/test/opencode_config.test.ts +++ b/packages/extension/test/opencode_config.test.ts @@ -221,10 +221,10 @@ describe("preferredModel", () => { const dir = mkdtempSync(join(tmpdir(), "auth-")); const authPath = join(dir, "auth.json"); writeFileSync(authPath, JSON.stringify({ google: { type: "api" } })); - expect(preferredModel(authPath)).toBe("google/gemini-2.5-flash"); + expect(preferredModel(authPath)).toBe("opencode/deepseek-v4-flash-free"); writeFileSync(authPath, JSON.stringify({ google: { type: "api" }, anthropic: { type: "api" } })); expect(preferredModel(authPath)).toBe("anthropic/claude-sonnet-5"); - expect(preferredModel(join(dir, "missing.json"))).toBeUndefined(); + expect(preferredModel(join(dir, "missing.json"))).toBe("opencode/deepseek-v4-flash-free"); }); }); @@ -237,7 +237,7 @@ describe("resolveModelPin (fallback-only)", () => { writeFileSync(cfgPath, JSON.stringify({ model: "anthropic/claude-sonnet-4-6" })); expect(resolveModelPin(cfgPath, authPath)).toBeUndefined(); writeFileSync(cfgPath, JSON.stringify({})); - expect(resolveModelPin(cfgPath, authPath)).toBe("google/gemini-2.5-flash"); - expect(resolveModelPin(join(dir, "missing.json"), authPath)).toBe("google/gemini-2.5-flash"); + expect(resolveModelPin(cfgPath, authPath)).toBe("opencode/deepseek-v4-flash-free"); + expect(resolveModelPin(join(dir, "missing.json"), authPath)).toBe("opencode/deepseek-v4-flash-free"); }); }); From 512cb8252d4983458e8c84f85c21eb14d397ad66 Mon Sep 17 00:00:00 2001 From: Jack Champagne Date: Tue, 7 Jul 2026 12:24:44 -0400 Subject: [PATCH 128/135] ci: authenticate the private-fork opencode fetch (GH_TOKEN) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fast/vsix-gate/boot-smoke fetch the vendored opencode binary from the PRIVATE harmoniqs/opencode release via `gh release download`. The default Actions GITHUB_TOKEN is scoped to this repo only, so gh is unauthenticated for harmoniqs/opencode and the fetch exits 1 — reding every job that vendors opencode (schema-roundtrip is untouched; it needs no binary). Pass a cross-repo token as GH_TOKEN to the three fetch:opencode step defs (boot-smoke's single step covers both matrix legs). Mirrors #80, adapted to this branch's split vsix-gate step. Requires repo secret OPENCODE_FETCH_TOKEN: a fine-grained PAT scoped to harmoniqs/opencode with Contents:Read (validated end-to-end — downloads both assets and the bytes match opencode.lock.json's SHA256 gate). Stays red until the secret exists; green once it's set, no further push needed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e655401..8a67ebc2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,11 @@ jobs: # self-skipping — the skip was the #25 CI-level false-green (an injection or # config-merge regression would pass CI because the only test for it skipped). - run: pnpm --filter amicode-v2 fetch:opencode + env: + # gh release download pulls the vendored binary from the PRIVATE + # harmoniqs/opencode release; the default GITHUB_TOKEN is scoped to + # this repo only, so gh needs a cross-repo token (repo secret). + GH_TOKEN: ${{ secrets.OPENCODE_FETCH_TOKEN }} - run: pnpm -r run test # amico-run suite (incl. S31 grep rule) + extension unit suite (incl. the opencode inject/merge integration) + @amicode/schema conformance - name: amico-validate — shipped configs conform + linked bin works (0.1c gate) run: | @@ -72,6 +77,8 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm -r run build # amico-run + extension (same as fast — ensures dist/ is in place before vsce) - run: pnpm --filter amicode-v2 fetch:opencode # vendor the opencode binary (explicit step, matches fast/boot-smoke) + env: + GH_TOKEN: ${{ secrets.OPENCODE_FETCH_TOKEN }} # private-fork release fetch (see fast) - run: pnpm --filter amicode-v2 exec vsce package --no-dependencies --allow-missing-repository -o amicode.vsix - run: AMICODE_REQUIRE_VSIX=1 pnpm --filter amicode-v2 exec vitest run test/packaging.test.ts boot-smoke: @@ -86,4 +93,6 @@ jobs: with: { node-version: 20, cache: pnpm } - run: pnpm install --frozen-lockfile - run: pnpm --filter amicode-v2 fetch:opencode + env: + GH_TOKEN: ${{ secrets.OPENCODE_FETCH_TOKEN }} # private-fork release fetch (see fast); one step covers both matrix legs - run: pnpm --filter amicode-v2 test:smoke From ded0ae2a872e0aa2c8ad9732e18712952f9b48c2 Mon Sep 17 00:00:00 2001 From: Jack Champagne Date: Tue, 7 Jul 2026 13:10:38 -0400 Subject: [PATCH 129/135] chore(extension): bump opencode.lock to v1.17.3-amicode.2 Pin the vendored opencode binary to the new fork release cut from rchari/amicode-fixes (opencode PR #1): Gemini tool-schema fix, /amicode run-cards + profile endpoints, one-spine run truth, multi-drive pulse fix, save-file bridge. darwin-arm64 + linux-x64 sha256 updated. vsix build verified locally (linux-x64 fetch + sha match + vsce package -> amicode.vsix). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/extension/opencode.lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/extension/opencode.lock.json b/packages/extension/opencode.lock.json index 8e581aad..9d7e0994 100644 --- a/packages/extension/opencode.lock.json +++ b/packages/extension/opencode.lock.json @@ -1,15 +1,15 @@ { "version": "1.17.3", "repo": "harmoniqs/opencode", - "tag": "v1.17.3-amicode.1", + "tag": "v1.17.3-amicode.2", "platforms": { "darwin-arm64": { "asset": "opencode-darwin-arm64.zip", - "sha256": "f1d6291485246e03a3d33eefb40b4d264d68c8e5812b11b4ffdfec4444ab9455" + "sha256": "3b41ea7344718e2c985b38b8e166603dd6b06e5472af8ac95431c897a557df33" }, "linux-x64": { "asset": "opencode-linux-x64.tar.gz", - "sha256": "268596e61475dd79d3076dd65d39bf97926d56c28477419250ab3c678bd55b10" + "sha256": "2aff796ab4685ecf9e506255a1a45dd86a0096d76f2a1b8473f4367b204d1ae8" } } } From bb5c8e7e96bbb9de9fe53ea35da2b2270d8247cd Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Tue, 7 Jul 2026 13:50:12 -0400 Subject: [PATCH 130/135] =?UTF-8?q?workbench:=20main=20(1.1=E2=80=931.4a)?= =?UTF-8?q?=20into=20problem-workspaces=20+=20bridges,=20stalled/stop=20tr?= =?UTF-8?q?uth,=20model=20pin,=20audit=20sweep,=20formatter=20(#89)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(1.1): Scheduler — serial run queue built to the ratified Executor contract (#56) `enqueue(spec, {concurrent?}) → ScheduledRun{queueId, handle: Promise, cancel}` plus a multi-consumer lifecycle stream (queued/started/finished/cancelled/error) for RunsManager/StatusBar (1.2). Lives in @amicode/amico-run (node-only, no vscode) beside the LocalExecutor it drives. Built TO the Track C contract (ratified 2026-07-02) so Δ8's RemoteExecutor drops in with zero reshape: - S12: enqueue resolves to the executor's RunHandle UNTOUCHED (identity passthrough, pinned by test) — downstream never sees an executor type. - (b) abort() is a request, not a kill: the pump advances ONLY when `finished` resolves; a post-abort() run still holds the queue (pinned by test). - (c) per-executor warming budget: the Scheduler owns NO timers — structurally pinned (test greps the source for setTimeout/setInterval). - (d) `finished` never rejects per contract; a rogue rejection is survived (error event) rather than wedging every queued run. Semantics: strictly serial; cancel() dequeues only pre-start (a live run is stopped via RunHandle.abort(), never the queue); a submit() ConfigError rejects that entry's handle, emits `error`, and the queue advances; `concurrent: true` is the NAMED Phase-4 seam — rejected loudly (ConfigError) instead of silently serializing. Listener errors are isolated from the pump. TDD: 12 tests (RED first) — serial ordering, S12 identity, opts passthrough, abort≠terminated, lifecycle sequence with positions, cancel pre/post start, ConfigError advance, the concurrent seam, multi-listener + dispose, throwing listener, and the no-timers structural pin. Repo suite green (schema 34, amico-run 59, extension 80); build + typecheck clean. Closes #56. Co-Authored-By: Claude Fable 5 * fix(1.1): scheduler review — microtask-deferred re-pump + enforce the defensive claims Adversarial review (mutation-tested; 0 must-fix, 2 should-fix) — all folded in: - finally's re-pump is now queueMicrotask-deferred: a contract-violating executor whose submit() throws SYNCHRONOUSLY previously made the finally a direct recursion — a backlog of such failures accumulated behind a pending run blew the stack on drain (RangeError) and STRANDED the rest of the queue. Mutation-verified: reverting to the direct call fails the new test (RangeError + timeout); the deferral drains 8000 sync-throwers flat. - The rogue-`finished`-rejection branch is now enforced, not just advertised: new test pins error-event + queue-advance (mutation-verified: deleting the branch fails it). Rejection reason normalized (instanceof Error) to match the submit path. - Explicit unhandledRejection pin for the internal handle.catch suppression (an untouched ScheduledRun.handle never trips the process on cancel). - cancel() docstring now names all three false cases (started / already cancelled / mid-submit, where handle may still reject); no-timers grep widened to setImmediate|Date.now. 15 tests green; repo suite green; typecheck clean. Co-Authored-By: Claude Fable 5 * feat: catalog entry card — components, save-to-catalog flow, session catalog (UX2) (#73) * feat(47): catalog entry card — components, save-to-catalog flow, session catalog (UX2) The catalog card (Krishna p5, UX2) shipped end to end as a seam prototype. Identity is Hamiltonian-anchored and user-named; the card is the feedback artifact for the open field-selection questions. Components (media/ui): - chip: the entry's handle — gate · system · tags · #index. The system slot carries the USER-ASSIGNED name (researchers think in named devices, "Emerald-Q3"); derived family is the fallback. Tags render as dashed "proposed" segments — none of tags/index/name are in catalog-entry.schema.json, and the marking is deliberate. - catalogcard: header (chip + run-id + Tune/Warm-Start/Promote actions, VS Code secondary-button styling, right-justified) → pulseplot (reused, hydrated) → metadata / pulse-data / high-level-metrics panels → sibling-chips row. Metrics are uniform hero cards in a wrapping flex row: fidelity · gate time (params.T) · spectral bandwidth (95%-power, computed client-side from the knots, DC removed; definitional choices marked proposed) · robustness (empty proposed slot — needs perturbed rollouts recorded at solve time). Solver telemetry (iterations/wall) lives in metadata: provenance, not pulse quality. Drive labels are u_i, matching the trajectory component and Piccolo's plot defaults. Flow + shell (src): - Save to catalog: a converged run's promote prompt (live) or the demo replay prompt → optional system-name and tags input → a pointer entry in the session catalog (workspaceState — NOT the Phase-3 CatalogStore; Q91/Q92 open) → the card opens, hydrated from the real run artifacts (run.toml identity; result.toml fidelity/params with gate/system lifted; run.log pulse lines → the plot). - Catalog tree view: chip-shaped rows (gate · system · fidelity, tags in description/tooltip), newest-first, deduped by run_id; click reopens the card. amicode.catalog.refresh actually registered. - The solve-template change that records params.gate/params.system on NEW runs ships separately; the bundled demo fixture already carries them (schema-validated). Tests: pulse-line grammar fixtures, hydration (field mapping, gate lift, newest-record pulse, degradation), session tree (dedup, order, open-card command, empty state). Closes #47's Kate-lane scope as amended (actions row instead of the "what do next" section; resume hand-off design returns to UX1 — see the deferred-items comment on #46). Co-Authored-By: Claude Fable 5 * feat(47): remove-from-catalog — non-destructive unsave via the tree context menu Right-click a Catalog row → "Remove from Catalog": deletes the POINTER record only (workspaceState) — the run dir and pulse.jld2 stay on disk, per the raw-data-trust principle. Archive/supersede lifecycle belongs to the Phase-3 CatalogStore (Q94/Q95), deliberately not faked here. Kept off the command palette (when: false) — the action only means something on a row. Tests: removal preserves remaining order, is idempotent, and rows carry the context value gating the menu. Part of #47. Co-Authored-By: Claude Fable 5 * fix(47): reveal-or-create dedupe for catalog-card panels (review) Clicking the same catalog row repeatedly spawned duplicate panels. Per Jack's review: run_id → live-panel map, second open re-focuses via reveal, disposal cleans the map so a closed tab re-creates fresh. Shell test covers all three; vscode stub grows createWebviewPanel + registerCommand to support it. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 * 1.2 — RunsManager: multi-run engine keyed on the append-only runs/index (#70) * feat(1.2): RunsManager — multi-run engine keyed on the append-only runs/index (#57) Replaces β's single-run RunsRootWatcher. Discovery now tails `runs/index` (amico-run's appendIndex TSV) instead of following the `latest` symlink — `latest` keeps being written (frozen contract) but a second concurrent solve no longer yanks tracking off the first mid-flight: every run WITHOUT a FINISHED gets its own pipeline (replay → run-dir watch → run.log tail) and is tracked to completion. - run_registry.ts (pure, vscode-free): parseIndexLine (tolerant of torn/blank lines — the tail heals) + RunRegistry (idempotent by runId; iter high-water; FINISHED-keyed terminal state). - log_tailer.ts: LogTailer extracted verbatim from file_watcher.ts — reused for every run.log AND the index (both append-only). - runs_manager.ts: per-run pipelines with per-run PulseStream/SinkDedup; runId-gated routing — the SELECTED run drives the single-run Inspector + StatusBar (selection auto-follows the newest started run, β latest-follow parity; `selectRun` is 1.3's seam), while completions + the promote-once prompt fire for EVERY run, selected or not. Completion keys on FINISHED (never result.toml presence). Poll backstop + idempotent consumers as before. attachScheduler consumes the #56 lifecycle (structural SchedulerLike so this is independent of #68's merge): `started` registers + selects immediately. - extension.ts: RunsManager replaces the watcher; replayDemo now renders via EXPLICIT selection (pokeDiscovery + selectRun) since a finished-at-discovery run registers quietly (idle-at-launch parity) — promote stays suppressed. - file_watcher.ts deleted (superseded); its statemachine tests ported to runs_manager.test.ts (idle-on-finished, warming→FINISHED-keyed completion, #66 pulse tail routing, replay-seeded meta) + new multi-run coverage: concurrent runs both tracked with background completion/promote, re-select replay without promote re-pop, missing-dir index lines tolerated, the Scheduler seam, and the demo-replay explicit-selection path. 15 new tests; repo green (extension 103, amico-run 47, schema 34); typecheck + build clean. Closes #57. Co-Authored-By: Claude Fable 5 * fix(1.2): runs-manager review — disk-checked warming, scheduler metadata backfill, watch guards Adversarial review (0 must-fix, 2 should-fix) — applied: - selectRun re-checks DISK for FINISHED before posting warming (β parity): registry phase can be ≤700ms stale, and warming-after-completion inverted the terminal badge — real once 1.3's user-driven selectRun lands. - RunRegistry.backfill: a scheduler-registered run (runId+runDir only) gains createdAt/scriptPath when its index line lands — the 1.3 trees would otherwise see undefined metadata on every scheduler-launched run. - fs.watch 'error' listeners on all three watcher sites (root, per-run dir, tailer) — an unhandled FSWatcher error is an uncaught host exception; the poll backstop keeps things live. - RunRegistry.all() returns copies (1.3 callers can't mutate registry state); honest header comment on the transient replay/tail re-delivery window. - test/log_tailer.test.ts (review gap — the tailer is now load-bearing for discovery): torn-line carry-over, truncation re-read, startOffset contract, poke self-attach. 19 tests green, typecheck clean. Still owed next session (review nits, test-only): cross-run PULSE routing gate test, backfill unit test, stale-warming pin test; inspector pendingPulse reset on selection switch is deferred to 1.3. Co-Authored-By: Claude Fable 5 * test(1.2): close the owed #70 review-nit gaps (all mutation-verified) The three test gaps the RunsManager review flagged: - RunRegistry.backfill: fills ONLY missing metadata (first-registration wins), no-throw on unknown runId — mutation-verified (dropping the missing-only guard fails it). - RunRegistry.all(): returns copies — a mutated snapshot can't corrupt registry state. - cross-run PULSE routing: a background run's pulse RECORD is gated on selection (not just iter) — never reaches the inspector while another run is selected. - stale-warming: selecting a run whose FINISHED landed inside the ≤700ms poll window shows completion, NOT warming (no terminal-badge inversion) — mutation-verified (reverting the disk re-check fails it). extension 111 tests (+8), repo green (amico-run 47, schema 34); typecheck clean. * fix(1.2): review #70 — pinned selection, torn-FINISHED retry, markFinished guard, single-pass discovery, one terminal orchestration Addresses jack-champagne's static/design pass, one commit per nothing — all five findings land together because #1/#4 reshape the same registerRun path: #1 (design, the #72 seam): explicit selectRun PINS the selection; auto-follow (newest registered live run, β latest-follow parity) only applies while nothing is pinned. A background solve starting can no longer yank the view off a run the user deliberately opened. Mutation-verified. #2: a FINISHED that is present but torn/invalid at discovery no longer finalizes as status:undefined-forever — the run falls through to the live path, whose checkFinished re-reads next tick (the retry the live lane already had). Promote stays suppressed (launch replay). No warming either (disk-checked: FINISHED exists). #3: RunRegistry.markFinished guards phase itself (first terminal wins) — a stray re-mark can't leave status:"failed" beside a stale fidelity. The guard now lives on the public surface, not only in completeRun. #4: discovery ingests the run dir ONCE. Auto-follow assigns selection BEFORE the registration replay, so the single pipelineSink pass both seeds state and feeds the display through routeIter/routePulse's selection gate — displaySink now backs only explicit selection replays. #5: the FINISHED→status→result.toml→fidelity orchestration lives ONCE, in run_dir_reader.readTerminalState (say-why callback preserved via the manager's channel); ingestRunDir and RunsManager.readTerminal both delegate, so a contract change (e.g. #64's formulation.toml) is edited in one place. Also rebased onto main (#68 Scheduler, #77 vsix-gate, #79 permission grant). 118 extension tests pass; typecheck + build clean. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 * 1.3: Run Inspector single→multi-run (runId-keyed protocol, per-run panes) (#72) * feat(1.3): Run Inspector single→multi-run (runId-keyed protocol, per-run panes) Freeze-2 reshape (#58): the host↔webview message protocol is now runId-keyed and both the host and the webview fan into per-run panes. Single→multi only — the pane markup stays the current pulseplot (design lane, UX4 #49). Host (run_inspector.ts): a PaneBuffer per runId + activeRunId; runId-keyed surface postPulse/postIterationRecord/postCompletion/setWarmingUp/setRunLabel + new activate(runId). Per-run 5 Hz pulse throttle. resolveWebviewView replays EVERY pane from its buffer (S36) with positional ordering, then posts activate last. setWarmingUp guarded from clobbering a pane that already has data/terminal state. pulse stays plot-only (deliberately does not clear warming). Webview (media/ui/views/inspector.ts): createPanel() instances the former single-run view per runId (no shared globals); a router keys panels by runId, activate toggles the one visible pane, background/late messages only touch their own pane. Pane-hiding uses two-class selectors so it wins over layout.css `.stack` on specificity, not stylesheet order. RunsManager (runs_manager.ts): fans every run's live events into the inspector runId-tagged (routeIter/routePulse ungated); registration replay is state-only so the selected run never double-posts; selectRun adds activate; the single status bar stays selection-gated; completion + promote still fire per-run. Tests: runs_manager + inspector_view_contract updated to the runId-keyed API and fan-out semantics; added per-run isolation, per-run-throttle independence, S36 reopen, activate-last, warming-guard. New happy-dom webview test covers the router itself (per-run isolation, activate toggle, empty-state, plot-only pulse) — closes the coverage gap flagged in adversarial review. All invariants mutation-verified. 121 tests pass; typecheck + build clean. S6 (formulation preview) deferred — no formulation-emit in the frozen contract. Co-Authored-By: Claude Fable 5 * refactor(1.3): flow RunCompletion whole through completeRun — the #84/#81 seam Jack's #72 merge-seam heads-up: #81 adds `formulation?` to RunCompletion, and completeRun was the third completion path cherry-picking fields positionally (runId/status/fidelity) — once #81 landed, live-completed runs would carry formulation: undefined while replayed runs got it (the exact bug Kate caught on onFinished, reintroduced here). completeRun now takes the WHOLE RunCompletion; both feeders (ingestRunDir's sink verbatim, checkFinished via {runId, runDir, ...readTerminalState()}) funnel the object from the one shared read. An additive field is now a one-place edit (RunCompletion + readTerminalState) and reaches every consumer by construction — consumers cherry-pick at the leaf. Documented as the #84 funnel on both the type and completeRun; the full N-reader consolidation (catalog hydrator etc.) stays #84. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 * 1.4a: smoke corpus — Scheduler → executor → RunsManager → Inspector e2e (#78) * feat(1.3): Run Inspector single→multi-run (runId-keyed protocol, per-run panes) Freeze-2 reshape (#58): the host↔webview message protocol is now runId-keyed and both the host and the webview fan into per-run panes. Single→multi only — the pane markup stays the current pulseplot (design lane, UX4 #49). Host (run_inspector.ts): a PaneBuffer per runId + activeRunId; runId-keyed surface postPulse/postIterationRecord/postCompletion/setWarmingUp/setRunLabel + new activate(runId). Per-run 5 Hz pulse throttle. resolveWebviewView replays EVERY pane from its buffer (S36) with positional ordering, then posts activate last. setWarmingUp guarded from clobbering a pane that already has data/terminal state. pulse stays plot-only (deliberately does not clear warming). Webview (media/ui/views/inspector.ts): createPanel() instances the former single-run view per runId (no shared globals); a router keys panels by runId, activate toggles the one visible pane, background/late messages only touch their own pane. Pane-hiding uses two-class selectors so it wins over layout.css `.stack` on specificity, not stylesheet order. RunsManager (runs_manager.ts): fans every run's live events into the inspector runId-tagged (routeIter/routePulse ungated); registration replay is state-only so the selected run never double-posts; selectRun adds activate; the single status bar stays selection-gated; completion + promote still fire per-run. Tests: runs_manager + inspector_view_contract updated to the runId-keyed API and fan-out semantics; added per-run isolation, per-run-throttle independence, S36 reopen, activate-last, warming-guard. New happy-dom webview test covers the router itself (per-run isolation, activate toggle, empty-state, plot-only pulse) — closes the coverage gap flagged in adversarial review. All invariants mutation-verified. 121 tests pass; typecheck + build clean. S6 (formulation preview) deferred — no formulation-emit in the frozen contract. Co-Authored-By: Claude Fable 5 * refactor(1.3): flow RunCompletion whole through completeRun — the #84/#81 seam Jack's #72 merge-seam heads-up: #81 adds `formulation?` to RunCompletion, and completeRun was the third completion path cherry-picking fields positionally (runId/status/fidelity) — once #81 landed, live-completed runs would carry formulation: undefined while replayed runs got it (the exact bug Kate caught on onFinished, reintroduced here). completeRun now takes the WHOLE RunCompletion; both feeders (ingestRunDir's sink verbatim, checkFinished via {runId, runDir, ...readTerminalState()}) funnel the object from the one shared read. An additive field is now a one-place edit (RunCompletion + readTerminalState) and reaches every consumer by construction — consumers cherry-pick at the leaf. Documented as the #84 funnel on both the type and completeRun; the full N-reader consolidation (catalog hydrator etc.) stays #84. Co-Authored-By: Claude Fable 5 * test(1.4a): smoke corpus — seconds-scale end-to-end fixtures, Scheduler → executor → run-dir → RunsManager → inspector (#61) Two corpus fixtures (transmon X, cavity displacement — distinct telemetry profiles: 2×8 vs 1×6, 4 vs 3 iters) + test/corpus/fake-julia, a node stand-in the executor spawns exactly like julia (last-argv script, cwd=runDir). It reads each fixture's AMICODE_SMOKE directive and emits the template's telemetry grammar (PULSE_META / ITER / PULSE → run.log via the executor's tail) with a small inter-iter delay so the LIVE tail path is exercised, then writes a schema-conformant result.toml. Zero Julia/Piccolo cost: full chain in ~0.5s. The end-to-end test pins what the unit suites can't — that the pieces AGREE: - Scheduler (#56) lifecycle is strictly serial (B starts only after A's finished event) and satisfies RunsManager's structural seam; - the executor's run-dir writes (run.toml/index/run.log/result.toml/FINISHED) are exactly what the manager's tailer/registry read back (fidelity + iter high-water land in the registry); - telemetry reaches the inspector runId-keyed per run with no cross-tagging (asserted by per-run record dims, completion fidelity, iter records). Runs in the regular vitest suite → already in CI's fast job; #62 promotes it to a named required gate. Mutation-verified: a wrong result.toml fidelity reds the registry + completion assertions. Branch note: contains merge of rchari/56-scheduler (the corpus drives the real Scheduler); diff collapses once #68/#70/#72 land. Co-Authored-By: Claude Fable 5 * test(1.4a): review #78 — failure-lane fixture, throwing pumpUntil, wiring-vs-format scope note 1. failing_solve.jl (exit=1): the previously-dead `exit=` directive support now has a fixture — a solve that emits two iterations then dies. Asserts the full failure path end-to-end: executor writes FINISHED{failed}, no result.toml, registry terminal with fidelity undefined but latestIter=2 (pre-crash telemetry tracked), completion fans runId-keyed, promote never fires. 2. pumpUntil now THROWS on timeout with a named condition — a wiring regression fails fast at the offending await instead of an opaque hang. 3. Scope note in the suite header: this guards WIRING (fake ↔ parser), not FORMAT (template ↔ parser) — that boundary is #83's. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 * feat: theme-calculated Harmoniqs yellow — OKLCH-solved brand accent (brand-wide) brand_accent.ts computes the deployed accent from the active theme at webview boot: the canonical #FFF676 ships EXACTLY wherever contrast vs the theme's editor background clears 3:1 (all dark themes); light themes get the closest-to-brand gold by binary-searching lightness with hue + chroma held (gamut-clamped). Two tokens with different jobs: lines (--color-accent, contrast-solved: borders/rings/marks) and fills (--color-accent-fill, always the brand lemon — black text on it ≈ 19:1; a 3:1-darkened gold passes WCAG math but reads muddy under text). --color-on-accent is contrast-picked; yellow is never text. Recomputed live on theme switch. Inspector + catalog-card webviews apply at boot; brand.css statics remain the no-JS fallback. Pill atom gains a dot-less badge variant (dot = process state; badges describe things). Co-Authored-By: Claude Fable 5 * workbench: run picker + pane-ticker pause + runId-tagged controls (+ Kate's theme accent picked) - amicode.selectRun: QuickPick over the registry (newest first; live/completed/ stopped/failed icons, iter + fidelity + script). Picking pins; "Follow latest" releases the pin via RunsManager.resumeAutoFollow() (jumps to the newest live run). Pre-UX4 utility — unblocks real multi-run testing. - Hidden panes pause their 1 Hz elapsed-strip ticker (Panel.setActive from the router's activate); resumes with a fresh render on re-activation. - Control-row messages carry their pane's runId (post-UX4 correctness; commands still resolve the selected run today). - Cherry-picked Kate's 154650c: OKLCH theme-calculated brand accent — fixes the hardcoded #FFF676 that dies on light themes (audit P0-1, extension side). 408 tests pass; typecheck + build clean. Co-Authored-By: Claude Fable 5 * workbench: wire catalog what-next — tune/warm-start stage a concrete chat prompt (clipboard + open chat); promote says Phase-3 honestly Co-Authored-By: Claude Fable 5 * workbench: chat theme bridge — iframe boots with ?colorScheme= from the editor theme; live re-theme via onDidChangeActiveColorTheme → two-lane relay (origin-pinned) → app's setColorScheme Co-Authored-By: Claude Fable 5 * docs: one-spine mirror breadcrumb — readTerminalState semantics are mirrored in the fork's run-terminal.ts; change both in one change-set Co-Authored-By: Claude Fable 5 * workbench: animated H-robot mark in the Run Inspector (replaces the <0||0> text ket; breathe + eye-blink, reduced-motion aware); openRunDir reveals run.toml (bare-dir reveal errors on macOS) with openExternal fallback; inspector auto-open defaults ON Co-Authored-By: Claude Fable 5 * workbench: inspector mark = the house silhouette glyph (AmicoSpinner geometry + pulse-opacity language, reduced-motion aware); allow workbench.action.showCommands over the chat bridge (⌘⇧P → VS Code palette) Co-Authored-By: Claude Fable 5 * workbench: chat auto-opens when the server is ready (amicode.chat.autoOpen, default on) Co-Authored-By: Claude Fable 5 * workbench: inspector never reveals during the boot index replay — only a run that STARTS while the user works auto-opens it (chat remains the only boot-time surface) Co-Authored-By: Claude Fable 5 * workbench: boot-quiet test coverage — fresh-run warming asserts the post-boot path; new pin that boot replay never warms/reveals Co-Authored-By: Claude Fable 5 * workbench: clipboard bridge — the framed app requests paste over the message bridge; extension answers with vscode.env.clipboard (webviews can't delegate clipboard-read into iframes) Co-Authored-By: Claude Fable 5 * workbench: open-external bridge — framed app opens https links via vscode.env.openExternal (target=_blank is dead in the webview iframe) Co-Authored-By: Claude Fable 5 * status bar: stalled-run gate — FINISHED-less run with run.log silent >10min shows 'stalled' (warning), never a perpetual 'running · iter N' from boot replay; mirrors fork isStalled Co-Authored-By: Claude Fable 5 * score: never leak the 'issimo' entitlement codename into chat (read as truncated Piccolissimo); dedupe doubled routing paragraph in stage-1 notes Co-Authored-By: Claude Fable 5 * stop always terminates: escalation ladder (cooperative STOP → stalled runs get kill+finalize immediately → healthy runs get a 120s grace then an explicit Force-stop offer) - stopPlan: FINISHED → no-op; fresh run.log → cooperative; log cold past the stall threshold (or logless zombie dir) → force - findRunPids: two-key match — cmdline references the run's solve script AND process cwd IS the run dir (sibling runs share the script, never the cwd; no pattern-kills, ever) - forceStop: TERM → 1.5s → KILL survivors → forceFinalize writes the terminal FINISHED (status aborted, atomic rename; run.log breadcrumb) so every contract reader on both spines converges and the UI clears - never a silent kill on a live solver: one long Ipopt iteration can look wedged — the grace path asks before forcing Co-Authored-By: Claude Fable 5 * fix(stop): probe lsof at /usr/sbin (macOS) and /usr/bin (Linux) — hardcoded macOS path silently disabled the kill path on Linux (findRunPids proved nothing → force-finalized runs with the solver still alive) Co-Authored-By: Claude Fable 5 * fix(stop): realpath both sides of the cwd ownership proof — lsof reports physical paths, so a symlinked runs root (/tmp → /private/tmp on macOS) made every pid unprovable and the kill path a no-op Co-Authored-By: Claude Fable 5 * fix(stop): forceStop yields to a FINISHED that appeared during the TERM→KILL window — the orchestrator's truthful verdict (failed/143) must not be overwritten with aborted (disk vs registry vs fork endpoints would disagree forever) Co-Authored-By: Claude Fable 5 * fix(stop): re-prove pid ownership before the SIGKILL sweep — a pid freed by TERM can be reused by an unrelated process inside the 1.5s window Co-Authored-By: Claude Fable 5 * fix(stop): JSON-decode script_path from run.toml — values are written JSON-escaped, so escaped chars never matched ps argv and dropped the script-path key from the ownership match Co-Authored-By: Claude Fable 5 * test(stop): pin JSON-decoding of escaped script_path values Co-Authored-By: Claude Fable 5 * fix(stop): every stop toast and the force-stop dialog name the run — a nameless dialog 120s later reads as the wrong run being wedged Co-Authored-By: Claude Fable 5 * fix(stop): double-stop guard + escalation-timer disposal — a second Stop no longer stacks a second 120s dialog, and the timer dies with the extension instead of firing after deactivate Co-Authored-By: Claude Fable 5 * fix(stop): tolerate a deleted run dir — STOP write and finalize are best-effort so a removed dir can't crash the command before the UI entry is cleared Co-Authored-By: Claude Fable 5 * perf(runs): 2s TTL cache on liveStatus — boot replay of a long run.log paid one statSync per iter line Co-Authored-By: Claude Fable 5 * fix(runs): the poll backstop downgrades the selected run to 'stalled' — routeIter only fires when a line ARRIVES (i.e. not stalled), so a run that wedged mid-watch kept 'running · iter N' forever; downgrade-only so warming/iter flow stays untouched + pin test Co-Authored-By: Claude Fable 5 * fix(runs): selectRun consults liveStatus instead of hardcoding 'running' (picking a stalled run stamped a lie nothing would correct); finished runs' display replay no longer flickers stalled/running per line — completion sets the bar once Co-Authored-By: Claude Fable 5 * chore: remove empty runs_manager_boot.test.ts accidentally created by a shell append probe two commits ago (vitest fails on suite-less files) Co-Authored-By: Claude Fable 5 * fix(picker): stalled runs show '$(warning) stalled · iter N' — the picker advertised a wedge as '$(pulse) live', contradicting the status bar Co-Authored-By: Claude Fable 5 * fix(bridge): https scheme check is case-insensitive (RFC 3986); clipboard replies gated on panel visibility — a hidden panel rendering LLM-driven content must not sample the OS clipboard in the background Co-Authored-By: Claude Fable 5 * test(scores-e2e): version-agnostic score-marker assertion — hardcoded 'v1' while SCORE.md is v3, red on every creds-bearing machine Co-Authored-By: Claude Fable 5 * refactor(runs): one STALL_AFTER_MS (runs_manager imports run_controls's) and the one-spine mirror comment now names the stall threshold + display vocabulary as mirrored semantics Co-Authored-By: Claude Fable 5 * refactor(runs): one STALL_AFTER_MS (runs_manager imports run_controls's export); one-spine mirror comment names the stall threshold + display vocabulary as mirrored semantics Co-Authored-By: Claude Fable 5 * style: prettier 3.6.2 over the branch's touched files + a minimal .prettierrc (printWidth 120, semi) — the repo had no formatter configured; config formalizes the existing conventions Co-Authored-By: Claude Fable 5 * style: prettier over the branch's touched amico-run files (missed in the previous style commit) Co-Authored-By: Claude Fable 5 * style: prettier repo-wide (new .prettierrc/.prettierignore) — one-time full-repo normalization so the formatter is enforceable from here Co-Authored-By: Claude Fable 5 * fix(config): fallback-only model pin — without one, opencode's default resolution gambles on provider ordering and (with Google creds) picked a hanging preview model for every headless/agent turn; anthropic > GA gemini flash, and a user's global model always wins (1.17.3 preserve contract) + tests Co-Authored-By: Claude Fable 5 * ci(vsix-gate): fix by splitting package step — explicit build + fetch:opencode + vsce * test(slow): live-turn extractor is model-agnostic — Gemini opens with the amicode_ask TOOL CALL and no prose, so the ask input (question+options) counts as the turn text; production model pin wired into the e2e servers Co-Authored-By: Claude Fable 5 * feat(bridge): save-file lane — the run-card gallery's PNG export routes through a save dialog (downloads are dead in the framed app); PNG-only, basename-sanitized, size-bounded, relay-allowlisted Co-Authored-By: Claude Fable 5 * fix(config): google pin moves to gemini-2.5-flash — the newest flash is capacity-throttled at peak ('model overloaded' → failed turns render as 'model undefined' stubs in chat); the GA flash answered in 1.4s during the same window Co-Authored-By: Claude Fable 5 * fix(config): creds-free default model — opencode/deepseek-v4-flash-free (zen free tier, no user quota; answered a tool-bearing turn in ~3s while Gemini kept capacity-throttling); anthropic still wins when creds exist, user global model always wins Co-Authored-By: Claude Fable 5 * ci: authenticate the private-fork opencode fetch (GH_TOKEN) fast/vsix-gate/boot-smoke fetch the vendored opencode binary from the PRIVATE harmoniqs/opencode release via `gh release download`. The default Actions GITHUB_TOKEN is scoped to this repo only, so gh is unauthenticated for harmoniqs/opencode and the fetch exits 1 — reding every job that vendors opencode (schema-roundtrip is untouched; it needs no binary). Pass a cross-repo token as GH_TOKEN to the three fetch:opencode step defs (boot-smoke's single step covers both matrix legs). Mirrors #80, adapted to this branch's split vsix-gate step. Requires repo secret OPENCODE_FETCH_TOKEN: a fine-grained PAT scoped to harmoniqs/opencode with Contents:Read (validated end-to-end — downloads both assets and the bytes match opencode.lock.json's SHA256 gate). Stays red until the secret exists; green once it's set, no further push needed. Co-Authored-By: Claude Opus 4.8 (1M context) * chore(extension): bump opencode.lock to v1.17.3-amicode.2 Pin the vendored opencode binary to the new fork release cut from rchari/amicode-fixes (opencode PR #1): Gemini tool-schema fix, /amicode run-cards + profile endpoints, one-spine run truth, multi-drive pulse fix, save-file bridge. darwin-arm64 + linux-x64 sha256 updated. vsix build verified locally (linux-x64 fetch + sha match + vsce package -> amicode.vsix). Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Fable 5 Co-authored-by: Kate Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Jack Champagne Co-authored-by: Jack Champagne --- .DS_Store | Bin 0 -> 6148 bytes .github/workflows/ci.yml | 13 +- .prettierignore | 8 + .prettierrc | 4 + packages/.DS_Store | Bin 0 -> 6148 bytes packages/amico-run/.DS_Store | Bin 0 -> 6148 bytes packages/amico-run/esbuild.config.mjs | 22 +- packages/amico-run/src/authoring.ts | 60 +- packages/amico-run/src/baseline.ts | 39 +- packages/amico-run/src/catalog.ts | 170 ++- packages/amico-run/src/cli.ts | 227 ++-- packages/amico-run/src/event_queue.ts | 26 +- packages/amico-run/src/gate.ts | 126 +- packages/amico-run/src/import_scan.ts | 77 +- packages/amico-run/src/index.ts | 13 +- packages/amico-run/src/local_executor.ts | 213 +-- packages/amico-run/src/run_dir.ts | 93 +- packages/amico-run/src/scheduler.ts | 180 +++ packages/amico-run/src/subcommands.ts | 122 +- packages/amico-run/src/telemetry.ts | 20 +- packages/amico-run/src/types.ts | 55 +- packages/amico-run/src/verify.ts | 49 +- packages/amico-run/test/abort.test.ts | 74 +- packages/amico-run/test/authoring.test.ts | 82 +- packages/amico-run/test/baseline.test.ts | 50 +- packages/amico-run/test/catalog.test.ts | 127 +- packages/amico-run/test/cli.test.ts | 324 +++-- packages/amico-run/test/failure_lanes.test.ts | 221 ++-- packages/amico-run/test/gate.test.ts | 154 +-- packages/amico-run/test/helpers.ts | 20 +- packages/amico-run/test/import_scan.test.ts | 42 +- .../amico-run/test/local_executor.test.ts | 124 +- packages/amico-run/test/run_dir.test.ts | 185 +-- packages/amico-run/test/s31.test.ts | 23 +- packages/amico-run/test/scheduler.test.ts | 294 +++++ packages/amico-run/test/schemas.test.ts | 97 +- .../amico-run/test/slow/integration.test.ts | 57 +- packages/amico-run/test/subcommands.test.ts | 166 +-- packages/amico-run/test/telemetry.test.ts | 54 +- packages/amico-run/test/verify.test.ts | 104 +- packages/extension/.DS_Store | Bin 0 -> 10244 bytes packages/extension/AGENTS.md | 17 +- packages/extension/CONTRACT.md | 20 +- packages/extension/DEMO_CHECKLIST.md | 18 +- packages/extension/DISTILLER.md | 34 +- packages/extension/RUNBOOK.md | 17 +- packages/extension/TESTING.md | 2 +- packages/extension/demo/run/result.toml | 2 + packages/extension/demo/run/run.log | 2 +- .../dev/pulseplot_harness/index.html | 100 +- .../extension/dev/pulseplot_harness/main.ts | 70 +- packages/extension/esbuild.config.mjs | 12 + packages/extension/julia/README.md | 2 + packages/extension/media/brand.css | 4 +- packages/extension/media/layout.css | 61 +- packages/extension/media/ui/atoms/button.ts | 18 +- packages/extension/media/ui/atoms/icon.ts | 35 +- packages/extension/media/ui/atoms/pill.ts | 21 +- packages/extension/media/ui/atoms/text.ts | 14 +- packages/extension/media/ui/brand_accent.ts | 149 +++ .../media/ui/components/catalogcard.ts | 287 ++++ .../extension/media/ui/components/chip.ts | 52 + .../extension/media/ui/components/metric.ts | 7 +- .../media/ui/components/pulseplot.ts | 40 +- .../media/ui/components/sparkline.ts | 43 +- .../extension/media/ui/views/inspector.ts | 188 ++- .../vendor/katex/fonts/KaTeX_AMS-Regular.ttf | Bin 0 -> 63632 bytes .../vendor/katex/fonts/KaTeX_AMS-Regular.woff | Bin 0 -> 33516 bytes .../katex/fonts/KaTeX_AMS-Regular.woff2 | Bin 0 -> 28076 bytes .../katex/fonts/KaTeX_Caligraphic-Bold.ttf | Bin 0 -> 12368 bytes .../katex/fonts/KaTeX_Caligraphic-Bold.woff | Bin 0 -> 7716 bytes .../katex/fonts/KaTeX_Caligraphic-Bold.woff2 | Bin 0 -> 6912 bytes .../katex/fonts/KaTeX_Caligraphic-Regular.ttf | Bin 0 -> 12344 bytes .../fonts/KaTeX_Caligraphic-Regular.woff | Bin 0 -> 7656 bytes .../fonts/KaTeX_Caligraphic-Regular.woff2 | Bin 0 -> 6908 bytes .../vendor/katex/fonts/KaTeX_Fraktur-Bold.ttf | Bin 0 -> 19584 bytes .../katex/fonts/KaTeX_Fraktur-Bold.woff | Bin 0 -> 13296 bytes .../katex/fonts/KaTeX_Fraktur-Bold.woff2 | Bin 0 -> 11348 bytes .../katex/fonts/KaTeX_Fraktur-Regular.ttf | Bin 0 -> 19572 bytes .../katex/fonts/KaTeX_Fraktur-Regular.woff | Bin 0 -> 13208 bytes .../katex/fonts/KaTeX_Fraktur-Regular.woff2 | Bin 0 -> 11316 bytes .../vendor/katex/fonts/KaTeX_Main-Bold.ttf | Bin 0 -> 51336 bytes .../vendor/katex/fonts/KaTeX_Main-Bold.woff | Bin 0 -> 29912 bytes .../vendor/katex/fonts/KaTeX_Main-Bold.woff2 | Bin 0 -> 25324 bytes .../katex/fonts/KaTeX_Main-BoldItalic.ttf | Bin 0 -> 32968 bytes .../katex/fonts/KaTeX_Main-BoldItalic.woff | Bin 0 -> 19412 bytes .../katex/fonts/KaTeX_Main-BoldItalic.woff2 | Bin 0 -> 16780 bytes .../vendor/katex/fonts/KaTeX_Main-Italic.ttf | Bin 0 -> 33580 bytes .../vendor/katex/fonts/KaTeX_Main-Italic.woff | Bin 0 -> 19676 bytes .../katex/fonts/KaTeX_Main-Italic.woff2 | Bin 0 -> 16988 bytes .../vendor/katex/fonts/KaTeX_Main-Regular.ttf | Bin 0 -> 53580 bytes .../katex/fonts/KaTeX_Main-Regular.woff | Bin 0 -> 30772 bytes .../katex/fonts/KaTeX_Main-Regular.woff2 | Bin 0 -> 26272 bytes .../katex/fonts/KaTeX_Math-BoldItalic.ttf | Bin 0 -> 31196 bytes .../katex/fonts/KaTeX_Math-BoldItalic.woff | Bin 0 -> 18668 bytes .../katex/fonts/KaTeX_Math-BoldItalic.woff2 | Bin 0 -> 16400 bytes .../vendor/katex/fonts/KaTeX_Math-Italic.ttf | Bin 0 -> 31308 bytes .../vendor/katex/fonts/KaTeX_Math-Italic.woff | Bin 0 -> 18748 bytes .../katex/fonts/KaTeX_Math-Italic.woff2 | Bin 0 -> 16440 bytes .../katex/fonts/KaTeX_SansSerif-Bold.ttf | Bin 0 -> 24504 bytes .../katex/fonts/KaTeX_SansSerif-Bold.woff | Bin 0 -> 14408 bytes .../katex/fonts/KaTeX_SansSerif-Bold.woff2 | Bin 0 -> 12216 bytes .../katex/fonts/KaTeX_SansSerif-Italic.ttf | Bin 0 -> 22364 bytes .../katex/fonts/KaTeX_SansSerif-Italic.woff | Bin 0 -> 14112 bytes .../katex/fonts/KaTeX_SansSerif-Italic.woff2 | Bin 0 -> 12028 bytes .../katex/fonts/KaTeX_SansSerif-Regular.ttf | Bin 0 -> 19436 bytes .../katex/fonts/KaTeX_SansSerif-Regular.woff | Bin 0 -> 12316 bytes .../katex/fonts/KaTeX_SansSerif-Regular.woff2 | Bin 0 -> 10344 bytes .../katex/fonts/KaTeX_Script-Regular.ttf | Bin 0 -> 16648 bytes .../katex/fonts/KaTeX_Script-Regular.woff | Bin 0 -> 10588 bytes .../katex/fonts/KaTeX_Script-Regular.woff2 | Bin 0 -> 9644 bytes .../katex/fonts/KaTeX_Size1-Regular.ttf | Bin 0 -> 12228 bytes .../katex/fonts/KaTeX_Size1-Regular.woff | Bin 0 -> 6496 bytes .../katex/fonts/KaTeX_Size1-Regular.woff2 | Bin 0 -> 5468 bytes .../katex/fonts/KaTeX_Size2-Regular.ttf | Bin 0 -> 11508 bytes .../katex/fonts/KaTeX_Size2-Regular.woff | Bin 0 -> 6188 bytes .../katex/fonts/KaTeX_Size2-Regular.woff2 | Bin 0 -> 5208 bytes .../katex/fonts/KaTeX_Size3-Regular.ttf | Bin 0 -> 7588 bytes .../katex/fonts/KaTeX_Size3-Regular.woff | Bin 0 -> 4420 bytes .../katex/fonts/KaTeX_Size3-Regular.woff2 | Bin 0 -> 3624 bytes .../katex/fonts/KaTeX_Size4-Regular.ttf | Bin 0 -> 10364 bytes .../katex/fonts/KaTeX_Size4-Regular.woff | Bin 0 -> 5980 bytes .../katex/fonts/KaTeX_Size4-Regular.woff2 | Bin 0 -> 4928 bytes .../katex/fonts/KaTeX_Typewriter-Regular.ttf | Bin 0 -> 27556 bytes .../katex/fonts/KaTeX_Typewriter-Regular.woff | Bin 0 -> 16028 bytes .../fonts/KaTeX_Typewriter-Regular.woff2 | Bin 0 -> 13568 bytes .../media/vendor/katex/katex.min.css | 1162 +++++++++++++++++ .../opencode-plugin/amicode_tools.ts | 73 +- .../opencode-plugin/distill_queue.ts | 11 +- .../extension/opencode-plugin/entities.ts | 8 +- .../extension/opencode-plugin/onboarding.ts | 5 +- .../extension/opencode-plugin/problems.ts | 15 +- .../extension/opencode-plugin/score_guard.ts | 7 +- packages/extension/opencode.lock.json | 12 +- packages/extension/package.json | 47 +- packages/extension/scores/README.md | 31 +- .../scores/memory/confidence-rubric.md | 13 +- packages/extension/scores/overture/SCORE.md | 20 +- .../extension/scores/pulse-designer/SCORE.md | 13 +- .../extension/scripts/build_exemplars.mjs | 101 +- packages/extension/scripts/distill_batch.mjs | 31 +- packages/extension/scripts/fetch_opencode.mjs | 167 ++- packages/extension/scripts/healthcheck.mjs | 115 +- packages/extension/scripts/opencode_probe.mjs | 25 +- packages/extension/scripts/plugin_exercise.ts | 39 +- packages/extension/src/catalog_card_shell.ts | 145 ++ .../extension/src/catalog_card_webview.ts | 72 + packages/extension/src/chat_panel.ts | 141 +- packages/extension/src/demo_replay.ts | 2 +- packages/extension/src/executor_check.ts | 10 +- packages/extension/src/extension.ts | 271 +++- packages/extension/src/file_watcher.ts | 389 ------ packages/extension/src/inspector_webview.ts | 3 + packages/extension/src/llm_creds.d.mts | 12 +- packages/extension/src/llm_creds.mjs | 3 +- packages/extension/src/log_tailer.ts | 115 ++ packages/extension/src/opencode_binary.ts | 5 +- packages/extension/src/opencode_config.ts | 106 +- packages/extension/src/run_controls.ts | 169 +++ packages/extension/src/run_dir_reader.ts | 159 ++- packages/extension/src/run_inspector.ts | 299 +++-- packages/extension/src/run_registry.ts | 99 ++ packages/extension/src/runs_manager.ts | 637 +++++++++ packages/extension/src/scores/compiler.ts | 10 +- .../extension/src/scores/package_skills.ts | 20 +- packages/extension/src/scores/schema.ts | 13 +- packages/extension/src/server_manager.ts | 27 +- packages/extension/src/sse_client.ts | 19 +- packages/extension/src/status_bar.ts | 28 +- packages/extension/src/substrate/distiller.ts | 10 +- packages/extension/src/trees.ts | 97 +- packages/extension/src/types.ts | 2 +- packages/extension/test/__mocks__/vscode.ts | 49 +- packages/extension/test/agents_md.test.ts | 256 ++-- packages/extension/test/amicode_tools.test.ts | 519 ++++---- packages/extension/test/boot_smoke.mjs | 25 +- packages/extension/test/brand_accent.test.ts | 60 + packages/extension/test/catalog_shell.test.ts | 149 +++ .../test/corpus/cavity_displacement.jl | 16 + .../extension/test/corpus/failing_solve.jl | 11 + packages/extension/test/corpus/fake-julia | 71 + packages/extension/test/corpus/transmon_x.jl | 21 + packages/extension/test/demo_replay.test.ts | 64 +- .../extension/test/fetch_opencode.test.ts | 184 +-- packages/extension/test/hashes.test.ts | 26 +- packages/extension/test/healthcheck.test.ts | 41 +- .../test/inspector_view_contract.test.ts | 191 ++- .../test/inspector_webview_view.test.ts | 94 ++ packages/extension/test/lab_config.test.ts | 44 +- packages/extension/test/llm_creds.test.ts | 179 +-- packages/extension/test/log_tailer.test.ts | 61 + .../extension/test/opencode_binary.test.ts | 60 +- .../extension/test/opencode_config.test.ts | 339 +++-- .../extension/test/opencode_paths.test.ts | 82 +- packages/extension/test/packaging.test.ts | 86 +- packages/extension/test/problems.test.ts | 430 +++--- packages/extension/test/run_controls.test.ts | 118 ++ .../test/run_dir_reader_stopped.test.ts | 10 +- packages/extension/test/run_registry.test.ts | 78 ++ packages/extension/test/runs_manager.test.ts | 465 +++++++ .../test/scores/allowlist_production.test.ts | 6 +- .../test/scores/entitlements_router.test.ts | 26 +- packages/extension/test/scores/guard.test.ts | 24 +- .../test/scores/overture_routing.test.ts | 24 +- .../test/scores/package_skills.test.ts | 18 +- .../test/scores/prep_integration.test.ts | 23 +- .../test/scores/repertoire_lint.test.ts | 14 +- packages/extension/test/scores/schema.test.ts | 50 +- .../extension/test/slow/interview_e2e.test.ts | 336 +++-- .../extension/test/slow/scores_e2e.test.ts | 264 ++-- packages/extension/test/slow/template.test.ts | 51 +- .../test/slow/verify_harness.test.ts | 59 +- .../slow/verify_spline_free_phase.test.ts | 64 +- packages/extension/test/smoke_corpus.test.ts | 189 +++ packages/extension/test/sparkline.test.ts | 4 +- packages/extension/test/status_bar.test.ts | 9 + .../test/substrate/user_splice.test.ts | 10 +- .../test/substrate/vault_store.test.ts | 5 +- .../extension/test/watcher_contract.test.ts | 422 +++--- .../test/watcher_statemachine.test.ts | 129 -- packages/schema/.DS_Store | Bin 0 -> 8196 bytes packages/schema/esbuild.config.mjs | 18 +- packages/schema/package.json | 8 +- .../schema/schemas/catalog-entry.schema.json | 12 +- packages/schema/schemas/lab.schema.json | 28 +- packages/schema/schemas/result.schema.json | 13 +- packages/schema/schemas/run.schema.json | 21 +- packages/schema/schemas/solvespec.schema.json | 32 +- packages/schema/src/cli.ts | 26 +- packages/schema/src/index.ts | 19 +- packages/schema/test/cli.test.ts | 14 +- packages/schema/test/validate.test.ts | 146 ++- pnpm-lock.yaml | 75 +- 233 files changed, 10973 insertions(+), 4583 deletions(-) create mode 100644 .DS_Store create mode 100644 .prettierignore create mode 100644 .prettierrc create mode 100644 packages/.DS_Store create mode 100644 packages/amico-run/.DS_Store create mode 100644 packages/amico-run/src/scheduler.ts create mode 100644 packages/amico-run/test/scheduler.test.ts create mode 100644 packages/extension/.DS_Store create mode 100644 packages/extension/media/ui/brand_accent.ts create mode 100644 packages/extension/media/ui/components/catalogcard.ts create mode 100644 packages/extension/media/ui/components/chip.ts create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_AMS-Regular.ttf create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_AMS-Regular.woff create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_Caligraphic-Bold.ttf create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_Caligraphic-Regular.ttf create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_Fraktur-Bold.ttf create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_Fraktur-Regular.ttf create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_Main-Bold.ttf create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_Main-Bold.woff create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_Main-Bold.woff2 create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_Main-BoldItalic.ttf create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_Main-Italic.ttf create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_Main-Italic.woff create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_Main-Italic.woff2 create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_Main-Regular.ttf create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_Main-Regular.woff create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_Main-Regular.woff2 create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_Math-BoldItalic.ttf create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_Math-Italic.ttf create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_Math-Italic.woff create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_Math-Italic.woff2 create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Bold.ttf create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Italic.ttf create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Regular.ttf create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_Script-Regular.ttf create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_Script-Regular.woff create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_Script-Regular.woff2 create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_Size1-Regular.ttf create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_Size1-Regular.woff create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_Size2-Regular.ttf create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_Size2-Regular.woff create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_Size3-Regular.ttf create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_Size3-Regular.woff create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_Size4-Regular.ttf create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_Size4-Regular.woff create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_Typewriter-Regular.ttf create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff create mode 100644 packages/extension/media/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 create mode 100644 packages/extension/media/vendor/katex/katex.min.css create mode 100644 packages/extension/src/catalog_card_shell.ts create mode 100644 packages/extension/src/catalog_card_webview.ts delete mode 100644 packages/extension/src/file_watcher.ts create mode 100644 packages/extension/src/log_tailer.ts create mode 100644 packages/extension/src/run_registry.ts create mode 100644 packages/extension/src/runs_manager.ts create mode 100644 packages/extension/test/brand_accent.test.ts create mode 100644 packages/extension/test/catalog_shell.test.ts create mode 100644 packages/extension/test/corpus/cavity_displacement.jl create mode 100644 packages/extension/test/corpus/failing_solve.jl create mode 100755 packages/extension/test/corpus/fake-julia create mode 100644 packages/extension/test/corpus/transmon_x.jl create mode 100644 packages/extension/test/inspector_webview_view.test.ts create mode 100644 packages/extension/test/log_tailer.test.ts create mode 100644 packages/extension/test/run_registry.test.ts create mode 100644 packages/extension/test/runs_manager.test.ts create mode 100644 packages/extension/test/smoke_corpus.test.ts delete mode 100644 packages/extension/test/watcher_statemachine.test.ts create mode 100644 packages/schema/.DS_Store diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..4627c288496d7cf87997167c2a8a6bb5db3dc19b GIT binary patch literal 6148 zcmeHLF;4<96#lA2FQ90gTug3tF=28&@SGeRSWJvj5d|Z7M8w4Het?rEZZ3{a?oJNI zUtnDP2fFKozSmZ1IUr7kn7*X_dav)bA78na0zed6kOtxSvbHMzm*^ zQ1BSFO0KvQ)fzhC9jx#=qx96)lvk*)g-4j4(O8&ga7Wcgdk^oO;w@(m%@L1OJ=P37XhJ zgfez*K7!TYnT!aN&;5$U$BOFk=^thD;r*D_rA({R>{=IbF=qCe9L}>(^Dl3DU39Lg zMz4NrUUAFwPT)`}fKAk8tA!eN(KHoFf6iPFiZyxVZ~V(S^?m6(w#oB1@h0!d=a_kW zXkeRG?||w`H{WdgYQ0yC$@Agp>_YQQjAim06le09%E)zOZo_DFX0v%2g|2oAI0c*n zKML^v;8S4?G{y?$)fK$L$U{pO$^ZCEr|Nd_$xhJQ9Q{Z1IAW~s5 zEYL^NXKUia@mcFr*`mV6aj`;~LX|#_WrL4m{$D{F_4(W&3^c|H$wTIT2uK-RAEBMG3(6}*D zI;1Na({ON+%hteORDjp6Ol?Z&lDZVEU%lB)laZrbOR&1jvXV58JA<@?-qQ5*>BvrA zk5^^(*32CBgPFHm7!EU^(=FY=59t~_Q~rI;v#Rftm*rU@zvs8RIltU^Njm5Eb7sh| z`tiv8Ho>Y(eeg@D9WtZc)BErJA*Y859qfx{3uAn>3d|JX{lTI!MjkUkw{^g|D*&*FuroaKU4m<@$H-$Qh#r`cQlOL?f5k9T zj=1%Ck;hC>%E|c4hw(ETe?u{Dc8qUbI+;jNy(*vzEGn=euQfjZPgdXm7mM^w6;K8K zl>(+1H{%9A$)ByckK?n}qP5W2I4={NmEgf0#mMEOcn8fH;+7wPk;hCB5t#lFa5AV< I1%6e5FQkNcp#T5? literal 0 HcmV?d00001 diff --git a/packages/amico-run/.DS_Store b/packages/amico-run/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..294803c07324749cb3b1f72dd6b35bbc2c2b5a6e GIT binary patch literal 6148 zcmeHKy-EW?5S~pA3@RaoqGI6%Z0wRo+cR7m+dP1pAB>Qgn4eDJd;_1sH}DZ`4A|KV zzJP_5cH%d?OLFTau@MoOf!%L!XZGg1?{c$QB2uMcy-1WJq6`|Nw}@_vv7cMQQg&tq zsQ4bOlVYXTX?GKmG9e0x0)J5f{&qd8(<$|7;C#Q?ol)G^?u22r)v01G^H9DVJiUze zU4MP9zuSz}hHL>#QB+!VOhY=O8a1gwJtse2>lcozJ5ItMcV{qqg6WaqNrdT-Y1ujQJ;7r}^Ey zuei0?4=I2>n=P|sP-^K+NBZFo+DSP-(HnXxP6s5D{{=N<;6&RFW6c7bm1=8j+$NPVI z_W9pkBv+zoM7 TMg}nhlRpAl2I)kBUsd1}M?b7P literal 0 HcmV?d00001 diff --git a/packages/amico-run/esbuild.config.mjs b/packages/amico-run/esbuild.config.mjs index eb12aaa7..69a8d9b7 100644 --- a/packages/amico-run/esbuild.config.mjs +++ b/packages/amico-run/esbuild.config.mjs @@ -1,17 +1,17 @@ -import { build } from 'esbuild' -import { chmodSync } from 'node:fs' +import { build } from "esbuild"; +import { chmodSync } from "node:fs"; await build({ - entryPoints: ['src/cli.ts'], + entryPoints: ["src/cli.ts"], bundle: true, - platform: 'node', - target: 'node20', + platform: "node", + target: "node20", // ESM, not CJS: the package is "type": "module", so node executes dist/amico-run.js // as ESM — a CJS bundle would die on `require is not defined in ES module scope`. - format: 'esm', - outfile: 'dist/amico-run.js', - banner: { js: '#!/usr/bin/env node' }, + format: "esm", + outfile: "dist/amico-run.js", + banner: { js: "#!/usr/bin/env node" }, sourcemap: true, - logLevel: 'info', -}) -chmodSync('dist/amico-run.js', 0o755) + logLevel: "info", +}); +chmodSync("dist/amico-run.js", 0o755); diff --git a/packages/amico-run/src/authoring.ts b/packages/amico-run/src/authoring.ts index 30a9e5c8..788046eb 100644 --- a/packages/amico-run/src/authoring.ts +++ b/packages/amico-run/src/authoring.ts @@ -4,9 +4,9 @@ // assets); the gate reads it here. Absent file → conservative built-in // defaults (public base ∪ support set) so a bare-but-spec'd dev invocation // still gates sanely. $AMICO_AUTHORING_FILE overrides the path (tests). -import { existsSync, readFileSync } from 'node:fs' -import { homedir } from 'node:os' -import { join } from 'node:path' +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; // NOTE (spec-20260704-113005 §3): session prep ALSO writes an additive // `skills: [{source: "library"|"package", package?, name, description, path}]` @@ -14,54 +14,54 @@ import { join } from 'node:path' // record for provenance/UI; amico-run does not consume it (unknown fields are // ignored here), so it is intentionally NOT in this interface. export interface AuthoringConfig { - allowlist: string[] // entitlement-resolved Harmoniqs packages - support_set: string[] // fixed support packages the run-dir contract itself needs - registry?: string // abs path to templates/registry.toml - exemplars?: string // abs path to exemplars/index.json - verify_harness?: string // abs path to julia/verify_rollout.jl - verify_tolerance: number // tier-3 re-rollout agreement (absolute) + allowlist: string[]; // entitlement-resolved Harmoniqs packages + support_set: string[]; // fixed support packages the run-dir contract itself needs + registry?: string; // abs path to templates/registry.toml + exemplars?: string; // abs path to exemplars/index.json + verify_harness?: string; // abs path to julia/verify_rollout.jl + verify_tolerance: number; // tier-3 re-rollout agreement (absolute) } -export const DEFAULT_ALLOWLIST = ['Piccolo', 'Legato', 'Intonato', 'NamedTrajectories', 'DirectTrajOpt'] -export const DEFAULT_SUPPORT = ['JLD2', 'CairoMakie', 'Makie', 'TOML', 'Printf'] -const DEFAULT_TOLERANCE = 0.001 +export const DEFAULT_ALLOWLIST = ["Piccolo", "Legato", "Intonato", "NamedTrajectories", "DirectTrajOpt"]; +export const DEFAULT_SUPPORT = ["JLD2", "CairoMakie", "Makie", "TOML", "Printf"]; +const DEFAULT_TOLERANCE = 0.001; function defaults(): AuthoringConfig { return { allowlist: [...DEFAULT_ALLOWLIST], support_set: [...DEFAULT_SUPPORT], verify_tolerance: DEFAULT_TOLERANCE, - } + }; } export function authoringFile(): string { - const env = process.env.AMICO_AUTHORING_FILE - if (env && env.trim() !== '') return env - return join(homedir(), '.amico', 'authoring', 'authoring.json') + const env = process.env.AMICO_AUTHORING_FILE; + if (env && env.trim() !== "") return env; + return join(homedir(), ".amico", "authoring", "authoring.json"); } export function readAuthoring(): { config: AuthoringConfig; warning?: string } { - const file = authoringFile() - if (!existsSync(file)) return { config: defaults() } - let raw: unknown + const file = authoringFile(); + if (!existsSync(file)) return { config: defaults() }; + let raw: unknown; try { - raw = JSON.parse(readFileSync(file, 'utf8')) + raw = JSON.parse(readFileSync(file, "utf8")); } catch { - return { config: defaults(), warning: `malformed authoring.json at ${file} — using built-in defaults` } + return { config: defaults(), warning: `malformed authoring.json at ${file} — using built-in defaults` }; } - if (typeof raw !== 'object' || raw === null) - return { config: defaults(), warning: `authoring.json at ${file} is not an object — using built-in defaults` } - const data = raw as Record + if (typeof raw !== "object" || raw === null) + return { config: defaults(), warning: `authoring.json at ${file} is not an object — using built-in defaults` }; + const data = raw as Record; const strings = (v: unknown): string[] | undefined => - Array.isArray(v) ? v.filter((s): s is string => typeof s === 'string') : undefined + Array.isArray(v) ? v.filter((s): s is string => typeof s === "string") : undefined; return { config: { allowlist: strings(data.allowlist) ?? [...DEFAULT_ALLOWLIST], support_set: strings(data.support_set) ?? [...DEFAULT_SUPPORT], - registry: typeof data.registry === 'string' ? data.registry : undefined, - exemplars: typeof data.exemplars === 'string' ? data.exemplars : undefined, - verify_harness: typeof data.verify_harness === 'string' ? data.verify_harness : undefined, - verify_tolerance: typeof data.verify_tolerance === 'number' ? data.verify_tolerance : DEFAULT_TOLERANCE, + registry: typeof data.registry === "string" ? data.registry : undefined, + exemplars: typeof data.exemplars === "string" ? data.exemplars : undefined, + verify_harness: typeof data.verify_harness === "string" ? data.verify_harness : undefined, + verify_tolerance: typeof data.verify_tolerance === "number" ? data.verify_tolerance : DEFAULT_TOLERANCE, }, - } + }; } diff --git a/packages/amico-run/src/baseline.ts b/packages/amico-run/src/baseline.ts index 2bc10f8e..e3f59ab1 100644 --- a/packages/amico-run/src/baseline.ts +++ b/packages/amico-run/src/baseline.ts @@ -6,32 +6,37 @@ // convention's `# ── FILL IN` / `# ─────` pair; an index entry may override // with fill_begin/fill_end regex sources. Unterminated blocks mask to EOF // (conservative: an attacker deleting the end marker can't unmask anything). -import { createHash } from 'node:crypto' +import { createHash } from "node:crypto"; -const DEFAULT_BEGIN = '^# ── FILL IN' -const DEFAULT_END = '^# ─────' +const DEFAULT_BEGIN = "^# ── FILL IN"; +const DEFAULT_END = "^# ─────"; export function maskFillPoints(text: string, beginSource?: string, endSource?: string): string { - const begin = new RegExp(beginSource ?? DEFAULT_BEGIN) - const end = new RegExp(endSource ?? DEFAULT_END) - const out: string[] = [] - let inside = false - for (const line of text.split('\n')) { + const begin = new RegExp(beginSource ?? DEFAULT_BEGIN); + const end = new RegExp(endSource ?? DEFAULT_END); + const out: string[] = []; + let inside = false; + for (const line of text.split("\n")) { if (!inside && begin.test(line)) { - inside = true - out.push(line) - continue + inside = true; + out.push(line); + continue; } if (inside && end.test(line)) { - inside = false - out.push(line) - continue + inside = false; + out.push(line); + continue; } - out.push(inside ? '#MASKED' : line) + out.push(inside ? "#MASKED" : line); } - return out.join('\n') + return out.join("\n"); } export function maskedHash(text: string, beginSource?: string, endSource?: string): string { - return 'sha256:' + createHash('sha256').update(maskFillPoints(text, beginSource, endSource)).digest('hex') + return ( + "sha256:" + + createHash("sha256") + .update(maskFillPoints(text, beginSource, endSource)) + .digest("hex") + ); } diff --git a/packages/amico-run/src/catalog.ts b/packages/amico-run/src/catalog.ts index 1c47fbeb..855f0a50 100644 --- a/packages/amico-run/src/catalog.ts +++ b/packages/amico-run/src/catalog.ts @@ -5,131 +5,131 @@ // exemplars index (exemplars/index.json, built by build_exemplars.mjs) is // tier 2, with build-time masked baseline_hash per entry. Loaders never // throw: a missing/corrupt catalog degrades to tier 3, not a crash. -import { existsSync, readFileSync } from 'node:fs' -import { parse as parseToml } from 'smol-toml' -import { JULIA_STDLIBS } from './import_scan.js' +import { existsSync, readFileSync } from "node:fs"; +import { parse as parseToml } from "smol-toml"; +import { JULIA_STDLIBS } from "./import_scan.js"; export interface TemplateEntry { - id: string - platform: string - kind: string - size: number - path: string - packages: string[] - status: string // "vetted" | "experimental" | … - entitlement?: string // required entitlement id, when gated - fill_begin?: string - fill_end?: string + id: string; + platform: string; + kind: string; + size: number; + path: string; + packages: string[]; + status: string; // "vetted" | "experimental" | … + entitlement?: string; // required entitlement id, when gated + fill_begin?: string; + fill_end?: string; } export interface ExemplarEntry { - id: string - platform: string - kind: string - size: number - path: string - packages: string[] - baseline_hash: string - notes?: string - fill_begin?: string - fill_end?: string + id: string; + platform: string; + kind: string; + size: number; + path: string; + packages: string[]; + baseline_hash: string; + notes?: string; + fill_begin?: string; + fill_end?: string; } export interface Registry { - templates: TemplateEntry[] - support: string[] - uuids: Record - verifyTolerance: number + templates: TemplateEntry[]; + support: string[]; + uuids: Record; + verifyTolerance: number; } export interface ExemplarsIndex { - exemplars: ExemplarEntry[] + exemplars: ExemplarEntry[]; } export interface Shape { - platform: string - kind: string - size: number + platform: string; + kind: string; + size: number; } export interface ShapeMatch { - tier: 'vetted' | 'composed' | 'free' - template?: TemplateEntry - exemplar?: ExemplarEntry - blockedHigher?: { tier: 'vetted' | 'composed'; requires: string } + tier: "vetted" | "composed" | "free"; + template?: TemplateEntry; + exemplar?: ExemplarEntry; + blockedHigher?: { tier: "vetted" | "composed"; requires: string }; } -const EMPTY_REGISTRY: Registry = { templates: [], support: [], uuids: {}, verifyTolerance: 0.01 } +const EMPTY_REGISTRY: Registry = { templates: [], support: [], uuids: {}, verifyTolerance: 0.01 }; function strings(v: unknown): string[] { - return Array.isArray(v) ? v.filter((s): s is string => typeof s === 'string') : [] + return Array.isArray(v) ? v.filter((s): s is string => typeof s === "string") : []; } export function loadRegistry(file: string): Registry { - if (!existsSync(file)) return EMPTY_REGISTRY - let parsed: Record + if (!existsSync(file)) return EMPTY_REGISTRY; + let parsed: Record; try { - parsed = parseToml(readFileSync(file, 'utf8')) as Record + parsed = parseToml(readFileSync(file, "utf8")) as Record; } catch { - return EMPTY_REGISTRY + return EMPTY_REGISTRY; } const templates = (Array.isArray(parsed.template) ? parsed.template : []) - .filter((t): t is Record => typeof t === 'object' && t !== null) - .filter((t) => typeof t.id === 'string' && typeof t.platform === 'string' && typeof t.kind === 'string') + .filter((t): t is Record => typeof t === "object" && t !== null) + .filter((t) => typeof t.id === "string" && typeof t.platform === "string" && typeof t.kind === "string") .map( (t): TemplateEntry => ({ id: t.id as string, platform: t.platform as string, kind: t.kind as string, - size: typeof t.size === 'number' ? t.size : 1, - path: typeof t.path === 'string' ? t.path : '', + size: typeof t.size === "number" ? t.size : 1, + path: typeof t.path === "string" ? t.path : "", packages: strings(t.packages), - status: typeof t.status === 'string' ? t.status : 'experimental', - entitlement: typeof t.entitlement === 'string' ? t.entitlement : undefined, - fill_begin: typeof t.fill_begin === 'string' ? t.fill_begin : undefined, - fill_end: typeof t.fill_end === 'string' ? t.fill_end : undefined, + status: typeof t.status === "string" ? t.status : "experimental", + entitlement: typeof t.entitlement === "string" ? t.entitlement : undefined, + fill_begin: typeof t.fill_begin === "string" ? t.fill_begin : undefined, + fill_end: typeof t.fill_end === "string" ? t.fill_end : undefined, }), - ) - const support = strings((parsed.support as Record | undefined)?.packages) - const uuids: Record = {} - if (typeof parsed.uuids === 'object' && parsed.uuids !== null) + ); + const support = strings((parsed.support as Record | undefined)?.packages); + const uuids: Record = {}; + if (typeof parsed.uuids === "object" && parsed.uuids !== null) for (const [name, uuid] of Object.entries(parsed.uuids as Record)) - if (typeof uuid === 'string') uuids[name] = uuid + if (typeof uuid === "string") uuids[name] = uuid; return { templates, support, uuids, - verifyTolerance: typeof parsed.verify_tolerance === 'number' ? parsed.verify_tolerance : 0.01, - } + verifyTolerance: typeof parsed.verify_tolerance === "number" ? parsed.verify_tolerance : 0.01, + }; } export function loadExemplarsIndex(file: string): ExemplarsIndex { - if (!existsSync(file)) return { exemplars: [] } - let parsed: unknown + if (!existsSync(file)) return { exemplars: [] }; + let parsed: unknown; try { - parsed = JSON.parse(readFileSync(file, 'utf8')) + parsed = JSON.parse(readFileSync(file, "utf8")); } catch { - return { exemplars: [] } + return { exemplars: [] }; } - const raw = (parsed as Record)?.exemplars + const raw = (parsed as Record)?.exemplars; const exemplars = (Array.isArray(raw) ? raw : []) - .filter((e): e is Record => typeof e === 'object' && e !== null) - .filter((e) => typeof e.id === 'string' && typeof e.baseline_hash === 'string') + .filter((e): e is Record => typeof e === "object" && e !== null) + .filter((e) => typeof e.id === "string" && typeof e.baseline_hash === "string") .map( (e): ExemplarEntry => ({ id: e.id as string, - platform: typeof e.platform === 'string' ? e.platform : '', - kind: typeof e.kind === 'string' ? e.kind : '', - size: typeof e.size === 'number' ? e.size : 1, - path: typeof e.path === 'string' ? e.path : '', + platform: typeof e.platform === "string" ? e.platform : "", + kind: typeof e.kind === "string" ? e.kind : "", + size: typeof e.size === "number" ? e.size : 1, + path: typeof e.path === "string" ? e.path : "", packages: strings(e.packages), baseline_hash: e.baseline_hash as string, - notes: typeof e.notes === 'string' ? e.notes : undefined, - fill_begin: typeof e.fill_begin === 'string' ? e.fill_begin : undefined, - fill_end: typeof e.fill_end === 'string' ? e.fill_end : undefined, + notes: typeof e.notes === "string" ? e.notes : undefined, + fill_begin: typeof e.fill_begin === "string" ? e.fill_begin : undefined, + fill_end: typeof e.fill_end === "string" ? e.fill_end : undefined, }), - ) - return { exemplars } + ); + return { exemplars }; } /** Tier resolution (spec C, locked decision 5): exact vetted template match → @@ -143,27 +143,25 @@ export function matchShape( exemplars: ExemplarsIndex, allowlist: string[], ): ShapeMatch { - const allowed = new Set([...allowlist, ...registry.support, ...JULIA_STDLIBS]) - const packagesOk = (packages: string[]) => packages.every((p) => allowed.has(p)) - let blockedHigher: ShapeMatch['blockedHigher'] + const allowed = new Set([...allowlist, ...registry.support, ...JULIA_STDLIBS]); + const packagesOk = (packages: string[]) => packages.every((p) => allowed.has(p)); + let blockedHigher: ShapeMatch["blockedHigher"]; const templateMatches = registry.templates.filter( - (t) => t.status === 'vetted' && t.platform === shape.platform && t.kind === shape.kind && t.size === shape.size, - ) + (t) => t.status === "vetted" && t.platform === shape.platform && t.kind === shape.kind && t.size === shape.size, + ); for (const template of templateMatches) { - if (packagesOk(template.packages)) return { tier: 'vetted', template } - blockedHigher ??= { tier: 'vetted', requires: template.entitlement ?? 'unknown' } + if (packagesOk(template.packages)) return { tier: "vetted", template }; + blockedHigher ??= { tier: "vetted", requires: template.entitlement ?? "unknown" }; } - const exemplarMatches = exemplars.exemplars.filter( - (e) => e.platform === shape.platform && e.kind === shape.kind, - ) + const exemplarMatches = exemplars.exemplars.filter((e) => e.platform === shape.platform && e.kind === shape.kind); // prefer exact-size, then any - exemplarMatches.sort((a, b) => Number(b.size === shape.size) - Number(a.size === shape.size)) + exemplarMatches.sort((a, b) => Number(b.size === shape.size) - Number(a.size === shape.size)); for (const exemplar of exemplarMatches) { - if (packagesOk(exemplar.packages)) return { tier: 'composed', exemplar, blockedHigher } - blockedHigher ??= { tier: 'composed', requires: 'unknown' } + if (packagesOk(exemplar.packages)) return { tier: "composed", exemplar, blockedHigher }; + blockedHigher ??= { tier: "composed", requires: "unknown" }; } - return { tier: 'free', blockedHigher } + return { tier: "free", blockedHigher }; } diff --git a/packages/amico-run/src/cli.ts b/packages/amico-run/src/cli.ts index e261b4b4..2ef2ddc0 100644 --- a/packages/amico-run/src/cli.ts +++ b/packages/amico-run/src/cli.ts @@ -1,16 +1,19 @@ -import { existsSync, readFileSync } from 'node:fs' -import { join } from 'node:path' -import { parse as parseToml } from 'smol-toml' -import { LocalExecutor } from './local_executor.js' -import { ConfigError, type Finished, type SubmitOpts } from './types.js' -import { readAuthoring } from './authoring.js' -import { runGate } from './gate.js' -import { runVerification } from './verify.js' -import { trySubcommand } from './subcommands.js' +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { parse as parseToml } from "smol-toml"; +import { LocalExecutor } from "./local_executor.js"; +import { ConfigError, type Finished, type SubmitOpts } from "./types.js"; +import { readAuthoring } from "./authoring.js"; +import { runGate } from "./gate.js"; +import { runVerification } from "./verify.js"; +import { trySubcommand } from "./subcommands.js"; function readTomlSafe(fp: string): Record | undefined { - try { return parseToml(readFileSync(fp, 'utf8')) as Record } - catch { return undefined } + try { + return parseToml(readFileSync(fp, "utf8")) as Record; + } catch { + return undefined; + } } const USAGE = `usage: amico-run [--executor local] [--lab ] @@ -18,72 +21,119 @@ const USAGE = `usage: amico-run [--executor local] [--lab ] (spec C: validate + gate before launch) amico-run resolve --platform

--kind --size (tier resolution → JSON) amico-run sandbox --packages A,B,… (generate env/Project.toml) - (a bare script literally named "resolve"/"sandbox" still launches — dispatch checks the file exists)` + (a bare script literally named "resolve"/"sandbox" still launches — dispatch checks the file exists)`; export async function main(argv: string[]): Promise { // spec C subcommands — dispatched before the launch flag loop - const sub = trySubcommand(argv) - if (sub !== undefined) return sub + const sub = trySubcommand(argv); + if (sub !== undefined) return sub; - let script: string | undefined - let executor = 'local' - let specPath: string | undefined - const opts: SubmitOpts = { julia: {} } - let projectExplicit = false + let script: string | undefined; + let executor = "local"; + let specPath: string | undefined; + const opts: SubmitOpts = { julia: {} }; + let projectExplicit = false; for (let i = 0; i < argv.length; i++) { - const a = argv[i] + const a = argv[i]; const next = (): string => { - const v = argv[++i] - if (v === undefined) throw new ConfigError(`flag ${a} requires a value`) - return v - } + const v = argv[++i]; + if (v === undefined) throw new ConfigError(`flag ${a} requires a value`); + return v; + }; try { switch (a) { - case '--help': case '-h': console.log(USAGE); return 0 - case '--executor': executor = next(); break - case '--lab': opts.lab = next(); break - case '--runs-root': opts.runsRoot = next(); break - case '--julia': opts.julia!.julia = next(); break - case '--project': opts.julia!.project = next(); projectExplicit = true; break - case '--sysimage': opts.julia!.sysimage = next(); break - case '--spec': specPath = next(); break + case "--help": + case "-h": + console.log(USAGE); + return 0; + case "--executor": + executor = next(); + break; + case "--lab": + opts.lab = next(); + break; + case "--runs-root": + opts.runsRoot = next(); + break; + case "--julia": + opts.julia!.julia = next(); + break; + case "--project": + opts.julia!.project = next(); + projectExplicit = true; + break; + case "--sysimage": + opts.julia!.sysimage = next(); + break; + case "--spec": + specPath = next(); + break; default: - if (a.startsWith('-')) { console.error(`amico-run: unknown flag ${a}\n${USAGE}`); return 64 } - if (script) { console.error(`amico-run: multiple scripts given`); return 64 } - script = a + if (a.startsWith("-")) { + console.error(`amico-run: unknown flag ${a}\n${USAGE}`); + return 64; + } + if (script) { + console.error(`amico-run: multiple scripts given`); + return 64; + } + script = a; } } catch (e) { - if (e instanceof ConfigError) { console.error(`amico-run: ${e.message}`); return 64 } - throw e + if (e instanceof ConfigError) { + console.error(`amico-run: ${e.message}`); + return 64; + } + throw e; } } - if (!script) { console.error(`amico-run: no script given\n${USAGE}`); return 64 } - if (executor !== 'local') { console.error(`amico-run: only --executor local is supported in β`); return 64 } + if (!script) { + console.error(`amico-run: no script given\n${USAGE}`); + return 64; + } + if (executor !== "local") { + console.error(`amico-run: only --executor local is supported in β`); + return 64; + } // ── spec C: the launch gate. Failures leave NO run dir and exit 64. ── if (specPath) { - let specRaw: unknown - try { specRaw = JSON.parse(readFileSync(specPath, 'utf8')) } - catch (e) { console.error(`amico-run: cannot read --spec ${specPath}: ${(e as Error).message}`); return 64 } - let scriptText: string - try { scriptText = readFileSync(script, 'utf8') } - catch (e) { console.error(`amico-run: cannot read script ${script}: ${(e as Error).message}`); return 64 } - const { config: authoring, warning } = readAuthoring() - if (warning) console.error(`amico-run: ${warning}`) - const gate = runGate(specRaw, scriptText, authoring) - if (!gate.ok) { console.error(`amico-run: gate: ${gate.reason}`); return 64 } + let specRaw: unknown; + try { + specRaw = JSON.parse(readFileSync(specPath, "utf8")); + } catch (e) { + console.error(`amico-run: cannot read --spec ${specPath}: ${(e as Error).message}`); + return 64; + } + let scriptText: string; + try { + scriptText = readFileSync(script, "utf8"); + } catch (e) { + console.error(`amico-run: cannot read script ${script}: ${(e as Error).message}`); + return 64; + } + const { config: authoring, warning } = readAuthoring(); + if (warning) console.error(`amico-run: ${warning}`); + const gate = runGate(specRaw, scriptText, authoring); + if (!gate.ok) { + console.error(`amico-run: gate: ${gate.reason}`); + return 64; + } // env resolution: spec env.project feeds --project unless the flag was explicit - const env = (specRaw as { env?: { kind?: string; project?: string } }).env - if (env?.project && (env.kind === 'project' || env.kind === 'sandbox')) { + const env = (specRaw as { env?: { kind?: string; project?: string } }).env; + if (env?.project && (env.kind === "project" || env.kind === "sandbox")) { if (projectExplicit && opts.julia!.project !== env.project) - console.error(`amico-run: --project ${opts.julia!.project} overrides the spec's env.project ${env.project}`) - else opts.julia!.project = env.project + console.error(`amico-run: --project ${opts.julia!.project} overrides the spec's env.project ${env.project}`); + else opts.julia!.project = env.project; } opts.spec = { - canonical: gate.stamp.specCanonical, tier: gate.stamp.tier, hashes: gate.stamp.hashes, - julia_binary: opts.julia!.julia, env_project: opts.julia!.project, - } + canonical: gate.stamp.specCanonical, + tier: gate.stamp.tier, + hashes: gate.stamp.hashes, + julia_binary: opts.julia!.julia, + env_project: opts.julia!.project, + }; } // NOTE: `--sysimage ` is honored (passed through to the Julia process and @@ -93,53 +143,60 @@ export async function main(argv: string[]): Promise { // (CI build on self-hosted runners → R2 → manifest → download), pointed at via // this flag. Until that exists, solves pay the cold start (inspector warms up). - let handle + let handle; try { - handle = await new LocalExecutor().submit(script, opts) + handle = await new LocalExecutor().submit(script, opts); } catch (e) { - if (e instanceof ConfigError) { console.error(`amico-run: ${e.message}`); return 64 } - throw e + if (e instanceof ConfigError) { + console.error(`amico-run: ${e.message}`); + return 64; + } + throw e; } - const onSignal = (): void => { void handle.abort() } - process.on('SIGINT', onSignal) - process.on('SIGTERM', onSignal) + const onSignal = (): void => { + void handle.abort(); + }; + process.on("SIGINT", onSignal); + process.on("SIGTERM", onSignal); - let fin: Finished | undefined + let fin: Finished | undefined; for await (const ev of handle.events) { - if (ev.kind === 'iter' || ev.kind === 'done') console.log(ev.raw) - else if (ev.kind === 'log') console.log(ev.line) - else fin = { status: ev.status, exitCode: ev.exitCode } + if (ev.kind === "iter" || ev.kind === "done") console.log(ev.raw); + else if (ev.kind === "log") console.log(ev.line); + else fin = { status: ev.status, exitCode: ev.exitCode }; } - const f = fin ?? await handle.finished + const f = fin ?? (await handle.finished); // FINISHED-write failure lane (spec §6 last row): verdict file must exist on disk - if (!existsSync(join(handle.runDir, 'FINISHED'))) { - console.error(`amico-run: FINISHED missing in ${handle.runDir} (write fault)`) - return 64 + if (!existsSync(join(handle.runDir, "FINISHED"))) { + console.error(`amico-run: FINISHED missing in ${handle.runDir} (write fault)`); + return 64; } // spec C: free-tier re-rollout verification runs AFTER FINISHED, BEFORE the // AMICODE_FINISHED line — so consumers see a settled verification state. The // harness (or the fallback) always writes verification.toml; the promote gate // keys off agree==true. - if (opts.spec?.tier === 'free') { - const { config: authoring } = readAuthoring() - await runVerification(handle.runDir, opts.spec, authoring) - const verified = readTomlSafe(join(handle.runDir, 'verification.toml')) - console.log(`AMICODE_VERIFIED agree=${verified?.agree === true}`) + if (opts.spec?.tier === "free") { + const { config: authoring } = readAuthoring(); + await runVerification(handle.runDir, opts.spec, authoring); + const verified = readTomlSafe(join(handle.runDir, "verification.toml")); + console.log(`AMICODE_VERIFIED agree=${verified?.agree === true}`); } // stdout protocol line — camelCase by design (spec §4) - console.log(`AMICODE_FINISHED status=${f.status} exitCode=${f.exitCode} runDir=${handle.runDir}`) - if (f.status === 'aborted') return 130 - if (f.status === 'completed') return 0 - return f.exitCode === 0 ? 1 : f.exitCode + console.log(`AMICODE_FINISHED status=${f.status} exitCode=${f.exitCode} runDir=${handle.runDir}`); + if (f.status === "aborted") return 130; + if (f.status === "completed") return 0; + return f.exitCode === 0 ? 1 : f.exitCode; } main(process.argv.slice(2)).then( - c => { process.exitCode = c }, - e => { + (c) => { + process.exitCode = c; + }, + (e) => { // Any unexpected throw is an orchestrator fault, not a solve failure → 64. - console.error(`amico-run: unexpected error: ${e instanceof Error ? e.stack ?? e.message : e}`) - process.exitCode = 64 + console.error(`amico-run: unexpected error: ${e instanceof Error ? (e.stack ?? e.message) : e}`); + process.exitCode = 64; }, -) +); diff --git a/packages/amico-run/src/event_queue.ts b/packages/amico-run/src/event_queue.ts index 361b42f3..61b1bdd1 100644 --- a/packages/amico-run/src/event_queue.ts +++ b/packages/amico-run/src/event_queue.ts @@ -1,26 +1,26 @@ /** Push-based AsyncIterable: producer pushes, single consumer iterates. */ export class EventQueue implements AsyncIterable { - private buf: T[] = [] - private waiters: Array<(r: IteratorResult) => void> = [] - private ended = false + private buf: T[] = []; + private waiters: Array<(r: IteratorResult) => void> = []; + private ended = false; push(v: T): void { - if (this.ended) return // late producers (post-settle) are dropped, never buffered - const w = this.waiters.shift() - if (w) w({ value: v, done: false }) - else this.buf.push(v) + if (this.ended) return; // late producers (post-settle) are dropped, never buffered + const w = this.waiters.shift(); + if (w) w({ value: v, done: false }); + else this.buf.push(v); } close(): void { - this.ended = true - for (const w of this.waiters.splice(0)) w({ value: undefined as never, done: true }) + this.ended = true; + for (const w of this.waiters.splice(0)) w({ value: undefined as never, done: true }); } [Symbol.asyncIterator](): AsyncIterator { return { next: (): Promise> => { - if (this.buf.length > 0) return Promise.resolve({ value: this.buf.shift()!, done: false }) - if (this.ended) return Promise.resolve({ value: undefined as never, done: true }) - return new Promise(res => this.waiters.push(res)) + if (this.buf.length > 0) return Promise.resolve({ value: this.buf.shift()!, done: false }); + if (this.ended) return Promise.resolve({ value: undefined as never, done: true }); + return new Promise((res) => this.waiters.push(res)); }, - } + }; } } diff --git a/packages/amico-run/src/gate.ts b/packages/amico-run/src/gate.ts index 6e7a799d..ad83bfe9 100644 --- a/packages/amico-run/src/gate.ts +++ b/packages/amico-run/src/gate.ts @@ -6,107 +6,105 @@ // env is validated against its OWN Manifest, not the extension-pinned one); // (4) tier-2 masked-baseline check; (5) stamp assembly (canonical spec + // gate-computed spec_hash). Any failure → no Julia process, one clear line. -import { createHash } from 'node:crypto' -import { existsSync, readFileSync } from 'node:fs' -import { join } from 'node:path' -import { parse as parseToml } from 'smol-toml' -import { validate } from '@amicode/schema' -import type { AuthoringConfig } from './authoring.js' -import { checkImports, scanImports } from './import_scan.js' -import { maskedHash } from './baseline.js' -import { loadExemplarsIndex } from './catalog.js' +import { createHash } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { parse as parseToml } from "smol-toml"; +import { validate } from "@amicode/schema"; +import type { AuthoringConfig } from "./authoring.js"; +import { checkImports, scanImports } from "./import_scan.js"; +import { maskedHash } from "./baseline.js"; +import { loadExemplarsIndex } from "./catalog.js"; export interface GateStamp { - tier?: string - hashes: Record // spec hashes + gate-computed spec_hash - specCanonical: string // stable-key-order JSON, what gets persisted + tier?: string; + hashes: Record; // spec hashes + gate-computed spec_hash + specCanonical: string; // stable-key-order JSON, what gets persisted } -export type GateResult = - | { ok: true; stamp: GateStamp } - | { ok: false; reason: string; demote_to?: 'free' } +export type GateResult = { ok: true; stamp: GateStamp } | { ok: false; reason: string; demote_to?: "free" }; /** Stable key order at every level so spec_hash is insensitive to author key order. */ function canonicalize(value: unknown): unknown { - if (Array.isArray(value)) return value.map(canonicalize) - if (typeof value === 'object' && value !== null) { - const out: Record = {} + if (Array.isArray(value)) return value.map(canonicalize); + if (typeof value === "object" && value !== null) { + const out: Record = {}; for (const key of Object.keys(value as Record).sort()) - out[key] = canonicalize((value as Record)[key]) - return out + out[key] = canonicalize((value as Record)[key]); + return out; } - return value + return value; } /** Julia Manifest v2 keys deps as [[deps.]] — the parsed `deps` object's * keys ARE the package names. Every Project [deps] name must appear. */ function staleEnvCheck(projectDir: string): string | undefined { - const projectFile = join(projectDir, 'Project.toml') - const manifestFile = join(projectDir, 'Manifest.toml') - if (!existsSync(projectFile)) return `env has no Project.toml at ${projectDir}` + const projectFile = join(projectDir, "Project.toml"); + const manifestFile = join(projectDir, "Manifest.toml"); + if (!existsSync(projectFile)) return `env has no Project.toml at ${projectDir}`; if (!existsSync(manifestFile)) - return `env at ${projectDir} has no Manifest.toml — instantiate it first (JULIA_PKG_USE_CLI_GIT=true julia --project=${projectDir} -e 'using Pkg; Pkg.instantiate()')` + return `env at ${projectDir} has no Manifest.toml — instantiate it first (JULIA_PKG_USE_CLI_GIT=true julia --project=${projectDir} -e 'using Pkg; Pkg.instantiate()')`; try { - const project = parseToml(readFileSync(projectFile, 'utf8')) as Record - const manifest = parseToml(readFileSync(manifestFile, 'utf8')) as Record - const wanted = Object.keys((project.deps as Record) ?? {}) - const present = new Set(Object.keys((manifest.deps as Record) ?? {})) - const missing = wanted.filter((name) => !present.has(name)) + const project = parseToml(readFileSync(projectFile, "utf8")) as Record; + const manifest = parseToml(readFileSync(manifestFile, "utf8")) as Record; + const wanted = Object.keys((project.deps as Record) ?? {}); + const present = new Set(Object.keys((manifest.deps as Record) ?? {})); + const missing = wanted.filter((name) => !present.has(name)); if (missing.length > 0) - return `stale env: ${missing.join(', ')} in Project.toml but not its Manifest — re-instantiate` + return `stale env: ${missing.join(", ")} in Project.toml but not its Manifest — re-instantiate`; } catch (e) { - return `env at ${projectDir} has an unparseable Project/Manifest: ${(e as Error).message}` + return `env at ${projectDir} has an unparseable Project/Manifest: ${(e as Error).message}`; } - return undefined + return undefined; } export function runGate(specRaw: unknown, scriptText: string, authoring: AuthoringConfig): GateResult { // ── step 1: schema ── - const validation = validate(specRaw, 'solvespec') - if (!validation.ok) return { ok: false, reason: `solvespec schema: ${validation.errors[0]}` } - const spec = specRaw as Record - const tier = typeof spec.tier === 'string' ? spec.tier : undefined - const env = (typeof spec.env === 'object' && spec.env !== null ? spec.env : undefined) as + const validation = validate(specRaw, "solvespec"); + if (!validation.ok) return { ok: false, reason: `solvespec schema: ${validation.errors[0]}` }; + const spec = specRaw as Record; + const tier = typeof spec.tier === "string" ? spec.tier : undefined; + const env = (typeof spec.env === "object" && spec.env !== null ? spec.env : undefined) as | { kind?: string; project?: string } - | undefined + | undefined; // ── step 2: import scan ── - const scanned = scanImports(scriptText) - if (!scanned.ok) return { ok: false, reason: scanned.reason } - const checked = checkImports(scanned.roots, authoring) - if (!checked.ok) return { ok: false, reason: checked.reason } + const scanned = scanImports(scriptText); + if (!scanned.ok) return { ok: false, reason: scanned.reason }; + const checked = checkImports(scanned.roots, authoring); + if (!checked.ok) return { ok: false, reason: checked.reason }; // ── step 3: tier/env consistency ── - if (tier === 'free' && env?.kind !== 'sandbox') - return { ok: false, reason: 'free tier requires a sandbox env (env.kind = "sandbox")' } - if ((env?.kind === 'project' || env?.kind === 'sandbox') && env.project) { - const stale = staleEnvCheck(env.project) - if (stale) return { ok: false, reason: stale } + if (tier === "free" && env?.kind !== "sandbox") + return { ok: false, reason: 'free tier requires a sandbox env (env.kind = "sandbox")' }; + if ((env?.kind === "project" || env?.kind === "sandbox") && env.project) { + const stale = staleEnvCheck(env.project); + if (stale) return { ok: false, reason: stale }; } // ── step 4: composed → masked baseline vs the exemplar's build-time hash ── - if (tier === 'composed') { - const exemplarId = (spec.source as Record | undefined)?.exemplar_id - if (typeof exemplarId !== 'string') - return { ok: false, reason: 'tier "composed" requires source.exemplar_id' } - const index = loadExemplarsIndex(authoring.exemplars ?? '') - const entry = index.exemplars.find((e) => e.id === exemplarId) - if (!entry) return { ok: false, reason: `unknown exemplar_id "${exemplarId}" (index: ${authoring.exemplars ?? 'absent'})` } + if (tier === "composed") { + const exemplarId = (spec.source as Record | undefined)?.exemplar_id; + if (typeof exemplarId !== "string") return { ok: false, reason: 'tier "composed" requires source.exemplar_id' }; + const index = loadExemplarsIndex(authoring.exemplars ?? ""); + const entry = index.exemplars.find((e) => e.id === exemplarId); + if (!entry) + return { ok: false, reason: `unknown exemplar_id "${exemplarId}" (index: ${authoring.exemplars ?? "absent"})` }; if (maskedHash(scriptText, entry.fill_begin, entry.fill_end) !== entry.baseline_hash) return { ok: false, reason: `script is no longer the exemplar's physics (edits outside the fill points of "${exemplarId}") — re-assemble as tier "free"`, - demote_to: 'free', - } + demote_to: "free", + }; } // ── step 5: stamp — canonical spec + gate-computed spec_hash ── - const specCanonical = JSON.stringify(canonicalize(spec), null, 2) - const specHash = 'sha256:' + createHash('sha256').update(specCanonical).digest('hex') - const hashes: Record = {} - if (typeof spec.hashes === 'object' && spec.hashes !== null) + const specCanonical = JSON.stringify(canonicalize(spec), null, 2); + const specHash = "sha256:" + createHash("sha256").update(specCanonical).digest("hex"); + const hashes: Record = {}; + if (typeof spec.hashes === "object" && spec.hashes !== null) for (const [key, value] of Object.entries(spec.hashes as Record)) - if (typeof value === 'string') hashes[key] = value - hashes.spec_hash = specHash - return { ok: true, stamp: { tier, hashes, specCanonical } } + if (typeof value === "string") hashes[key] = value; + hashes.spec_hash = specHash; + return { ok: true, stamp: { tier, hashes, specCanonical } }; } diff --git a/packages/amico-run/src/import_scan.ts b/packages/amico-run/src/import_scan.ts index a7cc5a3a..41b7801c 100644 --- a/packages/amico-run/src/import_scan.ts +++ b/packages/amico-run/src/import_scan.ts @@ -7,50 +7,63 @@ // the templates/skeletons all use one statement per line. export const JULIA_STDLIBS = new Set([ - 'LinearAlgebra', 'Random', 'Statistics', 'SparseArrays', 'Printf', 'TOML', 'Dates', - 'Test', 'Pkg', 'Serialization', 'SHA', 'Logging', 'Markdown', 'UUIDs', - 'Distributed', 'InteractiveUtils', 'Base64', 'Unicode', 'REPL', -]) + "LinearAlgebra", + "Random", + "Statistics", + "SparseArrays", + "Printf", + "TOML", + "Dates", + "Test", + "Pkg", + "Serialization", + "SHA", + "Logging", + "Markdown", + "UUIDs", + "Distributed", + "InteractiveUtils", + "Base64", + "Unicode", + "REPL", +]); -export type ScanResult = { ok: true; roots: string[] } | { ok: false; reason: string } -export type CheckResult = { ok: true } | { ok: false; reason: string } +export type ScanResult = { ok: true; roots: string[] } | { ok: false; reason: string }; +export type CheckResult = { ok: true } | { ok: false; reason: string }; -const IMPORT_LINE = /^\s*(using|import)\s+(.+)$/ +const IMPORT_LINE = /^\s*(using|import)\s+(.+)$/; /** Strip a trailing comment (naive: templates never put `#` inside strings on import lines). */ function stripComment(line: string): string { - const hash = line.indexOf('#') - return hash === -1 ? line : line.slice(0, hash) + const hash = line.indexOf("#"); + return hash === -1 ? line : line.slice(0, hash); } export function scanImports(script: string): ScanResult { - const roots: string[] = [] - for (const rawLine of script.split('\n')) { - const line = stripComment(rawLine) - const match = IMPORT_LINE.exec(line) - if (!match) continue - const payload = match[2].trim() - if (payload.endsWith(',')) - return { ok: false, reason: 'multi-line using/import not supported — one statement per line' } - for (const item of payload.split(',')) { - const trimmed = item.trim() - if (!trimmed) continue - const root = trimmed.split(/[.:\s]/, 1)[0] - if (root && !roots.includes(root)) roots.push(root) + const roots: string[] = []; + for (const rawLine of script.split("\n")) { + const line = stripComment(rawLine); + const match = IMPORT_LINE.exec(line); + if (!match) continue; + const payload = match[2].trim(); + if (payload.endsWith(",")) + return { ok: false, reason: "multi-line using/import not supported — one statement per line" }; + for (const item of payload.split(",")) { + const trimmed = item.trim(); + if (!trimmed) continue; + const root = trimmed.split(/[.:\s]/, 1)[0]; + if (root && !roots.includes(root)) roots.push(root); } } - return { ok: true, roots } + return { ok: true, roots }; } -export function checkImports( - roots: string[], - allow: { allowlist: string[]; support_set: string[] }, -): CheckResult { - const permitted = new Set([...allow.allowlist, ...allow.support_set, ...JULIA_STDLIBS]) - const blocked = roots.filter((root) => !permitted.has(root)) - if (blocked.length === 0) return { ok: true } +export function checkImports(roots: string[], allow: { allowlist: string[]; support_set: string[] }): CheckResult { + const permitted = new Set([...allow.allowlist, ...allow.support_set, ...JULIA_STDLIBS]); + const blocked = roots.filter((root) => !permitted.has(root)); + if (blocked.length === 0) return { ok: true }; return { ok: false, - reason: `${blocked.join(', ')}: not in the allowed package set (entitlement allowlist ∪ support set ∪ stdlibs)`, - } + reason: `${blocked.join(", ")}: not in the allowed package set (entitlement allowlist ∪ support set ∪ stdlibs)`, + }; } diff --git a/packages/amico-run/src/index.ts b/packages/amico-run/src/index.ts index f8af7d5f..dcd0ab44 100644 --- a/packages/amico-run/src/index.ts +++ b/packages/amico-run/src/index.ts @@ -1,6 +1,7 @@ -export * from './types.js' -export * from './telemetry.js' -export * from './run_dir.js' -export * from './schemas.js' -export * from './event_queue.js' -export * from './local_executor.js' +export * from "./types.js"; +export * from "./telemetry.js"; +export * from "./run_dir.js"; +export * from "./schemas.js"; +export * from "./event_queue.js"; +export * from "./local_executor.js"; +export * from "./scheduler.js"; diff --git a/packages/amico-run/src/local_executor.ts b/packages/amico-run/src/local_executor.ts index da7f84d4..39da67e3 100644 --- a/packages/amico-run/src/local_executor.ts +++ b/packages/amico-run/src/local_executor.ts @@ -1,139 +1,176 @@ -import { spawn } from 'node:child_process' -import { accessSync, constants as fsConstants, createWriteStream, existsSync, mkdirSync } from 'node:fs' -import { constants as osConstants } from 'node:os' -import { delimiter, join, resolve } from 'node:path' -import * as readline from 'node:readline' -import { EventQueue } from './event_queue.js' -import { classifyLine } from './telemetry.js' +import { spawn } from "node:child_process"; +import { accessSync, constants as fsConstants, createWriteStream, existsSync, mkdirSync } from "node:fs"; +import { constants as osConstants } from "node:os"; +import { delimiter, join, resolve } from "node:path"; +import * as readline from "node:readline"; +import { EventQueue } from "./event_queue.js"; +import { classifyLine } from "./telemetry.js"; import { - appendIndex, atomicWriteFile, defaultRunsRoot, deriveLabId, generateRunId, - updateLatest, writeFinished, writeManifest, -} from './run_dir.js' + appendIndex, + atomicWriteFile, + defaultRunsRoot, + deriveLabId, + generateRunId, + updateLatest, + writeFinished, + writeManifest, +} from "./run_dir.js"; import { - ConfigError, type Executor, type Finished, type RunEvent, type RunHandle, - type RunStatus, type SubmitOpts, -} from './types.js' + ConfigError, + type Executor, + type Finished, + type RunEvent, + type RunHandle, + type RunStatus, + type SubmitOpts, +} from "./types.js"; -import pkg from '../package.json' with { type: 'json' } -const ORCHESTRATOR_VERSION = pkg.version // single source of truth (esbuild inlines the JSON) +import pkg from "../package.json" with { type: "json" }; +const ORCHESTRATOR_VERSION = pkg.version; // single source of truth (esbuild inlines the JSON) function resolveExecutable(bin: string): void { - const candidates = bin.includes('/') + const candidates = bin.includes("/") ? [resolve(bin)] - : (process.env.PATH ?? '').split(delimiter).filter(Boolean).map(d => join(d, bin)) + : (process.env.PATH ?? "") + .split(delimiter) + .filter(Boolean) + .map((d) => join(d, bin)); for (const c of candidates) { - try { accessSync(c, fsConstants.X_OK); return } catch { /* keep looking */ } + try { + accessSync(c, fsConstants.X_OK); + return; + } catch { + /* keep looking */ + } } - throw new ConfigError(`julia binary not found or not executable: ${bin}`) + throw new ConfigError(`julia binary not found or not executable: ${bin}`); } function signalCode(signal: NodeJS.Signals | null): number { - const n = signal ? (osConstants.signals as Record)[signal] : undefined - return 128 + (n ?? 1) + const n = signal ? (osConstants.signals as Record)[signal] : undefined; + return 128 + (n ?? 1); } export class LocalExecutor implements Executor { async submit(scriptPath: string, opts: SubmitOpts = {}): Promise { // ---- step 1 (spec §5): validate config; failures here create NO run dir ---- - const script = resolve(scriptPath) - if (!existsSync(script)) throw new ConfigError(`script not found: ${script}`) - const juliaBin = opts.julia?.julia ?? 'julia' - resolveExecutable(juliaBin) - const lab = opts.lab ?? 'default' - const labId = deriveLabId(lab) - const runsRoot = opts.runsRoot ?? defaultRunsRoot(labId) - try { mkdirSync(runsRoot, { recursive: true }) } catch (e) { - throw new ConfigError(`runs root not writable: ${runsRoot} (${(e as Error).message})`) + const script = resolve(scriptPath); + if (!existsSync(script)) throw new ConfigError(`script not found: ${script}`); + const juliaBin = opts.julia?.julia ?? "julia"; + resolveExecutable(juliaBin); + const lab = opts.lab ?? "default"; + const labId = deriveLabId(lab); + const runsRoot = opts.runsRoot ?? defaultRunsRoot(labId); + try { + mkdirSync(runsRoot, { recursive: true }); + } catch (e) { + throw new ConfigError(`runs root not writable: ${runsRoot} (${(e as Error).message})`); } // ---- steps 2–5: run dir, manifest FIRST, index, latest ---- - const runId = generateRunId(runsRoot) - const runDir = join(runsRoot, runId) - mkdirSync(runDir) - const createdAt = new Date().toISOString() + const runId = generateRunId(runsRoot); + const runDir = join(runsRoot, runId); + mkdirSync(runDir); + const createdAt = new Date().toISOString(); writeManifest(runDir, { // spec C: --spec launches stamp tier + hashes and bump to v2; bare runs stay v1 - schema_version: opts.spec ? '2' : '1', run_id: runId, script_path: script, - lab, lab_id: labId, created_at: createdAt, + schema_version: opts.spec ? "2" : "1", + run_id: runId, + script_path: script, + lab, + lab_id: labId, + created_at: createdAt, orchestrator_version: ORCHESTRATOR_VERSION, julia: { binary: juliaBin, project: opts.julia?.project, sysimage: opts.julia?.sysimage }, - tier: opts.spec?.tier, hashes: opts.spec?.hashes, - }) - if (opts.spec) atomicWriteFile(runDir, 'solvespec.json', opts.spec.canonical + '\n') - appendIndex(runsRoot, runId, createdAt, script) - updateLatest(runsRoot, runId) + tier: opts.spec?.tier, + hashes: opts.spec?.hashes, + }); + if (opts.spec) atomicWriteFile(runDir, "solvespec.json", opts.spec.canonical + "\n"); + appendIndex(runsRoot, runId, createdAt, script); + updateLatest(runsRoot, runId); // ---- step 6: spawn julia, own process group, cwd = runDir ---- - const args: string[] = [] - if (opts.julia?.project) args.push(`--project=${opts.julia.project}`) - if (opts.julia?.sysimage) args.push(`--sysimage=${opts.julia.sysimage}`) - args.push(script) + const args: string[] = []; + if (opts.julia?.project) args.push(`--project=${opts.julia.project}`); + if (opts.julia?.sysimage) args.push(`--sysimage=${opts.julia.sysimage}`); + args.push(script); - const events = new EventQueue() - const logStream = createWriteStream(join(runDir, 'run.log'), { flags: 'a' }) - let resolveFinished!: (f: Finished) => void - const finished = new Promise(r => { resolveFinished = r }) + const events = new EventQueue(); + const logStream = createWriteStream(join(runDir, "run.log"), { flags: "a" }); + let resolveFinished!: (f: Finished) => void; + const finished = new Promise((r) => { + resolveFinished = r; + }); - let settled = false - let aborting = false + let settled = false; + let aborting = false; const settle = (status: RunStatus, exitCode: number): void => { - if (settled) return - settled = true + if (settled) return; + settled = true; try { - writeFinished(runDir, status, exitCode) // orchestrator verdict, atomic — overwrites - } catch (e) { // any FINISHED a script faked (spec §5 step 8) - process.stderr.write(`amico-run: failed to write FINISHED: ${(e as Error).message}\n`) + writeFinished(runDir, status, exitCode); // orchestrator verdict, atomic — overwrites + } catch (e) { + // any FINISHED a script faked (spec §5 step 8) + process.stderr.write(`amico-run: failed to write FINISHED: ${(e as Error).message}\n`); } - logStream.end() - events.push({ kind: 'finished', status, exitCode }) - events.close() - resolveFinished({ status, exitCode }) - } + logStream.end(); + events.push({ kind: "finished", status, exitCode }); + events.close(); + resolveFinished({ status, exitCode }); + }; // stdbuf (spec §5 "where available") is deliberately omitted in β.1: the β.3 script // convention prints with flush, and the fake-julia fixtures are node (line-flushed). // If live ITER streaming degrades on a real lab machine, β.6's dry-run catches it. const child = spawn(juliaBin, args, { - cwd: runDir, detached: true, stdio: ['ignore', 'pipe', 'pipe'], - }) + cwd: runDir, + detached: true, + stdio: ["ignore", "pipe", "pipe"], + }); // spawn failure AFTER manifest exists → FINISHED{failed, 127} (spec §6) - child.on('error', () => settle('failed', 127)) + child.on("error", () => settle("failed", 127)); // 'close', NOT 'exit': close waits for stdout/stderr to drain, so every line event // lands before settle() — the events stream must terminate ON the finished event (§3). - child.on('close', (code, signal) => { - const rc = code ?? signalCode(signal) - settle(aborting ? 'aborted' : rc === 0 ? 'completed' : 'failed', rc) - }) + child.on("close", (code, signal) => { + const rc = code ?? signalCode(signal); + settle(aborting ? "aborted" : rc === 0 ? "completed" : "failed", rc); + }); - const onLine = (stream: 'stdout' | 'stderr') => (line: string): void => { - if (settled) return // belt-and-braces; 'close' ordering makes this rare - logStream.write(line + '\n') - events.push(classifyLine(line, stream)) - } - readline.createInterface({ input: child.stdout! }).on('line', onLine('stdout')) - readline.createInterface({ input: child.stderr! }).on('line', onLine('stderr')) + const onLine = + (stream: "stdout" | "stderr") => + (line: string): void => { + if (settled) return; // belt-and-braces; 'close' ordering makes this rare + logStream.write(line + "\n"); + events.push(classifyLine(line, stream)); + }; + readline.createInterface({ input: child.stdout! }).on("line", onLine("stdout")); + readline.createInterface({ input: child.stderr! }).on("line", onLine("stderr")); - const graceMs = opts.graceMs ?? 5000 + const graceMs = opts.graceMs ?? 5000; const abort = async (): Promise => { - if (settled) return - aborting = true + if (settled) return; + aborting = true; const killGroup = (sig: NodeJS.Signals): void => { - try { process.kill(-child.pid!, sig) } catch { /* already gone */ } - } - killGroup('SIGTERM') - const killer = setTimeout(() => killGroup('SIGKILL'), graceMs) - killer.unref() - await finished - clearTimeout(killer) - } + try { + process.kill(-child.pid!, sig); + } catch { + /* already gone */ + } + }; + killGroup("SIGTERM"); + const killer = setTimeout(() => killGroup("SIGKILL"), graceMs); + killer.unref(); + await finished; + clearTimeout(killer); + }; - return { runId, runDir, events, finished, abort } + return { runId, runDir, events, finished, abort }; } } /** Spec §3: interface seam only — implementation is post-β. */ export class RemoteExecutor implements Executor { submit(): Promise { - return Promise.reject(new Error('RemoteExecutor: not implemented in β (D9 plan, Phase 2+)')) + return Promise.reject(new Error("RemoteExecutor: not implemented in β (D9 plan, Phase 2+)")); } } diff --git a/packages/amico-run/src/run_dir.ts b/packages/amico-run/src/run_dir.ts index e799e25b..5c5db6f1 100644 --- a/packages/amico-run/src/run_dir.ts +++ b/packages/amico-run/src/run_dir.ts @@ -1,62 +1,63 @@ -import { existsSync, writeFileSync, renameSync, appendFileSync, symlinkSync, rmSync } from 'node:fs' -import { randomBytes } from 'node:crypto' -import { homedir } from 'node:os' -import { join, dirname, basename, resolve } from 'node:path' -import { ConfigError, type RunStatus } from './types.js' +import { existsSync, writeFileSync, renameSync, appendFileSync, symlinkSync, rmSync } from "node:fs"; +import { randomBytes } from "node:crypto"; +import { homedir } from "node:os"; +import { join, dirname, basename, resolve } from "node:path"; +import { ConfigError, type RunStatus } from "./types.js"; -const ID_RE = /^[a-z0-9][a-z0-9_-]*$/ +const ID_RE = /^[a-z0-9][a-z0-9_-]*$/; /** Spec §3: id pointers verbatim; path pointers (contain "/" or end ".toml") * derive the id from the parent directory name of the lab.toml. */ export function deriveLabId(lab: string): string { - if (ID_RE.test(lab)) return lab - if (lab.includes('/') || lab.endsWith('.toml')) { - const id = basename(dirname(resolve(lab))) - if (ID_RE.test(id)) return id - throw new ConfigError(`cannot derive lab id from "${lab}": parent dir "${id}" is not a valid id`) + if (ID_RE.test(lab)) return lab; + if (lab.includes("/") || lab.endsWith(".toml")) { + const id = basename(dirname(resolve(lab))); + if (ID_RE.test(id)) return id; + throw new ConfigError(`cannot derive lab id from "${lab}": parent dir "${id}" is not a valid id`); } - throw new ConfigError(`invalid lab pointer "${lab}" (want [a-z0-9][a-z0-9_-]* or a lab.toml path)`) + throw new ConfigError(`invalid lab pointer "${lab}" (want [a-z0-9][a-z0-9_-]* or a lab.toml path)`); } export function defaultRunsRoot(labId: string): string { - return join(homedir(), '.amico', 'runs', labId) + return join(homedir(), ".amico", "runs", labId); } export function generateRunId(runsRoot: string, now = new Date()): string { - const p = (n: number, w = 2) => String(n).padStart(w, '0') - const ts = `${now.getUTCFullYear()}${p(now.getUTCMonth() + 1)}${p(now.getUTCDate())}` + - `-${p(now.getUTCHours())}${p(now.getUTCMinutes())}${p(now.getUTCSeconds())}Z` + const p = (n: number, w = 2) => String(n).padStart(w, "0"); + const ts = + `${now.getUTCFullYear()}${p(now.getUTCMonth() + 1)}${p(now.getUTCDate())}` + + `-${p(now.getUTCHours())}${p(now.getUTCMinutes())}${p(now.getUTCSeconds())}Z`; for (;;) { - const id = `r${ts}-${randomBytes(2).toString('hex')}` - if (!existsSync(join(runsRoot, id))) return id + const id = `r${ts}-${randomBytes(2).toString("hex")}`; + if (!existsSync(join(runsRoot, id))) return id; } } /** Write-temp-then-rename in the same dir: a watcher can never observe a partial file. */ export function atomicWriteFile(dir: string, name: string, content: string): void { - const tmp = join(dir, `.${name}.tmp-${process.pid}`) - writeFileSync(tmp, content) - renameSync(tmp, join(dir, name)) + const tmp = join(dir, `.${name}.tmp-${process.pid}`); + writeFileSync(tmp, content); + renameSync(tmp, join(dir, name)); } -const ts = (s: string) => JSON.stringify(s) // JSON escaping is valid TOML basic-string +const ts = (s: string) => JSON.stringify(s); // JSON escaping is valid TOML basic-string export interface Manifest { - schema_version: '1' | '2' - run_id: string - script_path: string - lab: string - lab_id: string - created_at: string - orchestrator_version: string - julia: { binary: string; project?: string; sysimage?: string } + schema_version: "1" | "2"; + run_id: string; + script_path: string; + lab: string; + lab_id: string; + created_at: string; + orchestrator_version: string; + julia: { binary: string; project?: string; sysimage?: string }; // v2 (spec C, --spec launches only) — bare runs stay byte-identical v1 - tier?: string - hashes?: Record + tier?: string; + hashes?: Record; } export function writeManifest(runDir: string, m: Manifest): void { - const hashEntries = Object.entries(m.hashes ?? {}) + const hashEntries = Object.entries(m.hashes ?? {}); const lines = [ `schema_version = ${ts(m.schema_version)}`, ...(m.tier ? [`tier = ${ts(m.tier)}`] : []), @@ -66,35 +67,33 @@ export function writeManifest(runDir: string, m: Manifest): void { `lab_id = ${ts(m.lab_id)}`, `created_at = ${ts(m.created_at)}`, `orchestrator_version = ${ts(m.orchestrator_version)}`, - '', - '[julia]', + "", + "[julia]", `binary = ${ts(m.julia.binary)}`, ...(m.julia.project ? [`project = ${ts(m.julia.project)}`] : []), ...(m.julia.sysimage ? [`sysimage = ${ts(m.julia.sysimage)}`] : []), - ...(hashEntries.length > 0 - ? ['', '[hashes]', ...hashEntries.map(([key, value]) => `${key} = ${ts(value)}`)] - : []), - ] - atomicWriteFile(runDir, 'run.toml', lines.join('\n') + '\n') + ...(hashEntries.length > 0 ? ["", "[hashes]", ...hashEntries.map(([key, value]) => `${key} = ${ts(value)}`)] : []), + ]; + atomicWriteFile(runDir, "run.toml", lines.join("\n") + "\n"); } export function writeFinished(runDir: string, status: RunStatus, exitCode: number): void { - atomicWriteFile(runDir, 'FINISHED', `status = ${ts(status)}\nexit_code = ${exitCode}\n`) + atomicWriteFile(runDir, "FINISHED", `status = ${ts(status)}\nexit_code = ${exitCode}\n`); } export function appendIndex(runsRoot: string, runId: string, createdAt: string, scriptPath: string): void { // The index is a tab-separated, one-line-per-run log; a tab/newline in the // (last-field) script path would corrupt it. Sanitize control chars to a // space — run.toml holds the canonical, TOML-escaped script_path. - const safePath = scriptPath.replace(/[\t\r\n]/g, ' ') - appendFileSync(join(runsRoot, 'index'), `${runId}\t${createdAt}\t${safePath}\n`) + const safePath = scriptPath.replace(/[\t\r\n]/g, " "); + appendFileSync(join(runsRoot, "index"), `${runId}\t${createdAt}\t${safePath}\n`); } export function updateLatest(runsRoot: string, runId: string): void { // Scope the temp name to runId so concurrent same-lab submits don't race on // a shared `.latest.tmp` (one would unlink the other's in-flight temp). - const tmp = join(runsRoot, `.latest.${runId}.tmp`) - rmSync(tmp, { force: true }) - symlinkSync(runId, tmp) - renameSync(tmp, join(runsRoot, 'latest')) + const tmp = join(runsRoot, `.latest.${runId}.tmp`); + rmSync(tmp, { force: true }); + symlinkSync(runId, tmp); + renameSync(tmp, join(runsRoot, "latest")); } diff --git a/packages/amico-run/src/scheduler.ts b/packages/amico-run/src/scheduler.ts new file mode 100644 index 00000000..82d434ff --- /dev/null +++ b/packages/amico-run/src/scheduler.ts @@ -0,0 +1,180 @@ +import { ConfigError, type Executor, type RunHandle, type RunStatus, type SubmitOpts } from "./types.js"; + +// ============================================================================ +// Scheduler (Phase 1.1, #56) — a serial run queue built TO the ratified +// Executor contract (Track C spec, locked 2026-07-02), so the cloud +// RemoteExecutor (Δ8/#32) drops in with zero reshape: +// +// - S12: downstream (RunsManager / Inspector / Catalog) sees ONLY the +// executor's RunHandle — enqueue() resolves to it untouched; nothing here +// branches on executor type. +// - (b) abort() is a REQUEST, not a kill: the queue advances ONLY when a +// run's `finished` resolves (a FINISHED — or executor-inferred terminal — +// landed). Post-abort() the run is still live; the Scheduler never treats +// abort as terminal. +// - (c) per-executor warming budget: the Scheduler owns NO timers. However +// long a run takes to warm/finish (remote cold-start ≫ local seconds) is +// between the executor and its handle; the queue just awaits `finished`. +// - (d) terminal resolution is the executor's job (`finished` never rejects +// per the contract); the Scheduler defensively survives a rogue rejection +// rather than wedging the queue. +// +// Serial by default; `{concurrent: true}` is the NAMED Phase-4 seam (§4.2) — +// rejected loudly today so nothing silently serializes when callers expect a +// parallel lane later. +// ============================================================================ + +/** What to run when this entry reaches the head of the queue. */ +export interface SubmitSpec { + scriptPath: string; + /** Passed to Executor.submit verbatim (lab pointer, runsRoot, julia opts…). */ + opts?: SubmitOpts; +} + +export interface EnqueueOpts { + /** Phase-4 seam (opt-in parallel lane) — NOT implemented; throws ConfigError. */ + concurrent?: boolean; +} + +/** Run lifecycle the RunsManager / StatusBar consume (1.2). `queueId` is the + * Scheduler's own id (assigned at enqueue, before any run exists); `runId` + * appears once the executor has admitted the run. */ +export type SchedulerEvent = + | { kind: "queued"; queueId: string; position: number } + | { kind: "started"; queueId: string; runId: string; runDir: string } + | { kind: "finished"; queueId: string; runId: string; status: RunStatus; exitCode: number } + | { kind: "cancelled"; queueId: string } + | { kind: "error"; queueId: string; message: string }; + +export interface ScheduledRun { + queueId: string; + /** Resolves with the executor's RunHandle when this entry reaches the head + * of the queue and submit() succeeds. Rejects if the entry is cancelled + * before starting, or if submit() throws (e.g. ConfigError). */ + handle: Promise; + /** Dequeue BEFORE start: true iff the entry was still queued (it will never + * run). False in every other case — already started, already cancelled, or + * mid-submit (shifted but `started` not yet emitted; `handle` may still + * REJECT if that submit fails). To stop a live run, `await handle` (in a + * try/catch) and call RunHandle.abort() — a request, per contract (b); + * never via the queue. */ + cancel(): boolean; +} + +interface Entry { + queueId: string; + spec: SubmitSpec; + resolve: (h: RunHandle) => void; + reject: (e: Error) => void; +} + +export class Scheduler { + private readonly queue: Entry[] = []; + private running = false; + private nextId = 1; + private readonly listeners = new Set<(e: SchedulerEvent) => void>(); + + constructor(private readonly executor: Executor) {} + + /** Subscribe to lifecycle events. Returns a dispose function. Multi-consumer + * (RunsManager + StatusBar); a throwing listener is isolated. */ + onEvent(listener: (e: SchedulerEvent) => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + /** Queued + running entries — 0 means an enqueue() would start immediately. */ + get depth(): number { + return this.queue.length + (this.running ? 1 : 0); + } + + enqueue(spec: SubmitSpec, opts: EnqueueOpts = {}): ScheduledRun { + if (opts.concurrent) { + throw new ConfigError("Scheduler: the parallel lane (concurrent: true) is deferred to Phase 4 — runs are serial"); + } + const queueId = `q${this.nextId++}`; + let resolve!: (h: RunHandle) => void; + let reject!: (e: Error) => void; + const handle = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + // The Scheduler itself observes failures (error event) — callers that only + // consume events must not trip an unhandled-rejection on the same promise. + handle.catch(() => {}); + const entry: Entry = { queueId, spec, resolve, reject }; + this.queue.push(entry); + this.emit({ kind: "queued", queueId, position: this.queue.length - 1 + (this.running ? 1 : 0) }); + void this.pump(); + return { + queueId, + handle, + cancel: (): boolean => { + const i = this.queue.indexOf(entry); + if (i === -1) return false; // already started (or done) — abort via the handle + this.queue.splice(i, 1); + this.emit({ kind: "cancelled", queueId }); + entry.reject(new Error(`Scheduler: ${queueId} cancelled before start`)); + return true; + }, + }; + } + + // -------- internal -------- + + private emit(e: SchedulerEvent): void { + for (const l of this.listeners) { + try { + l(e); + } catch { + /* a bad listener must not wedge the pump */ + } + } + } + + /** The serial pump: one entry at a time; advances ONLY on `finished` + * resolution (contract (b) — never on abort(), which is just a request). */ + private async pump(): Promise { + if (this.running) return; + const entry = this.queue.shift(); + if (!entry) return; + this.running = true; + try { + let handle: RunHandle; + try { + handle = await this.executor.submit(entry.spec.scriptPath, entry.spec.opts); + } catch (e) { + const err = e instanceof Error ? e : new Error(String(e)); + this.emit({ kind: "error", queueId: entry.queueId, message: err.message }); + entry.reject(err); + return; // finally advances the queue — a config failure must not wedge it + } + this.emit({ kind: "started", queueId: entry.queueId, runId: handle.runId, runDir: handle.runDir }); + entry.resolve(handle); + try { + const fin = await handle.finished; // contract: never rejects… + this.emit({ + kind: "finished", + queueId: entry.queueId, + runId: handle.runId, + status: fin.status, + exitCode: fin.exitCode, + }); + } catch (e) { + // …but a rogue executor breaking that must not deadlock every queued run. + const msg = e instanceof Error ? e.message : String(e); + this.emit({ kind: "error", queueId: entry.queueId, message: `finished rejected: ${msg}` }); + } + } finally { + this.running = false; + // Microtask deferral, NOT a direct call: a contract-violating executor + // whose submit() throws SYNCHRONOUSLY would otherwise make this finally + // direct recursion — a long backlog of such failures blows the stack and + // strands the rest of the queue. Deferring one microtask keeps the chain + // flat regardless of how the executor misbehaves. + queueMicrotask(() => void this.pump()); + } + } +} diff --git a/packages/amico-run/src/subcommands.ts b/packages/amico-run/src/subcommands.ts index b0cc66a0..c01b1263 100644 --- a/packages/amico-run/src/subcommands.ts +++ b/packages/amico-run/src/subcommands.ts @@ -4,65 +4,77 @@ // the Amicode workflow. Dispatch only fires when argv[0] is the literal // subcommand AND is not an existing file (a bare script named `resolve` keeps // the launch contract). -import { existsSync, mkdirSync, writeFileSync } from 'node:fs' -import { dirname, join, resolve } from 'node:path' -import { readAuthoring } from './authoring.js' -import { loadExemplarsIndex, loadRegistry, matchShape } from './catalog.js' -import { JULIA_STDLIBS } from './import_scan.js' +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { readAuthoring } from "./authoring.js"; +import { loadExemplarsIndex, loadRegistry, matchShape } from "./catalog.js"; +import { JULIA_STDLIBS } from "./import_scan.js"; /** Tier-3 minimum package set — the free skeleton's `using` block AND the * re-rollout harness both need these in the sandbox env, so `resolve` returns * them for tier free (an empty set would generate an uninstantiable env). */ -const TIER3_MIN_PACKAGES = ['Piccolo', 'CairoMakie', 'JLD2', 'TOML', 'Printf'] +const TIER3_MIN_PACKAGES = ["Piccolo", "CairoMakie", "JLD2", "TOML", "Printf"]; function flagValue(argv: string[], name: string): string | undefined { - const i = argv.indexOf(name) - return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined + const i = argv.indexOf(name); + return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined; } export function resolveCommand(argv: string[]): number { - const platform = flagValue(argv, '--platform') - const kind = flagValue(argv, '--kind') - const sizeRaw = flagValue(argv, '--size') + const platform = flagValue(argv, "--platform"); + const kind = flagValue(argv, "--kind"); + const sizeRaw = flagValue(argv, "--size"); if (!platform || !kind || !sizeRaw) { - console.error('amico-run resolve: --platform, --kind, --size are all required') - return 64 + console.error("amico-run resolve: --platform, --kind, --size are all required"); + return 64; + } + const size = Number(sizeRaw); + if (!Number.isFinite(size)) { + console.error(`amico-run resolve: --size must be a number (got ${sizeRaw})`); + return 64; } - const size = Number(sizeRaw) - if (!Number.isFinite(size)) { console.error(`amico-run resolve: --size must be a number (got ${sizeRaw})`); return 64 } - const { config } = readAuthoring() - const registry = loadRegistry(config.registry ?? '') - const exemplars = loadExemplarsIndex(config.exemplars ?? '') - const match = matchShape({ platform, kind, size }, registry, exemplars, config.allowlist) + const { config } = readAuthoring(); + const registry = loadRegistry(config.registry ?? ""); + const exemplars = loadExemplarsIndex(config.exemplars ?? ""); + const match = matchShape({ platform, kind, size }, registry, exemplars, config.allowlist); // template/exemplar paths in the catalog are relative to their manifest file; // resolve to absolute so the agent can copy the script directly. - const registryDir = config.registry ? dirname(config.registry) : process.cwd() - const exemplarsDir = config.exemplars ? dirname(config.exemplars) : process.cwd() - const out: Record = { tier: match.tier } + const registryDir = config.registry ? dirname(config.registry) : process.cwd(); + const exemplarsDir = config.exemplars ? dirname(config.exemplars) : process.cwd(); + const out: Record = { tier: match.tier }; if (match.template) { - out.source = { template_id: match.template.id } - out.template_path = resolve(registryDir, match.template.path) - out.packages = match.template.packages + out.source = { template_id: match.template.id }; + out.template_path = resolve(registryDir, match.template.path); + out.packages = match.template.packages; } else if (match.exemplar) { - out.source = { exemplar_id: match.exemplar.id } - out.exemplar_path = resolve(exemplarsDir, match.exemplar.path) - out.packages = match.exemplar.packages + out.source = { exemplar_id: match.exemplar.id }; + out.exemplar_path = resolve(exemplarsDir, match.exemplar.path); + out.packages = match.exemplar.packages; } else { - out.packages = TIER3_MIN_PACKAGES + out.packages = TIER3_MIN_PACKAGES; } - if (match.blockedHigher) out.blocked_higher = match.blockedHigher - console.log(JSON.stringify(out)) - return 0 + if (match.blockedHigher) out.blocked_higher = match.blockedHigher; + console.log(JSON.stringify(out)); + return 0; } export function sandboxCommand(argv: string[]): number { - const target = argv[0] - if (!target || target.startsWith('-')) { console.error('amico-run sandbox: required'); return 64 } - const packagesRaw = flagValue(argv, '--packages') - if (!packagesRaw) { console.error('amico-run sandbox: --packages A,B,… required'); return 64 } - const packages = packagesRaw.split(',').map((p) => p.trim()).filter(Boolean) + const target = argv[0]; + if (!target || target.startsWith("-")) { + console.error("amico-run sandbox: required"); + return 64; + } + const packagesRaw = flagValue(argv, "--packages"); + if (!packagesRaw) { + console.error("amico-run sandbox: --packages A,B,… required"); + return 64; + } + const packages = packagesRaw + .split(",") + .map((p) => p.trim()) + .filter(Boolean); // Julia stdlibs load from @stdlib in LOAD_PATH regardless of a project's // [deps] — they need no uuid and no [deps] entry. Filter them so the sandbox @@ -70,34 +82,34 @@ export function sandboxCommand(argv: string[]): number { // defect #2: TIER3_MIN_PACKAGES ships Printf+TOML, both stdlibs with no // [uuids] entry, which exit-64'd every tier-free launch at env generation). // Non-stdlib packages still require a uuid — the unknown-package guard holds. - const depsNeeded = packages.filter((p) => !JULIA_STDLIBS.has(p)) + const depsNeeded = packages.filter((p) => !JULIA_STDLIBS.has(p)); - const { config } = readAuthoring() - const registry = loadRegistry(config.registry ?? '') - const missing = depsNeeded.filter((p) => !registry.uuids[p]) + const { config } = readAuthoring(); + const registry = loadRegistry(config.registry ?? ""); + const missing = depsNeeded.filter((p) => !registry.uuids[p]); if (missing.length > 0) { - console.error(`amico-run sandbox: no uuid in the registry for: ${missing.join(', ')}`) - return 64 + console.error(`amico-run sandbox: no uuid in the registry for: ${missing.join(", ")}`); + return 64; } const deps = depsNeeded .slice() .sort() .map((p) => `${p} = ${JSON.stringify(registry.uuids[p])}`) - .join('\n') - const envDir = join(target, 'env') - mkdirSync(envDir, { recursive: true }) - writeFileSync(join(envDir, 'Project.toml'), `[deps]\n${deps}\n`) - console.log(`amico-run: wrote ${join(envDir, 'Project.toml')}`) - console.log(`instantiate it (private git deps need CLI git):`) - console.log(` JULIA_PKG_USE_CLI_GIT=true julia --project=${envDir} -e 'using Pkg; Pkg.instantiate()'`) - return 0 + .join("\n"); + const envDir = join(target, "env"); + mkdirSync(envDir, { recursive: true }); + writeFileSync(join(envDir, "Project.toml"), `[deps]\n${deps}\n`); + console.log(`amico-run: wrote ${join(envDir, "Project.toml")}`); + console.log(`instantiate it (private git deps need CLI git):`); + console.log(` JULIA_PKG_USE_CLI_GIT=true julia --project=${envDir} -e 'using Pkg; Pkg.instantiate()'`); + return 0; } /** Dispatch a subcommand if argv[0] names one and is not an existing file. */ export function trySubcommand(argv: string[]): number | undefined { - const head = argv[0] - if (head === 'resolve' && !existsSync(head)) return resolveCommand(argv.slice(1)) - if (head === 'sandbox' && !existsSync(head)) return sandboxCommand(argv.slice(1)) - return undefined + const head = argv[0]; + if (head === "resolve" && !existsSync(head)) return resolveCommand(argv.slice(1)); + if (head === "sandbox" && !existsSync(head)) return sandboxCommand(argv.slice(1)); + return undefined; } diff --git a/packages/amico-run/src/telemetry.ts b/packages/amico-run/src/telemetry.ts index b88159f9..a3f8d72a 100644 --- a/packages/amico-run/src/telemetry.ts +++ b/packages/amico-run/src/telemetry.ts @@ -1,14 +1,14 @@ -import type { RunEvent } from './types.js' +import type { RunEvent } from "./types.js"; -export function classifyLine(line: string, stream: 'stdout' | 'stderr'): RunEvent { - if (stream === 'stdout' && line.startsWith('AMICODE_ITER')) { - const fields: Record = {} - for (const tok of line.slice('AMICODE_ITER'.length).trim().split(/\s+/)) { - const eq = tok.indexOf('=') - if (eq > 0) fields[tok.slice(0, eq)] = tok.slice(eq + 1) +export function classifyLine(line: string, stream: "stdout" | "stderr"): RunEvent { + if (stream === "stdout" && line.startsWith("AMICODE_ITER")) { + const fields: Record = {}; + for (const tok of line.slice("AMICODE_ITER".length).trim().split(/\s+/)) { + const eq = tok.indexOf("="); + if (eq > 0) fields[tok.slice(0, eq)] = tok.slice(eq + 1); } - return { kind: 'iter', raw: line, fields } + return { kind: "iter", raw: line, fields }; } - if (stream === 'stdout' && /^DONE(\s|$)/.test(line)) return { kind: 'done', raw: line } - return { kind: 'log', stream, line } + if (stream === "stdout" && /^DONE(\s|$)/.test(line)) return { kind: "done", raw: line }; + return { kind: "log", stream, line }; } diff --git a/packages/amico-run/src/types.ts b/packages/amico-run/src/types.ts index 67625612..86550fa6 100644 --- a/packages/amico-run/src/types.ts +++ b/packages/amico-run/src/types.ts @@ -1,46 +1,49 @@ -export type RunStatus = 'completed' | 'failed' | 'aborted' +export type RunStatus = "completed" | "failed" | "aborted"; export interface JuliaOpts { - julia?: string // julia binary path; default "julia" from PATH - project?: string // --project= - sysimage?: string // --sysimage= + julia?: string; // julia binary path; default "julia" from PATH + project?: string; // --project= + sysimage?: string; // --sysimage= } export interface SubmitOpts { - lab?: string // lab POINTER (id or lab.toml path), passed through verbatim; default "default" - runsRoot?: string // default: ~/.amico/runs// - julia?: JuliaOpts - graceMs?: number // abort SIGTERM→SIGKILL grace; default 5000. Test knob, NOT exposed in the CLI. - spec?: SpecStamp // spec C: gate-passed SolveSpec → solvespec.json persisted + run.toml v2 stamped + lab?: string; // lab POINTER (id or lab.toml path), passed through verbatim; default "default" + runsRoot?: string; // default: ~/.amico/runs// + julia?: JuliaOpts; + graceMs?: number; // abort SIGTERM→SIGKILL grace; default 5000. Test knob, NOT exposed in the CLI. + spec?: SpecStamp; // spec C: gate-passed SolveSpec → solvespec.json persisted + run.toml v2 stamped } /** What a gate-passed --spec launch carries into the run dir (spec C). */ export interface SpecStamp { - canonical: string // stable-key-order solvespec.json body - tier?: string - hashes?: Record // incl. gate-computed spec_hash - julia_binary?: string // resolved julia bin — the free-tier verify harness runs under it - env_project?: string // resolved env project — --project for the harness + canonical: string; // stable-key-order solvespec.json body + tier?: string; + hashes?: Record; // incl. gate-computed spec_hash + julia_binary?: string; // resolved julia bin — the free-tier verify harness runs under it + env_project?: string; // resolved env project — --project for the harness } export type RunEvent = - | { kind: 'iter'; raw: string; fields: Record } - | { kind: 'done'; raw: string } - | { kind: 'log'; stream: 'stdout' | 'stderr'; line: string } - | { kind: 'finished'; status: RunStatus; exitCode: number } - -export interface Finished { status: RunStatus; exitCode: number } + | { kind: "iter"; raw: string; fields: Record } + | { kind: "done"; raw: string } + | { kind: "log"; stream: "stdout" | "stderr"; line: string } + | { kind: "finished"; status: RunStatus; exitCode: number }; + +export interface Finished { + status: RunStatus; + exitCode: number; +} export interface RunHandle { - runId: string - runDir: string - events: AsyncIterable // terminates after the 'finished' event - finished: Promise // never rejects - abort(): Promise // idempotent + runId: string; + runDir: string; + events: AsyncIterable; // terminates after the 'finished' event + finished: Promise; // never rejects + abort(): Promise; // idempotent } export interface Executor { - submit(scriptPath: string, opts?: SubmitOpts): Promise + submit(scriptPath: string, opts?: SubmitOpts): Promise; } /** Exit-64-class fault: bad config, nothing solver-related ran. */ diff --git a/packages/amico-run/src/verify.ts b/packages/amico-run/src/verify.ts index eb61fa51..e168bd83 100644 --- a/packages/amico-run/src/verify.ts +++ b/packages/amico-run/src/verify.ts @@ -6,14 +6,14 @@ // writing, we write a fallback verification.toml with agree=false + a reason — // a free run must NEVER end verification-less (absence would read as "pending" // forever and mask a failure, and the auto-promote gate keys off agree==true). -import { spawn } from 'node:child_process' -import { existsSync, renameSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' -import type { AuthoringConfig } from './authoring.js' -import type { SpecStamp } from './types.js' +import { spawn } from "node:child_process"; +import { existsSync, renameSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import type { AuthoringConfig } from "./authoring.js"; +import type { SpecStamp } from "./types.js"; function tomlEscape(s: string): string { - return JSON.stringify(s) + return JSON.stringify(s); } function writeFallback(runDir: string, reason: string, tolerance: number): void { @@ -24,34 +24,35 @@ function writeFallback(runDir: string, reason: string, tolerance: number): void `fidelity_reported = "nan"\n` + `tolerance = ${tolerance}\n` + `integrator = "none"\n` + - `error = ${tomlEscape(reason)}\n` - const tmp = join(runDir, `.verification.toml.tmp-${process.pid}`) - writeFileSync(tmp, body) - renameSync(tmp, join(runDir, 'verification.toml')) + `error = ${tomlEscape(reason)}\n`; + const tmp = join(runDir, `.verification.toml.tmp-${process.pid}`); + writeFileSync(tmp, body); + renameSync(tmp, join(runDir, "verification.toml")); } /** Run the harness; guarantee a verification.toml exists afterward. Never rejects. */ export async function runVerification(runDir: string, spec: SpecStamp, authoring: AuthoringConfig): Promise { - const tolerance = authoring.verify_tolerance - const harness = authoring.verify_harness + const tolerance = authoring.verify_tolerance; + const harness = authoring.verify_harness; if (!harness || !existsSync(harness)) { - writeFallback(runDir, `verification harness not found (${harness ?? 'unset'})`, tolerance) - return + writeFallback(runDir, `verification harness not found (${harness ?? "unset"})`, tolerance); + return; } // The harness interpreter is julia in production; AMICO_VERIFY_RUNNER overrides // it for tests (node fake-harness). The env's project comes from the spec. - const runner = process.env.AMICO_VERIFY_RUNNER ?? spec.julia_binary ?? 'julia' - const args = runner === 'julia' && spec.env_project - ? [`--project=${spec.env_project}`, harness, runDir, String(tolerance)] - : [harness, runDir, String(tolerance)] + const runner = process.env.AMICO_VERIFY_RUNNER ?? spec.julia_binary ?? "julia"; + const args = + runner === "julia" && spec.env_project + ? [`--project=${spec.env_project}`, harness, runDir, String(tolerance)] + : [harness, runDir, String(tolerance)]; const exitCode: number = await new Promise((resolvePromise) => { - const child = spawn(runner, args, { stdio: ['ignore', 'inherit', 'inherit'] }) - child.on('error', () => resolvePromise(127)) - child.on('close', (code) => resolvePromise(code ?? 1)) - }) + const child = spawn(runner, args, { stdio: ["ignore", "inherit", "inherit"] }); + child.on("error", () => resolvePromise(127)); + child.on("close", (code) => resolvePromise(code ?? 1)); + }); - if (!existsSync(join(runDir, 'verification.toml'))) { - writeFallback(runDir, `verification harness exited ${exitCode} without writing verification.toml`, tolerance) + if (!existsSync(join(runDir, "verification.toml"))) { + writeFallback(runDir, `verification harness exited ${exitCode} without writing verification.toml`, tolerance); } } diff --git a/packages/amico-run/test/abort.test.ts b/packages/amico-run/test/abort.test.ts index 8946c0b1..fefcd12b 100644 --- a/packages/amico-run/test/abort.test.ts +++ b/packages/amico-run/test/abort.test.ts @@ -1,44 +1,46 @@ -import { describe, it, expect } from 'vitest' -import { join } from 'node:path' -import { tmpRoot, fakeJulia, readToml } from './helpers.js' -import { LocalExecutor } from '../src/local_executor.js' +import { describe, it, expect } from "vitest"; +import { join } from "node:path"; +import { tmpRoot, fakeJulia, readToml } from "./helpers.js"; +import { LocalExecutor } from "../src/local_executor.js"; -const HANG = `setInterval(() => {}, 1000)` // dies on SIGTERM → 143 +const HANG = `setInterval(() => {}, 1000)`; // dies on SIGTERM → 143 // prints READY only after the SIGTERM handler is installed — the test must not abort // before then, or the signal hits node's default disposition during interpreter boot (→ 143) -const HANG_IGNORE = `process.on('SIGTERM', () => {}); console.log('READY'); setInterval(() => {}, 1000)` +const HANG_IGNORE = `process.on('SIGTERM', () => {}); console.log('READY'); setInterval(() => {}, 1000)`; -describe('abort lane (spec §3/§6)', () => { - it('abort() on a hanging run → FINISHED{aborted, 143} (SIGTERM)', async () => { - const root = tmpRoot() - const h = await new LocalExecutor().submit(fakeJulia(root, 's.jl', ''), { - runsRoot: join(root, 'runs'), julia: { julia: fakeJulia(root, 'j', HANG) }, - }) - await h.abort() - expect(await h.finished).toEqual({ status: 'aborted', exitCode: 143 }) - expect(readToml(join(h.runDir, 'FINISHED'))).toEqual({ status: 'aborted', exit_code: 143 }) - }) +describe("abort lane (spec §3/§6)", () => { + it("abort() on a hanging run → FINISHED{aborted, 143} (SIGTERM)", async () => { + const root = tmpRoot(); + const h = await new LocalExecutor().submit(fakeJulia(root, "s.jl", ""), { + runsRoot: join(root, "runs"), + julia: { julia: fakeJulia(root, "j", HANG) }, + }); + await h.abort(); + expect(await h.finished).toEqual({ status: "aborted", exitCode: 143 }); + expect(readToml(join(h.runDir, "FINISHED"))).toEqual({ status: "aborted", exit_code: 143 }); + }); - it('SIGTERM-ignoring script is SIGKILLed after grace → FINISHED{aborted, 137}', async () => { - const root = tmpRoot() - const h = await new LocalExecutor().submit(fakeJulia(root, 's.jl', ''), { - runsRoot: join(root, 'runs'), - julia: { julia: fakeJulia(root, 'j', HANG_IGNORE) }, - graceMs: 200, // test knob — spec default is 5000 - }) + it("SIGTERM-ignoring script is SIGKILLed after grace → FINISHED{aborted, 137}", async () => { + const root = tmpRoot(); + const h = await new LocalExecutor().submit(fakeJulia(root, "s.jl", ""), { + runsRoot: join(root, "runs"), + julia: { julia: fakeJulia(root, "j", HANG_IGNORE) }, + graceMs: 200, // test knob — spec default is 5000 + }); for await (const e of h.events) { - if (e.kind === 'log' && e.line === 'READY') void h.abort() // handler installed — now abort + if (e.kind === "log" && e.line === "READY") void h.abort(); // handler installed — now abort } - expect(await h.finished).toEqual({ status: 'aborted', exitCode: 137 }) - }, 15000) + expect(await h.finished).toEqual({ status: "aborted", exitCode: 137 }); + }, 15000); - it('abort() is idempotent and a no-op after completion', async () => { - const root = tmpRoot() - const h = await new LocalExecutor().submit(fakeJulia(root, 's.jl', ''), { - runsRoot: join(root, 'runs'), julia: { julia: fakeJulia(root, 'j', 'process.exit(0)') }, - }) - await h.finished - await expect(h.abort()).resolves.toBeUndefined() - expect(readToml(join(h.runDir, 'FINISHED')).status).toBe('completed') - }) -}) + it("abort() is idempotent and a no-op after completion", async () => { + const root = tmpRoot(); + const h = await new LocalExecutor().submit(fakeJulia(root, "s.jl", ""), { + runsRoot: join(root, "runs"), + julia: { julia: fakeJulia(root, "j", "process.exit(0)") }, + }); + await h.finished; + await expect(h.abort()).resolves.toBeUndefined(); + expect(readToml(join(h.runDir, "FINISHED")).status).toBe("completed"); + }); +}); diff --git a/packages/amico-run/test/authoring.test.ts b/packages/amico-run/test/authoring.test.ts index 27903517..9060e22e 100644 --- a/packages/amico-run/test/authoring.test.ts +++ b/packages/amico-run/test/authoring.test.ts @@ -1,20 +1,20 @@ -import { describe, it, expect, afterEach } from "vitest" -import { mkdtempSync, writeFileSync, rmSync } from "node:fs" -import { tmpdir } from "node:os" -import { join } from "node:path" -import { readAuthoring, DEFAULT_ALLOWLIST, DEFAULT_SUPPORT } from "../src/authoring.js" +import { describe, it, expect, afterEach } from "vitest"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { readAuthoring, DEFAULT_ALLOWLIST, DEFAULT_SUPPORT } from "../src/authoring.js"; -let dir: string | undefined +let dir: string | undefined; afterEach(() => { - delete process.env.AMICO_AUTHORING_FILE - if (dir) rmSync(dir, { recursive: true, force: true }) - dir = undefined -}) + delete process.env.AMICO_AUTHORING_FILE; + if (dir) rmSync(dir, { recursive: true, force: true }); + dir = undefined; +}); describe("readAuthoring", () => { it("reads the file named by $AMICO_AUTHORING_FILE, fields round-trip", () => { - dir = mkdtempSync(join(tmpdir(), "amico-authoring-")) - const file = join(dir, "authoring.json") + dir = mkdtempSync(join(tmpdir(), "amico-authoring-")); + const file = join(dir, "authoring.json"); writeFileSync( file, JSON.stringify({ @@ -26,36 +26,36 @@ describe("readAuthoring", () => { verify_harness: "/abs/verify_rollout.jl", verify_tolerance: 0.02, }), - ) - process.env.AMICO_AUTHORING_FILE = file - const { config, warning } = readAuthoring() - expect(warning).toBeUndefined() - expect(config.allowlist).toEqual(["Piccolo", "Piccolissimo"]) - expect(config.support_set).toEqual(["JLD2"]) - expect(config.registry).toBe("/abs/registry.toml") - expect(config.exemplars).toBe("/abs/index.json") - expect(config.verify_harness).toBe("/abs/verify_rollout.jl") - expect(config.verify_tolerance).toBe(0.02) - }) + ); + process.env.AMICO_AUTHORING_FILE = file; + const { config, warning } = readAuthoring(); + expect(warning).toBeUndefined(); + expect(config.allowlist).toEqual(["Piccolo", "Piccolissimo"]); + expect(config.support_set).toEqual(["JLD2"]); + expect(config.registry).toBe("/abs/registry.toml"); + expect(config.exemplars).toBe("/abs/index.json"); + expect(config.verify_harness).toBe("/abs/verify_rollout.jl"); + expect(config.verify_tolerance).toBe(0.02); + }); it("missing file → conservative built-in defaults, no warning", () => { - process.env.AMICO_AUTHORING_FILE = "/nonexistent/authoring.json" - const { config, warning } = readAuthoring() - expect(warning).toBeUndefined() - expect(config.allowlist).toEqual(DEFAULT_ALLOWLIST) - expect(config.allowlist).toEqual(["Piccolo", "Legato", "Intonato", "NamedTrajectories", "DirectTrajOpt"]) - expect(config.support_set).toEqual(DEFAULT_SUPPORT) - expect(config.support_set).toEqual(expect.arrayContaining(["JLD2", "CairoMakie", "TOML"])) - expect(config.verify_tolerance).toBe(0.001) // spec-20260704-113005 §6 (resolves spec-C open q1) - }) + process.env.AMICO_AUTHORING_FILE = "/nonexistent/authoring.json"; + const { config, warning } = readAuthoring(); + expect(warning).toBeUndefined(); + expect(config.allowlist).toEqual(DEFAULT_ALLOWLIST); + expect(config.allowlist).toEqual(["Piccolo", "Legato", "Intonato", "NamedTrajectories", "DirectTrajOpt"]); + expect(config.support_set).toEqual(DEFAULT_SUPPORT); + expect(config.support_set).toEqual(expect.arrayContaining(["JLD2", "CairoMakie", "TOML"])); + expect(config.verify_tolerance).toBe(0.001); // spec-20260704-113005 §6 (resolves spec-C open q1) + }); it("malformed JSON → defaults + a warning naming the file", () => { - dir = mkdtempSync(join(tmpdir(), "amico-authoring-")) - const file = join(dir, "authoring.json") - writeFileSync(file, "{nope") - process.env.AMICO_AUTHORING_FILE = file - const { config, warning } = readAuthoring() - expect(config.allowlist).toEqual(DEFAULT_ALLOWLIST) - expect(warning).toContain("authoring.json") - }) -}) + dir = mkdtempSync(join(tmpdir(), "amico-authoring-")); + const file = join(dir, "authoring.json"); + writeFileSync(file, "{nope"); + process.env.AMICO_AUTHORING_FILE = file; + const { config, warning } = readAuthoring(); + expect(config.allowlist).toEqual(DEFAULT_ALLOWLIST); + expect(warning).toContain("authoring.json"); + }); +}); diff --git a/packages/amico-run/test/baseline.test.ts b/packages/amico-run/test/baseline.test.ts index b49e6f8e..a0b06a87 100644 --- a/packages/amico-run/test/baseline.test.ts +++ b/packages/amico-run/test/baseline.test.ts @@ -1,36 +1,36 @@ -import { describe, it, expect } from "vitest" -import { maskFillPoints, maskedHash } from "../src/baseline.js" +import { describe, it, expect } from "vitest"; +import { maskFillPoints, maskedHash } from "../src/baseline.js"; -const SCRIPT = `using Piccolo\n# ── FILL IN ──────\nT = 10.0\nN = 50\n# ─────────────────\nsolve()\n` +const SCRIPT = `using Piccolo\n# ── FILL IN ──────\nT = 10.0\nN = 50\n# ─────────────────\nsolve()\n`; describe("maskedHash", () => { it("is edit-invariant inside fill points, sensitive outside", () => { - const edited = SCRIPT.replace("T = 10.0", "T = 25.0") - expect(maskedHash(SCRIPT)).toBe(maskedHash(edited)) - const physics = SCRIPT.replace("solve()", "solve!(hacked)") - expect(maskedHash(SCRIPT)).not.toBe(maskedHash(physics)) - }) + const edited = SCRIPT.replace("T = 10.0", "T = 25.0"); + expect(maskedHash(SCRIPT)).toBe(maskedHash(edited)); + const physics = SCRIPT.replace("solve()", "solve!(hacked)"); + expect(maskedHash(SCRIPT)).not.toBe(maskedHash(physics)); + }); it("custom markers override the defaults", () => { - const custom = `a\n# BEGIN-KNOBS\nx = 1\n# END-KNOBS\nb\n` - const edited = custom.replace("x = 1", "x = 999") + const custom = `a\n# BEGIN-KNOBS\nx = 1\n# END-KNOBS\nb\n`; + const edited = custom.replace("x = 1", "x = 999"); expect(maskedHash(custom, "^# BEGIN-KNOBS", "^# END-KNOBS")).toBe( maskedHash(edited, "^# BEGIN-KNOBS", "^# END-KNOBS"), - ) + ); // default markers don't match this file → edits are visible - expect(maskedHash(custom)).not.toBe(maskedHash(edited)) - }) + expect(maskedHash(custom)).not.toBe(maskedHash(edited)); + }); it("an unterminated block masks to EOF", () => { - const open = `head\n# ── FILL IN ──\nx = 1\ny = 2\n` - const edited = open.replace("y = 2", "y = 3") - expect(maskedHash(open)).toBe(maskedHash(edited)) + const open = `head\n# ── FILL IN ──\nx = 1\ny = 2\n`; + const edited = open.replace("y = 2", "y = 3"); + expect(maskedHash(open)).toBe(maskedHash(edited)); // but the head is still sensitive - expect(maskedHash(open)).not.toBe(maskedHash(open.replace("head", "HEAD"))) - }) + expect(maskedHash(open)).not.toBe(maskedHash(open.replace("head", "HEAD"))); + }); it("the masked text keeps the marker lines and replaces interior lines", () => { - const masked = maskFillPoints(SCRIPT) - expect(masked).toContain("# ── FILL IN") - expect(masked).toContain("# ─────") - expect(masked).not.toContain("T = 10.0") - expect(masked).toContain("#MASKED") - }) -}) + const masked = maskFillPoints(SCRIPT); + expect(masked).toContain("# ── FILL IN"); + expect(masked).toContain("# ─────"); + expect(masked).not.toContain("T = 10.0"); + expect(masked).toContain("#MASKED"); + }); +}); diff --git a/packages/amico-run/test/catalog.test.ts b/packages/amico-run/test/catalog.test.ts index 98ab8331..737022af 100644 --- a/packages/amico-run/test/catalog.test.ts +++ b/packages/amico-run/test/catalog.test.ts @@ -1,14 +1,14 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest" -import { mkdtempSync, writeFileSync, rmSync } from "node:fs" -import { tmpdir } from "node:os" -import { join } from "node:path" -import { loadRegistry, loadExemplarsIndex, matchShape } from "../src/catalog.js" +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadRegistry, loadExemplarsIndex, matchShape } from "../src/catalog.js"; -let dir: string +let dir: string; beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), "amico-catalog-")) -}) -afterEach(() => rmSync(dir, { recursive: true, force: true })) + dir = mkdtempSync(join(tmpdir(), "amico-catalog-")); +}); +afterEach(() => rmSync(dir, { recursive: true, force: true })); const REGISTRY = ` verify_tolerance = 0.01 @@ -47,7 +47,7 @@ packages = ["JLD2", "CairoMakie", "TOML", "Printf"] [uuids] Piccolo = "c4671d76-df94-11ed-2057-43d4fd632fad" JLD2 = "033835bb-8acc-5ee8-8aae-3f567f8a3819" -` +`; const INDEX = JSON.stringify({ schema_version: 1, @@ -62,68 +62,85 @@ const INDEX = JSON.stringify({ baseline_hash: "sha256:deadbeef", }, ], -}) +}); function seed() { - writeFileSync(join(dir, "registry.toml"), REGISTRY) - writeFileSync(join(dir, "index.json"), INDEX) + writeFileSync(join(dir, "registry.toml"), REGISTRY); + writeFileSync(join(dir, "index.json"), INDEX); return { registry: loadRegistry(join(dir, "registry.toml")), exemplars: loadExemplarsIndex(join(dir, "index.json")), - } + }; } -const PUBLIC_ALLOW = ["Piccolo", "Legato", "Intonato", "NamedTrajectories", "DirectTrajOpt"] +const PUBLIC_ALLOW = ["Piccolo", "Legato", "Intonato", "NamedTrajectories", "DirectTrajOpt"]; describe("loaders", () => { it("registry parses templates, support set, uuids, tolerance", () => { - const { registry } = seed() - expect(registry.templates).toHaveLength(3) - expect(registry.support).toEqual(["JLD2", "CairoMakie", "TOML", "Printf"]) - expect(registry.uuids.Piccolo).toBe("c4671d76-df94-11ed-2057-43d4fd632fad") - expect(registry.verifyTolerance).toBe(0.01) - }) + const { registry } = seed(); + expect(registry.templates).toHaveLength(3); + expect(registry.support).toEqual(["JLD2", "CairoMakie", "TOML", "Printf"]); + expect(registry.uuids.Piccolo).toBe("c4671d76-df94-11ed-2057-43d4fd632fad"); + expect(registry.verifyTolerance).toBe(0.01); + }); it("missing files → empty catalog, never throws", () => { - expect(loadRegistry(join(dir, "nope.toml")).templates).toEqual([]) - expect(loadExemplarsIndex(join(dir, "nope.json")).exemplars).toEqual([]) - }) -}) + expect(loadRegistry(join(dir, "nope.toml")).templates).toEqual([]); + expect(loadExemplarsIndex(join(dir, "nope.json")).exemplars).toEqual([]); + }); +}); describe("matchShape", () => { it("exact vetted template match → tier 1", () => { - const { registry, exemplars } = seed() - const match = matchShape({ platform: "transmon", kind: "gate_synthesis", size: 1 }, registry, exemplars, PUBLIC_ALLOW) - expect(match.tier).toBe("vetted") - expect(match.template?.id).toBe("transmon-gate-1q") - }) + const { registry, exemplars } = seed(); + const match = matchShape( + { platform: "transmon", kind: "gate_synthesis", size: 1 }, + registry, + exemplars, + PUBLIC_ALLOW, + ); + expect(match.tier).toBe("vetted"); + expect(match.template?.id).toBe("transmon-gate-1q"); + }); it("experimental templates are NEVER tier 1 — falls through to the exemplar", () => { - const { registry, exemplars } = seed() - const match = matchShape({ platform: "rydberg", kind: "gate_synthesis", size: 2 }, registry, exemplars, PUBLIC_ALLOW) - expect(match.tier).toBe("composed") - expect(match.exemplar?.id).toBe("rydberg-cz") - }) + const { registry, exemplars } = seed(); + const match = matchShape( + { platform: "rydberg", kind: "gate_synthesis", size: 2 }, + registry, + exemplars, + PUBLIC_ALLOW, + ); + expect(match.tier).toBe("composed"); + expect(match.exemplar?.id).toBe("rydberg-cz"); + }); it("no template and no exemplar → tier 3 (free)", () => { - const { registry, exemplars } = seed() - expect(matchShape({ platform: "ions", kind: "gate_synthesis", size: 1 }, registry, exemplars, PUBLIC_ALLOW).tier).toBe("free") - }) + const { registry, exemplars } = seed(); + expect( + matchShape({ platform: "ions", kind: "gate_synthesis", size: 1 }, registry, exemplars, PUBLIC_ALLOW).tier, + ).toBe("free"); + }); it("entitlement-blocked vetted match is excluded AND reported as blocked_higher", () => { - const { registry, exemplars } = seed() - const match = matchShape({ platform: "transmon", kind: "state_prep", size: 1 }, registry, exemplars, PUBLIC_ALLOW) - expect(match.tier).toBe("free") - expect(match.blockedHigher).toEqual({ tier: "vetted", requires: "issimo" }) + const { registry, exemplars } = seed(); + const match = matchShape({ platform: "transmon", kind: "state_prep", size: 1 }, registry, exemplars, PUBLIC_ALLOW); + expect(match.tier).toBe("free"); + expect(match.blockedHigher).toEqual({ tier: "vetted", requires: "issimo" }); // with the issimo packages allowed, the same shape resolves tier 1 - const withIssimo = matchShape( - { platform: "transmon", kind: "state_prep", size: 1 }, + const withIssimo = matchShape({ platform: "transmon", kind: "state_prep", size: 1 }, registry, exemplars, [ + ...PUBLIC_ALLOW, + "Piccolissimo", + "Strettissimo", + "Intonatissimo", + ]); + expect(withIssimo.tier).toBe("vetted"); + expect(withIssimo.template?.id).toBe("issimo-special-1q"); + }); + it("exemplar match on platform+kind tolerates a size mismatch (near match)", () => { + const { registry, exemplars } = seed(); + const match = matchShape( + { platform: "rydberg", kind: "gate_synthesis", size: 3 }, registry, exemplars, - [...PUBLIC_ALLOW, "Piccolissimo", "Strettissimo", "Intonatissimo"], - ) - expect(withIssimo.tier).toBe("vetted") - expect(withIssimo.template?.id).toBe("issimo-special-1q") - }) - it("exemplar match on platform+kind tolerates a size mismatch (near match)", () => { - const { registry, exemplars } = seed() - const match = matchShape({ platform: "rydberg", kind: "gate_synthesis", size: 3 }, registry, exemplars, PUBLIC_ALLOW) - expect(match.tier).toBe("composed") - }) -}) + PUBLIC_ALLOW, + ); + expect(match.tier).toBe("composed"); + }); +}); diff --git a/packages/amico-run/test/cli.test.ts b/packages/amico-run/test/cli.test.ts index bf4368db..b814297f 100644 --- a/packages/amico-run/test/cli.test.ts +++ b/packages/amico-run/test/cli.test.ts @@ -1,150 +1,204 @@ -import { describe, it, expect, beforeAll } from 'vitest' -import { execFileSync, execFile } from 'node:child_process' -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' -import { tmpRoot, fakeJulia, readToml } from './helpers.js' +import { describe, it, expect, beforeAll } from "vitest"; +import { execFileSync, execFile } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpRoot, fakeJulia, readToml } from "./helpers.js"; -const BUNDLE = join(__dirname, '..', 'dist', 'amico-run.js') +const BUNDLE = join(__dirname, "..", "dist", "amico-run.js"); beforeAll(() => { - execFileSync('node', [join(__dirname, '..', 'esbuild.config.mjs')], { cwd: join(__dirname, '..') }) -}) + execFileSync("node", [join(__dirname, "..", "esbuild.config.mjs")], { cwd: join(__dirname, "..") }); +}); function run(args: string[], env: Record = {}): { code: number; stdout: string; stderr: string } { try { - const stdout = execFileSync('node', [BUNDLE, ...args], { encoding: 'utf8', env: { ...process.env, ...env } }) - return { code: 0, stdout, stderr: '' } + const stdout = execFileSync("node", [BUNDLE, ...args], { encoding: "utf8", env: { ...process.env, ...env } }); + return { code: 0, stdout, stderr: "" }; } catch (e) { - const err = e as { status?: number; stdout?: string; stderr?: string } - return { code: err.status ?? -1, stdout: err.stdout ?? '', stderr: err.stderr ?? '' } + const err = e as { status?: number; stdout?: string; stderr?: string }; + return { code: err.status ?? -1, stdout: err.stdout ?? "", stderr: err.stderr ?? "" }; } } -describe('amico-run CLI', () => { - it('clean solve: relays iter lines, prints AMICODE_FINISHED, exits 0', () => { - const root = tmpRoot() - const julia = fakeJulia(root, 'j', `console.log('AMICODE_ITER iter=1 f=0.5'); console.log('DONE f=0.99')`) - const script = fakeJulia(root, 's.jl', '') - const r = run([script, '--runs-root', join(root, 'runs'), '--julia', julia]) - expect(r.code).toBe(0) - expect(r.stdout).toContain('AMICODE_ITER iter=1 f=0.5') - expect(r.stdout).toMatch(/AMICODE_FINISHED status=completed exitCode=0 runDir=.+/) - }) - it('julia rc 7 passes through as exit 7', () => { - const root = tmpRoot() - const r = run([fakeJulia(root, 's.jl', ''), '--runs-root', join(root, 'runs'), - '--julia', fakeJulia(root, 'j', 'process.exit(7)')]) - expect(r.code).toBe(7) - expect(r.stdout).toContain('status=failed exitCode=7') - }) - it('missing script → 64, stderr one-liner, no run dir', () => { - const root = tmpRoot() - const r = run([join(root, 'nope.jl'), '--runs-root', join(root, 'runs')]) - expect(r.code).toBe(64) - expect(r.stderr).toMatch(/script not found/) - }) - it('unknown flag → 64 (never silently swallowed, spec Q68)', () => { - const root = tmpRoot() - const r = run([fakeJulia(root, 's.jl', ''), '--gates', 'X']) - expect(r.code).toBe(64) - expect(r.stderr).toMatch(/unknown flag/) - }) - it('--executor remote → 64 (only local in β)', () => { - const root = tmpRoot() - const r = run([fakeJulia(root, 's.jl', ''), '--executor', 'remote']) - expect(r.code).toBe(64) - }) - it('--spec: gate failure → 64, one-line stderr reason, NO run dir (spec C)', () => { - const root = tmpRoot() - const script = fakeJulia(root, 's.jl', '') - writeFileSync(join(root, 'bad.json'), JSON.stringify({ nope: true })) - const r = run([script, '--runs-root', join(root, 'runs'), '--spec', join(root, 'bad.json'), - '--julia', fakeJulia(root, 'j', '')]) - expect(r.code).toBe(64) - expect(r.stderr).toMatch(/solvespec schema/) - expect(existsSync(join(root, 'runs'))).toBe(false) - }) - it('--spec pass: solvespec.json persisted canonical + run.toml v2 stamped (spec C)', () => { - const root = tmpRoot() - const script = fakeJulia(root, 's.jl', '') +describe("amico-run CLI", () => { + it("clean solve: relays iter lines, prints AMICODE_FINISHED, exits 0", () => { + const root = tmpRoot(); + const julia = fakeJulia(root, "j", `console.log('AMICODE_ITER iter=1 f=0.5'); console.log('DONE f=0.99')`); + const script = fakeJulia(root, "s.jl", ""); + const r = run([script, "--runs-root", join(root, "runs"), "--julia", julia]); + expect(r.code).toBe(0); + expect(r.stdout).toContain("AMICODE_ITER iter=1 f=0.5"); + expect(r.stdout).toMatch(/AMICODE_FINISHED status=completed exitCode=0 runDir=.+/); + }); + it("julia rc 7 passes through as exit 7", () => { + const root = tmpRoot(); + const r = run([ + fakeJulia(root, "s.jl", ""), + "--runs-root", + join(root, "runs"), + "--julia", + fakeJulia(root, "j", "process.exit(7)"), + ]); + expect(r.code).toBe(7); + expect(r.stdout).toContain("status=failed exitCode=7"); + }); + it("missing script → 64, stderr one-liner, no run dir", () => { + const root = tmpRoot(); + const r = run([join(root, "nope.jl"), "--runs-root", join(root, "runs")]); + expect(r.code).toBe(64); + expect(r.stderr).toMatch(/script not found/); + }); + it("unknown flag → 64 (never silently swallowed, spec Q68)", () => { + const root = tmpRoot(); + const r = run([fakeJulia(root, "s.jl", ""), "--gates", "X"]); + expect(r.code).toBe(64); + expect(r.stderr).toMatch(/unknown flag/); + }); + it("--executor remote → 64 (only local in β)", () => { + const root = tmpRoot(); + const r = run([fakeJulia(root, "s.jl", ""), "--executor", "remote"]); + expect(r.code).toBe(64); + }); + it("--spec: gate failure → 64, one-line stderr reason, NO run dir (spec C)", () => { + const root = tmpRoot(); + const script = fakeJulia(root, "s.jl", ""); + writeFileSync(join(root, "bad.json"), JSON.stringify({ nope: true })); + const r = run([ + script, + "--runs-root", + join(root, "runs"), + "--spec", + join(root, "bad.json"), + "--julia", + fakeJulia(root, "j", ""), + ]); + expect(r.code).toBe(64); + expect(r.stderr).toMatch(/solvespec schema/); + expect(existsSync(join(root, "runs"))).toBe(false); + }); + it("--spec pass: solvespec.json persisted canonical + run.toml v2 stamped (spec C)", () => { + const root = tmpRoot(); + const script = fakeJulia(root, "s.jl", ""); const spec = { - schema_version: '2', script_path: script, lab_id: 'default', - executor: 'local', tier: 'vetted', - hashes: { system_hash: 'sha256:ab' }, - } - writeFileSync(join(root, 'spec.json'), JSON.stringify(spec)) - const r = run([script, '--runs-root', join(root, 'runs'), '--spec', join(root, 'spec.json'), - '--julia', fakeJulia(root, 'j', `console.log('DONE f=0.99')`)]) - expect(r.code).toBe(0) - const match = /runDir=(\S+)/.exec(r.stdout) - expect(match).toBeTruthy() - const runDir = match![1] - const persisted = JSON.parse(readFileSync(join(runDir, 'solvespec.json'), 'utf8')) - expect(persisted).toMatchObject({ tier: 'vetted', lab_id: 'default' }) - const manifest = readToml(join(runDir, 'run.toml')) - expect(manifest.schema_version).toBe('2') - expect(manifest.tier).toBe('vetted') - expect((manifest.hashes as Record).system_hash).toBe('sha256:ab') - expect((manifest.hashes as Record).spec_hash).toMatch(/^sha256:/) - }) - it('--spec env.kind=project sets the julia --project arg from env.project (spec C)', () => { - const root = tmpRoot() - const script = fakeJulia(root, 's.jl', '') - const env = join(root, 'env') - mkdirSync(env, { recursive: true }) - writeFileSync(join(env, 'Project.toml'), `[deps]\n`) - writeFileSync(join(env, 'Manifest.toml'), `julia_version = "1.11.0"\n`) + schema_version: "2", + script_path: script, + lab_id: "default", + executor: "local", + tier: "vetted", + hashes: { system_hash: "sha256:ab" }, + }; + writeFileSync(join(root, "spec.json"), JSON.stringify(spec)); + const r = run([ + script, + "--runs-root", + join(root, "runs"), + "--spec", + join(root, "spec.json"), + "--julia", + fakeJulia(root, "j", `console.log('DONE f=0.99')`), + ]); + expect(r.code).toBe(0); + const match = /runDir=(\S+)/.exec(r.stdout); + expect(match).toBeTruthy(); + const runDir = match![1]; + const persisted = JSON.parse(readFileSync(join(runDir, "solvespec.json"), "utf8")); + expect(persisted).toMatchObject({ tier: "vetted", lab_id: "default" }); + const manifest = readToml(join(runDir, "run.toml")); + expect(manifest.schema_version).toBe("2"); + expect(manifest.tier).toBe("vetted"); + expect((manifest.hashes as Record).system_hash).toBe("sha256:ab"); + expect((manifest.hashes as Record).spec_hash).toMatch(/^sha256:/); + }); + it("--spec env.kind=project sets the julia --project arg from env.project (spec C)", () => { + const root = tmpRoot(); + const script = fakeJulia(root, "s.jl", ""); + const env = join(root, "env"); + mkdirSync(env, { recursive: true }); + writeFileSync(join(env, "Project.toml"), `[deps]\n`); + writeFileSync(join(env, "Manifest.toml"), `julia_version = "1.11.0"\n`); const spec = { - schema_version: '2', script_path: script, lab_id: 'default', - tier: 'vetted', env: { kind: 'project', project: env }, - } - writeFileSync(join(root, 'spec.json'), JSON.stringify(spec)) - const julia = fakeJulia(root, 'j', `console.log('ARGS ' + process.argv.slice(2).join(' '))`) - const r = run([script, '--runs-root', join(root, 'runs'), '--spec', join(root, 'spec.json'), '--julia', julia]) - expect(r.code).toBe(0) - expect(r.stdout).toContain(`--project=${env}`) - }) - it('--spec tier=free: verification runs after FINISHED (AMICODE_VERIFIED + verification.toml); vetted: neither (spec C)', () => { - const root = tmpRoot() - const script = fakeJulia(root, 's.jl', '') - const env = join(root, 'env') - mkdirSync(env, { recursive: true }) - writeFileSync(join(env, 'Project.toml'), `[deps]\n`) - writeFileSync(join(env, 'Manifest.toml'), `julia_version = "1.11.0"\n`) + schema_version: "2", + script_path: script, + lab_id: "default", + tier: "vetted", + env: { kind: "project", project: env }, + }; + writeFileSync(join(root, "spec.json"), JSON.stringify(spec)); + const julia = fakeJulia(root, "j", `console.log('ARGS ' + process.argv.slice(2).join(' '))`); + const r = run([script, "--runs-root", join(root, "runs"), "--spec", join(root, "spec.json"), "--julia", julia]); + expect(r.code).toBe(0); + expect(r.stdout).toContain(`--project=${env}`); + }); + it("--spec tier=free: verification runs after FINISHED (AMICODE_VERIFIED + verification.toml); vetted: neither (spec C)", () => { + const root = tmpRoot(); + const script = fakeJulia(root, "s.jl", ""); + const env = join(root, "env"); + mkdirSync(env, { recursive: true }); + writeFileSync(join(env, "Project.toml"), `[deps]\n`); + writeFileSync(join(env, "Manifest.toml"), `julia_version = "1.11.0"\n`); // fake harness (node) that writes agree=true; wired as the julia binary so // runVerification spawns it (AMICO_VERIFY_RUNNER unset → spec.julia_binary) - const harness = fakeJulia(root, 'h.js', - `const fs=require('fs'),p=require('path');fs.writeFileSync(p.join(process.argv[process.argv.length-2],'verification.toml'),'schema_version = "1"\\nagree = true\\n')`) - writeFileSync(join(root, 'authoring.json'), JSON.stringify({ - schema_version: 1, allowlist: ['Piccolo'], support_set: ['JLD2', 'TOML'], - verify_harness: harness, verify_tolerance: 0.01, - })) - const julia = fakeJulia(root, 'j', `console.log('DONE f=0.99')`) - const AUTH = { AMICO_AUTHORING_FILE: join(root, 'authoring.json'), AMICO_VERIFY_RUNNER: harness } + const harness = fakeJulia( + root, + "h.js", + `const fs=require('fs'),p=require('path');fs.writeFileSync(p.join(process.argv[process.argv.length-2],'verification.toml'),'schema_version = "1"\\nagree = true\\n')`, + ); + writeFileSync( + join(root, "authoring.json"), + JSON.stringify({ + schema_version: 1, + allowlist: ["Piccolo"], + support_set: ["JLD2", "TOML"], + verify_harness: harness, + verify_tolerance: 0.01, + }), + ); + const julia = fakeJulia(root, "j", `console.log('DONE f=0.99')`); + const AUTH = { AMICO_AUTHORING_FILE: join(root, "authoring.json"), AMICO_VERIFY_RUNNER: harness }; - const freeSpec = { schema_version: '2', script_path: script, lab_id: 'default', tier: 'free', env: { kind: 'sandbox', project: env } } - writeFileSync(join(root, 'free.json'), JSON.stringify(freeSpec)) - const rFree = run([script, '--runs-root', join(root, 'runs'), '--spec', join(root, 'free.json'), '--julia', julia], AUTH) - expect(rFree.code).toBe(0) - expect(rFree.stdout).toMatch(/AMICODE_VERIFIED agree=true/) - const freeDir = /runDir=(\S+)/.exec(rFree.stdout)![1] - expect(existsSync(join(freeDir, 'verification.toml'))).toBe(true) + const freeSpec = { + schema_version: "2", + script_path: script, + lab_id: "default", + tier: "free", + env: { kind: "sandbox", project: env }, + }; + writeFileSync(join(root, "free.json"), JSON.stringify(freeSpec)); + const rFree = run( + [script, "--runs-root", join(root, "runs"), "--spec", join(root, "free.json"), "--julia", julia], + AUTH, + ); + expect(rFree.code).toBe(0); + expect(rFree.stdout).toMatch(/AMICODE_VERIFIED agree=true/); + const freeDir = /runDir=(\S+)/.exec(rFree.stdout)![1]; + expect(existsSync(join(freeDir, "verification.toml"))).toBe(true); - const vetSpec = { schema_version: '2', script_path: script, lab_id: 'default', tier: 'vetted', env: { kind: 'provisioned' } } - writeFileSync(join(root, 'vet.json'), JSON.stringify(vetSpec)) - const rVet = run([script, '--runs-root', join(root, 'runs2'), '--spec', join(root, 'vet.json'), '--julia', julia], AUTH) - expect(rVet.stdout).not.toMatch(/AMICODE_VERIFIED/) - const vetDir = /runDir=(\S+)/.exec(rVet.stdout)![1] - expect(existsSync(join(vetDir, 'verification.toml'))).toBe(false) - }) - it('SIGTERM to the CLI → abort lane, exit 130', async () => { - const root = tmpRoot() - const julia = fakeJulia(root, 'j', `console.log('READY'); setInterval(() => {}, 1000)`) - const script = fakeJulia(root, 's.jl', '') - const code: number = await new Promise(resolveP => { - const child = execFile('node', [BUNDLE, script, '--runs-root', join(root, 'runs'), '--julia', julia]) - child.stdout!.on('data', (d: string) => { if (d.includes('READY')) child.kill('SIGTERM') }) - child.on('exit', c => resolveP(c ?? -1)) - }) - expect(code).toBe(130) - }, 15000) -}) + const vetSpec = { + schema_version: "2", + script_path: script, + lab_id: "default", + tier: "vetted", + env: { kind: "provisioned" }, + }; + writeFileSync(join(root, "vet.json"), JSON.stringify(vetSpec)); + const rVet = run( + [script, "--runs-root", join(root, "runs2"), "--spec", join(root, "vet.json"), "--julia", julia], + AUTH, + ); + expect(rVet.stdout).not.toMatch(/AMICODE_VERIFIED/); + const vetDir = /runDir=(\S+)/.exec(rVet.stdout)![1]; + expect(existsSync(join(vetDir, "verification.toml"))).toBe(false); + }); + it("SIGTERM to the CLI → abort lane, exit 130", async () => { + const root = tmpRoot(); + const julia = fakeJulia(root, "j", `console.log('READY'); setInterval(() => {}, 1000)`); + const script = fakeJulia(root, "s.jl", ""); + const code: number = await new Promise((resolveP) => { + const child = execFile("node", [BUNDLE, script, "--runs-root", join(root, "runs"), "--julia", julia]); + child.stdout!.on("data", (d: string) => { + if (d.includes("READY")) child.kill("SIGTERM"); + }); + child.on("exit", (c) => resolveP(c ?? -1)); + }); + expect(code).toBe(130); + }, 15000); +}); diff --git a/packages/amico-run/test/failure_lanes.test.ts b/packages/amico-run/test/failure_lanes.test.ts index c6622bd1..b5819530 100644 --- a/packages/amico-run/test/failure_lanes.test.ts +++ b/packages/amico-run/test/failure_lanes.test.ts @@ -1,128 +1,143 @@ -import { describe, it, expect } from 'vitest' -import { chmodSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' -import { tmpRoot, fakeJulia, readToml } from './helpers.js' -import { LocalExecutor } from '../src/local_executor.js' +import { describe, it, expect } from "vitest"; +import { chmodSync, mkdirSync, readdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpRoot, fakeJulia, readToml } from "./helpers.js"; +import { LocalExecutor } from "../src/local_executor.js"; const sub = (root: string, julia: string, script: string) => - new LocalExecutor().submit(script, { runsRoot: join(root, 'runs'), julia: { julia } }) + new LocalExecutor().submit(script, { runsRoot: join(root, "runs"), julia: { julia } }); -describe('§6 failure matrix', () => { - it('nonzero exit → FINISHED{failed, rc}', async () => { - const root = tmpRoot() - const h = await sub(root, fakeJulia(root, 'j', 'process.exit(3)'), fakeJulia(root, 's.jl', '')) - expect(await h.finished).toEqual({ status: 'failed', exitCode: 3 }) - expect(readToml(join(h.runDir, 'FINISHED'))).toEqual({ status: 'failed', exit_code: 3 }) - }) +describe("§6 failure matrix", () => { + it("nonzero exit → FINISHED{failed, rc}", async () => { + const root = tmpRoot(); + const h = await sub(root, fakeJulia(root, "j", "process.exit(3)"), fakeJulia(root, "s.jl", "")); + expect(await h.finished).toEqual({ status: "failed", exitCode: 3 }); + expect(readToml(join(h.runDir, "FINISHED"))).toEqual({ status: "failed", exit_code: 3 }); + }); - it('crash before any output → FINISHED{failed}, manifest still valid', async () => { - const root = tmpRoot() - const h = await sub(root, fakeJulia(root, 'j', 'throw new Error("boom")'), fakeJulia(root, 's.jl', '')) - const f = await h.finished - expect(f.status).toBe('failed') - expect(readToml(join(h.runDir, 'run.toml')).run_id).toBe(h.runId) - }) + it("crash before any output → FINISHED{failed}, manifest still valid", async () => { + const root = tmpRoot(); + const h = await sub(root, fakeJulia(root, "j", 'throw new Error("boom")'), fakeJulia(root, "s.jl", "")); + const f = await h.finished; + expect(f.status).toBe("failed"); + expect(readToml(join(h.runDir, "run.toml")).run_id).toBe(h.runId); + }); - it('spawn failure after manifest (X_OK dir → spawn error) → FINISHED{failed, 127}', async () => { - const root = tmpRoot() + it("spawn failure after manifest (X_OK dir → spawn error) → FINISHED{failed, 127}", async () => { + const root = tmpRoot(); // a directory passes step-1 X_OK validation, but spawn() itself errors → child.on('error') - const dirAsJulia = join(root, 'julia-dir') - mkdirSync(dirAsJulia, { mode: 0o755 }) - const h = await sub(root, dirAsJulia, fakeJulia(root, 's.jl', '')) - expect(await h.finished).toEqual({ status: 'failed', exitCode: 127 }) - expect(readToml(join(h.runDir, 'run.toml')).run_id).toBe(h.runId) // manifest survived - }) + const dirAsJulia = join(root, "julia-dir"); + mkdirSync(dirAsJulia, { mode: 0o755 }); + const h = await sub(root, dirAsJulia, fakeJulia(root, "s.jl", "")); + expect(await h.finished).toEqual({ status: "failed", exitCode: 127 }); + expect(readToml(join(h.runDir, "run.toml")).run_id).toBe(h.runId); // manifest survived + }); - it('shell exec-failure rc passes through verbatim (wrapper execs missing target)', async () => { - const root = tmpRoot() - const wrapper = join(root, 'julia-wrapper') - writeFileSync(wrapper, '#!/usr/bin/env bash\nexec /nonexistent/amico-test-julia "$@"\n') - chmodSync(wrapper, 0o755) - const h = await sub(root, wrapper, fakeJulia(root, 's.jl', '')) - const f = await h.finished - expect(f.status).toBe('failed') - expect([126, 127]).toContain(f.exitCode) // bash version dependent; both are julia-rc passthrough - }) + it("shell exec-failure rc passes through verbatim (wrapper execs missing target)", async () => { + const root = tmpRoot(); + const wrapper = join(root, "julia-wrapper"); + writeFileSync(wrapper, '#!/usr/bin/env bash\nexec /nonexistent/amico-test-julia "$@"\n'); + chmodSync(wrapper, 0o755); + const h = await sub(root, wrapper, fakeJulia(root, "s.jl", "")); + const f = await h.finished; + expect(f.status).toBe("failed"); + expect([126, 127]).toContain(f.exitCode); // bash version dependent; both are julia-rc passthrough + }); - it('crash mid-stream: iter events delivered, then FINISHED{failed}', async () => { - const root = tmpRoot() - const julia = fakeJulia(root, 'j', ` + it("crash mid-stream: iter events delivered, then FINISHED{failed}", async () => { + const root = tmpRoot(); + const julia = fakeJulia( + root, + "j", + ` console.log('AMICODE_ITER iter=1 f=0.5') console.log('AMICODE_ITER iter=2 f=0.1') - process.exit(3)`) - const h = await sub(root, julia, fakeJulia(root, 's.jl', '')) - const evs: string[] = [] - for await (const e of h.events) evs.push(e.kind) - expect(evs.filter(k => k === 'iter')).toHaveLength(2) - expect(evs.at(-1)).toBe('finished') - expect((await h.finished).exitCode).toBe(3) - }) + process.exit(3)`, + ); + const h = await sub(root, julia, fakeJulia(root, "s.jl", "")); + const evs: string[] = []; + for await (const e of h.events) evs.push(e.kind); + expect(evs.filter((k) => k === "iter")).toHaveLength(2); + expect(evs.at(-1)).toBe("finished"); + expect((await h.finished).exitCode).toBe(3); + }); - it('julia killed by an EXTERNAL signal (not abort) → FINISHED{failed, 128+sig}', async () => { - const root = tmpRoot() + it("julia killed by an EXTERNAL signal (not abort) → FINISHED{failed, 128+sig}", async () => { + const root = tmpRoot(); // self-inflicted SIGTERM stands in for an external kill: abort() was never called, // so status must be failed (143), not aborted - const julia = fakeJulia(root, 'j', `process.kill(process.pid, 'SIGTERM')`) - const h = await sub(root, julia, fakeJulia(root, 's.jl', '')) - expect(await h.finished).toEqual({ status: 'failed', exitCode: 143 }) - }) + const julia = fakeJulia(root, "j", `process.kill(process.pid, 'SIGTERM')`); + const h = await sub(root, julia, fakeJulia(root, "s.jl", "")); + expect(await h.finished).toEqual({ status: "failed", exitCode: 143 }); + }); - it('manifest is on disk BEFORE julia spawns (script observes it in cwd at startup)', async () => { - const root = tmpRoot() - const julia = fakeJulia(root, 'j', - `process.exit(require('node:fs').existsSync('run.toml') ? 0 : 7)`) - const h = await sub(root, julia, fakeJulia(root, 's.jl', '')) - expect(await h.finished).toEqual({ status: 'completed', exitCode: 0 }) - }) + it("manifest is on disk BEFORE julia spawns (script observes it in cwd at startup)", async () => { + const root = tmpRoot(); + const julia = fakeJulia(root, "j", `process.exit(require('node:fs').existsSync('run.toml') ? 0 : 7)`); + const h = await sub(root, julia, fakeJulia(root, "s.jl", "")); + expect(await h.finished).toEqual({ status: "completed", exitCode: 0 }); + }); - it('garbage / binary stdout never crashes the parser; classified as log', async () => { - const root = tmpRoot() - const h = await sub(root, - fakeJulia(root, 'j', `process.stdout.write(Buffer.from([0xff, 0xfe, 0x0a])); console.log('ok')`), - fakeJulia(root, 's.jl', '')) - expect((await h.finished).status).toBe('completed') - }) + it("garbage / binary stdout never crashes the parser; classified as log", async () => { + const root = tmpRoot(); + const h = await sub( + root, + fakeJulia(root, "j", `process.stdout.write(Buffer.from([0xff, 0xfe, 0x0a])); console.log('ok')`), + fakeJulia(root, "s.jl", ""), + ); + expect((await h.finished).status).toBe("completed"); + }); - it('script that writes nothing at all still yields manifest + FINISHED', async () => { - const root = tmpRoot() - const h = await sub(root, fakeJulia(root, 'j', ''), fakeJulia(root, 's.jl', '')) - await h.finished - expect(readToml(join(h.runDir, 'FINISHED')).status).toBe('completed') - }) + it("script that writes nothing at all still yields manifest + FINISHED", async () => { + const root = tmpRoot(); + const h = await sub(root, fakeJulia(root, "j", ""), fakeJulia(root, "s.jl", "")); + await h.finished; + expect(readToml(join(h.runDir, "FINISHED")).status).toBe("completed"); + }); it("script's own bogus FINISHED is overwritten by the orchestrator verdict", async () => { - const root = tmpRoot() - const julia = fakeJulia(root, 'j', ` + const root = tmpRoot(); + const julia = fakeJulia( + root, + "j", + ` require('node:fs').writeFileSync('FINISHED', 'status = "completed"\\nexit_code = 0\\n') - process.exit(9)`) - const h = await sub(root, julia, fakeJulia(root, 's.jl', '')) - await h.finished - expect(readToml(join(h.runDir, 'FINISHED'))).toEqual({ status: 'failed', exit_code: 9 }) - }) + process.exit(9)`, + ); + const h = await sub(root, julia, fakeJulia(root, "s.jl", "")); + await h.finished; + expect(readToml(join(h.runDir, "FINISHED"))).toEqual({ status: "failed", exit_code: 9 }); + }); - it('exactly one finished event; events iterator terminates', async () => { - const root = tmpRoot() - const h = await sub(root, fakeJulia(root, 'j', 'process.exit(0)'), fakeJulia(root, 's.jl', '')) - let n = 0 - for await (const e of h.events) if (e.kind === 'finished') n++ - expect(n).toBe(1) - }) + it("exactly one finished event; events iterator terminates", async () => { + const root = tmpRoot(); + const h = await sub(root, fakeJulia(root, "j", "process.exit(0)"), fakeJulia(root, "s.jl", "")); + let n = 0; + for await (const e of h.events) if (e.kind === "finished") n++; + expect(n).toBe(1); + }); - it('no partial orchestrator file is ever observable (tight-loop reader, spec §8)', async () => { - const root = tmpRoot() - const julia = fakeJulia(root, 'j', ` + it("no partial orchestrator file is ever observable (tight-loop reader, spec §8)", async () => { + const root = tmpRoot(); + const julia = fakeJulia( + root, + "j", + ` let i = 0 const t = setInterval(() => { console.log('AMICODE_ITER iter=' + ++i + ' f=0.1') - if (i >= 20) { clearInterval(t) } }, 10)`) - const h = await sub(root, julia, fakeJulia(root, 's.jl', '')) - let sawTmp = false - let done = false - void h.finished.then(() => { done = true }) + if (i >= 20) { clearInterval(t) } }, 10)`, + ); + const h = await sub(root, julia, fakeJulia(root, "s.jl", "")); + let sawTmp = false; + let done = false; + void h.finished.then(() => { + done = true; + }); while (!done) { - if (readdirSync(h.runDir).some(f => f.includes('.tmp-'))) sawTmp = true - await new Promise(r => setTimeout(r, 2)) + if (readdirSync(h.runDir).some((f) => f.includes(".tmp-"))) sawTmp = true; + await new Promise((r) => setTimeout(r, 2)); } - expect(sawTmp).toBe(false) - expect(readToml(join(h.runDir, 'FINISHED')).status).toBe('completed') - }) -}) + expect(sawTmp).toBe(false); + expect(readToml(join(h.runDir, "FINISHED")).status).toBe("completed"); + }); +}); diff --git a/packages/amico-run/test/gate.test.ts b/packages/amico-run/test/gate.test.ts index 27476e39..f73a3741 100644 --- a/packages/amico-run/test/gate.test.ts +++ b/packages/amico-run/test/gate.test.ts @@ -1,22 +1,22 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest" -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs" -import { tmpdir } from "node:os" -import { join } from "node:path" -import { runGate } from "../src/gate.js" -import { maskedHash } from "../src/baseline.js" -import type { AuthoringConfig } from "../src/authoring.js" +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runGate } from "../src/gate.js"; +import { maskedHash } from "../src/baseline.js"; +import type { AuthoringConfig } from "../src/authoring.js"; -let dir: string +let dir: string; beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), "amico-gate-")) -}) -afterEach(() => rmSync(dir, { recursive: true, force: true })) + dir = mkdtempSync(join(tmpdir(), "amico-gate-")); +}); +afterEach(() => rmSync(dir, { recursive: true, force: true })); -const EXEMPLAR_SCRIPT = `using Piccolo\nusing JLD2, TOML\n# ── FILL IN ──────\nT = 10.0\n# ─────────────────\nsolve()\n` +const EXEMPLAR_SCRIPT = `using Piccolo\nusing JLD2, TOML\n# ── FILL IN ──────\nT = 10.0\n# ─────────────────\nsolve()\n`; function authoring(overrides?: Partial): AuthoringConfig { // exemplars index on disk with the fixture exemplar's build-time baseline - const index = join(dir, "index.json") + const index = join(dir, "index.json"); writeFileSync( index, JSON.stringify({ @@ -33,14 +33,14 @@ function authoring(overrides?: Partial): AuthoringConfig { }, ], }), - ) + ); return { allowlist: ["Piccolo", "Legato"], support_set: ["JLD2", "CairoMakie", "TOML", "Printf"], exemplars: index, verify_tolerance: 0.01, ...overrides, - } + }; } function spec(overrides: Record = {}): Record { @@ -52,89 +52,89 @@ function spec(overrides: Record = {}): Record tier: "vetted", env: { kind: "provisioned" }, ...overrides, - } + }; } describe("runGate", () => { it("step 1: schema-invalid spec → one-line schema reason", () => { - const result = runGate({ nope: true }, "using Piccolo\n", authoring()) - expect(result.ok).toBe(false) - if (!result.ok) expect(result.reason).toMatch(/schema/) - }) + const result = runGate({ nope: true }, "using Piccolo\n", authoring()); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toMatch(/schema/); + }); it("step 2: blocked import → reason names the package", () => { - const result = runGate(spec(), "using Piccolo\nusing Zygote\n", authoring()) - expect(result.ok).toBe(false) - if (!result.ok) expect(result.reason).toMatch(/Zygote/) - }) + const result = runGate(spec(), "using Piccolo\nusing Zygote\n", authoring()); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toMatch(/Zygote/); + }); it("step 3: free tier requires a sandbox env", () => { - const result = runGate(spec({ tier: "free", env: { kind: "provisioned" } }), "using Piccolo\n", authoring()) - expect(result.ok).toBe(false) - if (!result.ok) expect(result.reason).toMatch(/free tier requires a sandbox env/) - }) + const result = runGate(spec({ tier: "free", env: { kind: "provisioned" } }), "using Piccolo\n", authoring()); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toMatch(/free tier requires a sandbox env/); + }); it("step 3: project env without a Manifest.toml → instantiate message", () => { - const env = join(dir, "env") - mkdirSync(env) - writeFileSync(join(env, "Project.toml"), `[deps]\nPiccolo = "c4671d76-df94-11ed-2057-43d4fd632fad"\n`) - const result = runGate(spec({ env: { kind: "project", project: env } }), "using Piccolo\n", authoring()) - expect(result.ok).toBe(false) - if (!result.ok) expect(result.reason).toMatch(/instantiate/) - }) + const env = join(dir, "env"); + mkdirSync(env); + writeFileSync(join(env, "Project.toml"), `[deps]\nPiccolo = "c4671d76-df94-11ed-2057-43d4fd632fad"\n`); + const result = runGate(spec({ env: { kind: "project", project: env } }), "using Piccolo\n", authoring()); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toMatch(/instantiate/); + }); it("step 3b: stale env — Project dep missing from its OWN Manifest → named + re-instantiate", () => { - const env = join(dir, "env") - mkdirSync(env) + const env = join(dir, "env"); + mkdirSync(env); writeFileSync( join(env, "Project.toml"), `[deps]\nPiccolo = "c4671d76-df94-11ed-2057-43d4fd632fad"\nJLD2 = "033835bb-8acc-5ee8-8aae-3f567f8a3819"\n`, - ) - writeFileSync(join(env, "Manifest.toml"), `julia_version = "1.11.0"\n\n[[deps.Piccolo]]\nversion = "1.19.0"\n`) - const result = runGate(spec({ env: { kind: "project", project: env } }), "using Piccolo\n", authoring()) - expect(result.ok).toBe(false) - if (!result.ok) expect(result.reason).toMatch(/stale env.*JLD2.*re-instantiate/) + ); + writeFileSync(join(env, "Manifest.toml"), `julia_version = "1.11.0"\n\n[[deps.Piccolo]]\nversion = "1.19.0"\n`); + const result = runGate(spec({ env: { kind: "project", project: env } }), "using Piccolo\n", authoring()); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toMatch(/stale env.*JLD2.*re-instantiate/); // consistent pair passes writeFileSync( join(env, "Manifest.toml"), `julia_version = "1.11.0"\n\n[[deps.Piccolo]]\nversion = "1.19.0"\n\n[[deps.JLD2]]\nversion = "0.5.0"\n`, - ) - expect(runGate(spec({ env: { kind: "project", project: env } }), "using Piccolo\n", authoring()).ok).toBe(true) - }) + ); + expect(runGate(spec({ env: { kind: "project", project: env } }), "using Piccolo\n", authoring()).ok).toBe(true); + }); it("step 3: non-local executor rejected at schema level", () => { - const result = runGate(spec({ executor: "cloud" }), "using Piccolo\n", authoring()) - expect(result.ok).toBe(false) - if (!result.ok) expect(result.reason).toMatch(/executor/) - }) + const result = runGate(spec({ executor: "cloud" }), "using Piccolo\n", authoring()); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toMatch(/executor/); + }); it("step 4: composed — inside-fill-point edits pass; outside edits reject with demote_to", () => { - const sandboxSpec = spec({ tier: "composed", source: { exemplar_id: "ex-1" } }) - const filled = EXEMPLAR_SCRIPT.replace("T = 10.0", "T = 25.0") - expect(runGate(sandboxSpec, filled, authoring()).ok).toBe(true) - const hacked = EXEMPLAR_SCRIPT.replace("solve()", "solve!(other_physics)") - const result = runGate(sandboxSpec, hacked, authoring()) - expect(result.ok).toBe(false) + const sandboxSpec = spec({ tier: "composed", source: { exemplar_id: "ex-1" } }); + const filled = EXEMPLAR_SCRIPT.replace("T = 10.0", "T = 25.0"); + expect(runGate(sandboxSpec, filled, authoring()).ok).toBe(true); + const hacked = EXEMPLAR_SCRIPT.replace("solve()", "solve!(other_physics)"); + const result = runGate(sandboxSpec, hacked, authoring()); + expect(result.ok).toBe(false); if (!result.ok) { - expect(result.reason).toMatch(/no longer the exemplar/) - expect(result.demote_to).toBe("free") + expect(result.reason).toMatch(/no longer the exemplar/); + expect(result.demote_to).toBe("free"); } - }) + }); it("step 4: composed without exemplar_id → clear reason", () => { - const result = runGate(spec({ tier: "composed" }), EXEMPLAR_SCRIPT, authoring()) - expect(result.ok).toBe(false) - if (!result.ok) expect(result.reason).toMatch(/exemplar_id/) - }) + const result = runGate(spec({ tier: "composed" }), EXEMPLAR_SCRIPT, authoring()); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toMatch(/exemplar_id/); + }); it("step 5: pass returns the stamp; spec_hash is gate-computed and spec-sensitive", () => { - const specA = spec({ hashes: { system_hash: "sha256:ab" } }) - const resultA = runGate(specA, "using Piccolo\n", authoring()) - expect(resultA.ok).toBe(true) + const specA = spec({ hashes: { system_hash: "sha256:ab" } }); + const resultA = runGate(specA, "using Piccolo\n", authoring()); + expect(resultA.ok).toBe(true); if (resultA.ok) { - expect(resultA.stamp.tier).toBe("vetted") - expect(resultA.stamp.hashes.system_hash).toBe("sha256:ab") - expect(resultA.stamp.hashes.spec_hash).toMatch(/^sha256:/) - expect(JSON.parse(resultA.stamp.specCanonical)).toMatchObject({ tier: "vetted" }) - const resultB = runGate(spec({ hashes: { system_hash: "sha256:cd" } }), "using Piccolo\n", authoring()) - if (resultB.ok) expect(resultB.stamp.hashes.spec_hash).not.toBe(resultA.stamp.hashes.spec_hash) + expect(resultA.stamp.tier).toBe("vetted"); + expect(resultA.stamp.hashes.system_hash).toBe("sha256:ab"); + expect(resultA.stamp.hashes.spec_hash).toMatch(/^sha256:/); + expect(JSON.parse(resultA.stamp.specCanonical)).toMatchObject({ tier: "vetted" }); + const resultB = runGate(spec({ hashes: { system_hash: "sha256:cd" } }), "using Piccolo\n", authoring()); + if (resultB.ok) expect(resultB.stamp.hashes.spec_hash).not.toBe(resultA.stamp.hashes.spec_hash); } - }) + }); it("v1 specs (no tier) pass through with import scan only", () => { - const v1 = { schema_version: "1", script_path: "/s.jl", lab_id: "default" } - expect(runGate(v1, "using Piccolo\n", authoring()).ok).toBe(true) - expect(runGate(v1, "using Zygote\n", authoring()).ok).toBe(false) - }) -}) + const v1 = { schema_version: "1", script_path: "/s.jl", lab_id: "default" }; + expect(runGate(v1, "using Piccolo\n", authoring()).ok).toBe(true); + expect(runGate(v1, "using Zygote\n", authoring()).ok).toBe(false); + }); +}); diff --git a/packages/amico-run/test/helpers.ts b/packages/amico-run/test/helpers.ts index ff491074..26595876 100644 --- a/packages/amico-run/test/helpers.ts +++ b/packages/amico-run/test/helpers.ts @@ -1,21 +1,21 @@ -import { mkdtempSync, readFileSync, writeFileSync, chmodSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { parse } from 'smol-toml' +import { mkdtempSync, readFileSync, writeFileSync, chmodSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parse } from "smol-toml"; export function tmpRoot(): string { - return mkdtempSync(join(tmpdir(), 'amico-run-test-')) + return mkdtempSync(join(tmpdir(), "amico-run-test-")); } export function readToml(path: string): Record { - return parse(readFileSync(path, 'utf8')) as Record + return parse(readFileSync(path, "utf8")) as Record; } /** Create an executable fake-julia "binary" (node script via shebang). It receives * the julia argv (flags + script path) and ignores it unless the body uses it. */ export function fakeJulia(dir: string, name: string, body: string): string { - const p = join(dir, name) - writeFileSync(p, `#!/usr/bin/env node\n${body}\n`) - chmodSync(p, 0o755) - return p + const p = join(dir, name); + writeFileSync(p, `#!/usr/bin/env node\n${body}\n`); + chmodSync(p, 0o755); + return p; } diff --git a/packages/amico-run/test/import_scan.test.ts b/packages/amico-run/test/import_scan.test.ts index cbd472d3..4bbba736 100644 --- a/packages/amico-run/test/import_scan.test.ts +++ b/packages/amico-run/test/import_scan.test.ts @@ -1,7 +1,7 @@ -import { describe, it, expect } from "vitest" -import { scanImports, checkImports } from "../src/import_scan.js" +import { describe, it, expect } from "vitest"; +import { scanImports, checkImports } from "../src/import_scan.js"; -const ALLOW = { allowlist: ["Piccolo", "Legato"], support_set: ["JLD2", "CairoMakie", "TOML", "Printf"] } +const ALLOW = { allowlist: ["Piccolo", "Legato"], support_set: ["JLD2", "CairoMakie", "TOML", "Printf"] }; describe("scanImports", () => { it("extracts roots from every using/import form", () => { @@ -9,29 +9,29 @@ describe("scanImports", () => { scanImports( `using Piccolo\nusing JLD2, TOML\nimport LinearAlgebra as LA\nusing Piccolo.NamedTrajectories\nusing CairoMakie: heatmap\n# using Zygote (comment)`, ), - ).toEqual({ ok: true, roots: ["Piccolo", "JLD2", "TOML", "LinearAlgebra", "CairoMakie"] }) - }) + ).toEqual({ ok: true, roots: ["Piccolo", "JLD2", "TOML", "LinearAlgebra", "CairoMakie"] }); + }); it("fails CLOSED on a trailing-comma continuation line (multi-line using)", () => { - const scanned = scanImports(`using Piccolo,\n Zygote\n`) - expect(scanned.ok).toBe(false) - if (!scanned.ok) expect(scanned.reason).toMatch(/one statement per line/) - }) -}) + const scanned = scanImports(`using Piccolo,\n Zygote\n`); + expect(scanned.ok).toBe(false); + if (!scanned.ok) expect(scanned.reason).toMatch(/one statement per line/); + }); +}); describe("checkImports", () => { it("allows allowlist ∪ support ∪ stdlib", () => { - expect(checkImports(["Piccolo", "JLD2", "LinearAlgebra", "Printf"], ALLOW)).toEqual({ ok: true }) - }) + expect(checkImports(["Piccolo", "JLD2", "LinearAlgebra", "Printf"], ALLOW)).toEqual({ ok: true }); + }); it("blocks others with a one-line reason naming every blocked package", () => { - const bad = checkImports(["Piccolo", "Zygote", "Flux"], ALLOW) - expect(bad.ok).toBe(false) + const bad = checkImports(["Piccolo", "Zygote", "Flux"], ALLOW); + expect(bad.ok).toBe(false); if (!bad.ok) { - expect(bad.reason).toMatch(/Zygote/) - expect(bad.reason).toMatch(/Flux/) - expect(bad.reason).toMatch(/not in the allowed package set/) + expect(bad.reason).toMatch(/Zygote/); + expect(bad.reason).toMatch(/Flux/); + expect(bad.reason).toMatch(/not in the allowed package set/); } - }) + }); it("issimo package blocked without entitlement", () => { - expect(checkImports(["Piccolissimo"], ALLOW).ok).toBe(false) - }) -}) + expect(checkImports(["Piccolissimo"], ALLOW).ok).toBe(false); + }); +}); diff --git a/packages/amico-run/test/local_executor.test.ts b/packages/amico-run/test/local_executor.test.ts index 371e9027..393b0cd1 100644 --- a/packages/amico-run/test/local_executor.test.ts +++ b/packages/amico-run/test/local_executor.test.ts @@ -1,75 +1,89 @@ -import { describe, it, expect } from 'vitest' -import { existsSync, readFileSync, readdirSync } from 'node:fs' -import { join } from 'node:path' -import { tmpRoot, fakeJulia, readToml } from './helpers.js' -import { LocalExecutor } from '../src/local_executor.js' -import { validateManifest, validateFinished } from '../src/schemas.js' -import type { RunEvent } from '../src/types.js' +import { describe, it, expect } from "vitest"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { tmpRoot, fakeJulia, readToml } from "./helpers.js"; +import { LocalExecutor } from "../src/local_executor.js"; +import { validateManifest, validateFinished } from "../src/schemas.js"; +import type { RunEvent } from "../src/types.js"; const CLEAN = ` console.log('AMICODE_ITER iter=1 f=1.0e-2') console.log('AMICODE_ITER iter=2 f=3.0e-4') console.log('DONE fidelity=0.9999') -` +`; async function collect(events: AsyncIterable): Promise { - const out: RunEvent[] = [] - for await (const e of events) out.push(e) - return out + const out: RunEvent[] = []; + for await (const e of events) out.push(e); + return out; } -describe('LocalExecutor happy path', () => { - it('produces a conforming run dir and ordered event stream', async () => { - const root = tmpRoot() - const julia = fakeJulia(root, 'julia-clean', CLEAN) - const script = fakeJulia(root, 'solve.jl', '') // content irrelevant; must exist +describe("LocalExecutor happy path", () => { + it("produces a conforming run dir and ordered event stream", async () => { + const root = tmpRoot(); + const julia = fakeJulia(root, "julia-clean", CLEAN); + const script = fakeJulia(root, "solve.jl", ""); // content irrelevant; must exist const h = await new LocalExecutor().submit(script, { - lab: 'testlab', runsRoot: join(root, 'runs'), julia: { julia }, - }) + lab: "testlab", + runsRoot: join(root, "runs"), + julia: { julia }, + }); // manifest observable before events finish — submit() resolved, so it must exist NOW - const manifest = readToml(join(h.runDir, 'run.toml')) - expect(validateManifest(manifest).ok).toBe(true) - expect(manifest.lab_id).toBe('testlab') + const manifest = readToml(join(h.runDir, "run.toml")); + expect(validateManifest(manifest).ok).toBe(true); + expect(manifest.lab_id).toBe("testlab"); - const evs = await collect(h.events) - expect(evs.filter(e => e.kind === 'iter')).toHaveLength(2) - expect(evs.filter(e => e.kind === 'done')).toHaveLength(1) - const fin = evs.at(-1)! - expect(fin).toEqual({ kind: 'finished', status: 'completed', exitCode: 0 }) - expect(await h.finished).toEqual({ status: 'completed', exitCode: 0 }) + const evs = await collect(h.events); + expect(evs.filter((e) => e.kind === "iter")).toHaveLength(2); + expect(evs.filter((e) => e.kind === "done")).toHaveLength(1); + const fin = evs.at(-1)!; + expect(fin).toEqual({ kind: "finished", status: "completed", exitCode: 0 }); + expect(await h.finished).toEqual({ status: "completed", exitCode: 0 }); - const finished = readToml(join(h.runDir, 'FINISHED')) - expect(validateFinished(finished).ok).toBe(true) - expect(finished.status).toBe('completed') + const finished = readToml(join(h.runDir, "FINISHED")); + expect(validateFinished(finished).ok).toBe(true); + expect(finished.status).toBe("completed"); // run.log mirrors stdout verbatim; index has exactly one line; latest points at the run - expect(readFileSync(join(h.runDir, 'run.log'), 'utf8')).toContain('AMICODE_ITER iter=2') - expect(readFileSync(join(root, 'runs', 'index'), 'utf8').trim().split('\n')).toHaveLength(1) + expect(readFileSync(join(h.runDir, "run.log"), "utf8")).toContain("AMICODE_ITER iter=2"); + expect( + readFileSync(join(root, "runs", "index"), "utf8") + .trim() + .split("\n"), + ).toHaveLength(1); // no temp files left anywhere in the run dir - expect(readdirSync(h.runDir).filter(f => f.includes('.tmp-'))).toHaveLength(0) - }) + expect(readdirSync(h.runDir).filter((f) => f.includes(".tmp-"))).toHaveLength(0); + }); - it('config errors reject BEFORE any run dir exists (exit-64 class)', async () => { - const root = tmpRoot() - await expect(new LocalExecutor().submit(join(root, 'nope.jl'), { runsRoot: join(root, 'runs') })) - .rejects.toThrow(/script not found/) - expect(existsSync(join(root, 'runs'))).toBe(false) - }) + it("config errors reject BEFORE any run dir exists (exit-64 class)", async () => { + const root = tmpRoot(); + await expect(new LocalExecutor().submit(join(root, "nope.jl"), { runsRoot: join(root, "runs") })).rejects.toThrow( + /script not found/, + ); + expect(existsSync(join(root, "runs"))).toBe(false); + }); - it('passes --project/--sysimage through and runs with cwd = runDir', async () => { - const root = tmpRoot() - const julia = fakeJulia(root, 'julia-echo', - `console.log('ARGS ' + process.argv.slice(2).join(' ')); console.log('CWD ' + process.cwd())`) - const script = fakeJulia(root, 's.jl', '') + it("passes --project/--sysimage through and runs with cwd = runDir", async () => { + const root = tmpRoot(); + const julia = fakeJulia( + root, + "julia-echo", + `console.log('ARGS ' + process.argv.slice(2).join(' ')); console.log('CWD ' + process.cwd())`, + ); + const script = fakeJulia(root, "s.jl", ""); const h = await new LocalExecutor().submit(script, { - runsRoot: join(root, 'runs'), julia: { julia, project: '/proj', sysimage: '/img.so' }, - }) - const evs = await collect(h.events) - const argLine = evs.find(e => e.kind === 'log' && e.line.startsWith('ARGS')) as Extract - expect(argLine.line).toContain('--project=/proj') - expect(argLine.line).toContain('--sysimage=/img.so') - const cwdLine = evs.find(e => e.kind === 'log' && e.line.startsWith('CWD')) as Extract - expect(cwdLine.line).toContain(h.runDir) - }) -}) + runsRoot: join(root, "runs"), + julia: { julia, project: "/proj", sysimage: "/img.so" }, + }); + const evs = await collect(h.events); + const argLine = evs.find((e) => e.kind === "log" && e.line.startsWith("ARGS")) as Extract< + RunEvent, + { kind: "log" } + >; + expect(argLine.line).toContain("--project=/proj"); + expect(argLine.line).toContain("--sysimage=/img.so"); + const cwdLine = evs.find((e) => e.kind === "log" && e.line.startsWith("CWD")) as Extract; + expect(cwdLine.line).toContain(h.runDir); + }); +}); diff --git a/packages/amico-run/test/run_dir.test.ts b/packages/amico-run/test/run_dir.test.ts index d05ba01c..b408ba47 100644 --- a/packages/amico-run/test/run_dir.test.ts +++ b/packages/amico-run/test/run_dir.test.ts @@ -1,95 +1,110 @@ -import { describe, it, expect } from 'vitest' -import { existsSync, readFileSync, readlinkSync, mkdirSync } from 'node:fs' -import { join } from 'node:path' -import { tmpRoot, readToml } from './helpers.js' +import { describe, it, expect } from "vitest"; +import { existsSync, readFileSync, readlinkSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { tmpRoot, readToml } from "./helpers.js"; import { - deriveLabId, generateRunId, atomicWriteFile, - writeManifest, writeFinished, appendIndex, updateLatest, -} from '../src/run_dir.js' -import { ConfigError } from '../src/types.js' -import { validate } from '@amicode/schema' + deriveLabId, + generateRunId, + atomicWriteFile, + writeManifest, + writeFinished, + appendIndex, + updateLatest, +} from "../src/run_dir.js"; +import { ConfigError } from "../src/types.js"; +import { validate } from "@amicode/schema"; -describe('deriveLabId', () => { - it('uses id pointers verbatim', () => expect(deriveLabId('schuster')).toBe('schuster')) - it('derives from parent dir of a lab.toml path', () => - expect(deriveLabId('/labs/schuster/lab.toml')).toBe('schuster')) - it('rejects pointers that fit neither rule', () => - expect(() => deriveLabId('Bad Lab!')).toThrow(ConfigError)) -}) +describe("deriveLabId", () => { + it("uses id pointers verbatim", () => expect(deriveLabId("schuster")).toBe("schuster")); + it("derives from parent dir of a lab.toml path", () => + expect(deriveLabId("/labs/schuster/lab.toml")).toBe("schuster")); + it("rejects pointers that fit neither rule", () => expect(() => deriveLabId("Bad Lab!")).toThrow(ConfigError)); +}); -describe('generateRunId', () => { - it('matches r-<4hex> and avoids collisions', () => { - const root = tmpRoot() - const id = generateRunId(root, new Date('2026-06-10T10:12:45.678Z')) - expect(id).toMatch(/^r20260610-101245Z-[0-9a-f]{4}$/) - mkdirSync(join(root, id)) - const id2 = generateRunId(root, new Date('2026-06-10T10:12:45.678Z')) - expect(id2).not.toBe(id) - }) -}) +describe("generateRunId", () => { + it("matches r-<4hex> and avoids collisions", () => { + const root = tmpRoot(); + const id = generateRunId(root, new Date("2026-06-10T10:12:45.678Z")); + expect(id).toMatch(/^r20260610-101245Z-[0-9a-f]{4}$/); + mkdirSync(join(root, id)); + const id2 = generateRunId(root, new Date("2026-06-10T10:12:45.678Z")); + expect(id2).not.toBe(id); + }); +}); -describe('writers', () => { - it('manifest round-trips through a TOML parser with exact snake_case keys', () => { - const root = tmpRoot() +describe("writers", () => { + it("manifest round-trips through a TOML parser with exact snake_case keys", () => { + const root = tmpRoot(); writeManifest(root, { - schema_version: '1', run_id: 'r1', script_path: '/s.jl', - lab: '/labs/x/lab.toml', lab_id: 'x', - created_at: '2026-06-10T10:12:45Z', orchestrator_version: '0.1.0', - julia: { binary: 'julia', project: '/proj' }, - }) - const m = readToml(join(root, 'run.toml')) - expect(m.schema_version).toBe('1') - expect(m.lab_id).toBe('x') - expect((m.julia as Record).project).toBe('/proj') - expect(m).not.toHaveProperty('sizeClass') // spec §5: intentionally absent - }) + schema_version: "1", + run_id: "r1", + script_path: "/s.jl", + lab: "/labs/x/lab.toml", + lab_id: "x", + created_at: "2026-06-10T10:12:45Z", + orchestrator_version: "0.1.0", + julia: { binary: "julia", project: "/proj" }, + }); + const m = readToml(join(root, "run.toml")); + expect(m.schema_version).toBe("1"); + expect(m.lab_id).toBe("x"); + expect((m.julia as Record).project).toBe("/proj"); + expect(m).not.toHaveProperty("sizeClass"); // spec §5: intentionally absent + }); it('manifest v2: tier + [hashes] emitted only when present; validates as "run" v2 (spec C)', () => { - const root = tmpRoot() + const root = tmpRoot(); const base = { - run_id: 'r1', script_path: '/s.jl', lab: 'default', lab_id: 'default', - created_at: '2026-07-03T00:00:00Z', orchestrator_version: '0.1.0', - julia: { binary: 'julia' }, - } + run_id: "r1", + script_path: "/s.jl", + lab: "default", + lab_id: "default", + created_at: "2026-07-03T00:00:00Z", + orchestrator_version: "0.1.0", + julia: { binary: "julia" }, + }; // bare (v1) output is byte-stable: no tier/hashes lines at all - writeManifest(root, { schema_version: '1', ...base }) - const v1text = readFileSync(join(root, 'run.toml'), 'utf8') - expect(v1text).not.toContain('tier') - expect(v1text).not.toContain('[hashes]') + writeManifest(root, { schema_version: "1", ...base }); + const v1text = readFileSync(join(root, "run.toml"), "utf8"); + expect(v1text).not.toContain("tier"); + expect(v1text).not.toContain("[hashes]"); // spec-driven (v2) writeManifest(root, { - schema_version: '2', ...base, tier: 'free', - hashes: { system_hash: 'sha256:ab', spec_hash: 'sha256:cd' }, - }) - const m = readToml(join(root, 'run.toml')) - expect(m.schema_version).toBe('2') - expect(m.tier).toBe('free') - expect((m.hashes as Record).spec_hash).toBe('sha256:cd') - expect(validate(m, 'run').errors).toEqual([]) - }) - it('FINISHED carries status + exit_code (snake_case)', () => { - const root = tmpRoot() - writeFinished(root, 'failed', 7) - expect(readToml(join(root, 'FINISHED'))).toEqual({ status: 'failed', exit_code: 7 }) - }) - it('atomicWriteFile leaves no temp file behind', () => { - const root = tmpRoot() - atomicWriteFile(root, 'f.toml', 'a = 1\n') - expect(readFileSync(join(root, 'f.toml'), 'utf8')).toBe('a = 1\n') - expect(existsSync(join(root, `.f.toml.tmp-${process.pid}`))).toBe(false) - }) - it('index appends one tab-separated line per run; latest symlink swings', () => { - const root = tmpRoot() - appendIndex(root, 'r1', 't1', '/a.jl'); appendIndex(root, 'r2', 't2', '/b.jl') - expect(readFileSync(join(root, 'index'), 'utf8')).toBe('r1\tt1\t/a.jl\nr2\tt2\t/b.jl\n') - mkdirSync(join(root, 'r2')) - updateLatest(root, 'r2') - expect(readlinkSync(join(root, 'latest'))).toBe('r2') - }) - it('sanitizes tab/newline in the script path so the TSV index stays one line per run', () => { - const root = tmpRoot() - appendIndex(root, 'r1', 't1', '/weird\tpath\nwith/ctrl.jl') - const lines = readFileSync(join(root, 'index'), 'utf8').trimEnd().split('\n') - expect(lines).toHaveLength(1) // not corrupted into multiple rows - expect(lines[0].split('\t')).toHaveLength(3) // exactly runId/createdAt/path fields - }) -}) + schema_version: "2", + ...base, + tier: "free", + hashes: { system_hash: "sha256:ab", spec_hash: "sha256:cd" }, + }); + const m = readToml(join(root, "run.toml")); + expect(m.schema_version).toBe("2"); + expect(m.tier).toBe("free"); + expect((m.hashes as Record).spec_hash).toBe("sha256:cd"); + expect(validate(m, "run").errors).toEqual([]); + }); + it("FINISHED carries status + exit_code (snake_case)", () => { + const root = tmpRoot(); + writeFinished(root, "failed", 7); + expect(readToml(join(root, "FINISHED"))).toEqual({ status: "failed", exit_code: 7 }); + }); + it("atomicWriteFile leaves no temp file behind", () => { + const root = tmpRoot(); + atomicWriteFile(root, "f.toml", "a = 1\n"); + expect(readFileSync(join(root, "f.toml"), "utf8")).toBe("a = 1\n"); + expect(existsSync(join(root, `.f.toml.tmp-${process.pid}`))).toBe(false); + }); + it("index appends one tab-separated line per run; latest symlink swings", () => { + const root = tmpRoot(); + appendIndex(root, "r1", "t1", "/a.jl"); + appendIndex(root, "r2", "t2", "/b.jl"); + expect(readFileSync(join(root, "index"), "utf8")).toBe("r1\tt1\t/a.jl\nr2\tt2\t/b.jl\n"); + mkdirSync(join(root, "r2")); + updateLatest(root, "r2"); + expect(readlinkSync(join(root, "latest"))).toBe("r2"); + }); + it("sanitizes tab/newline in the script path so the TSV index stays one line per run", () => { + const root = tmpRoot(); + appendIndex(root, "r1", "t1", "/weird\tpath\nwith/ctrl.jl"); + const lines = readFileSync(join(root, "index"), "utf8").trimEnd().split("\n"); + expect(lines).toHaveLength(1); // not corrupted into multiple rows + expect(lines[0].split("\t")).toHaveLength(3); // exactly runId/createdAt/path fields + }); +}); diff --git a/packages/amico-run/test/s31.test.ts b/packages/amico-run/test/s31.test.ts index d0914185..89f6a08b 100644 --- a/packages/amico-run/test/s31.test.ts +++ b/packages/amico-run/test/s31.test.ts @@ -1,23 +1,22 @@ -import { describe, it, expect } from 'vitest' -import { readFileSync, readdirSync } from 'node:fs' -import { join } from 'node:path' +import { describe, it, expect } from "vitest"; +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; // S31 / spec §4: no PHYSICS flag parsing, no MCP, no HTTP in the orchestrator. // (The original /SolveSpec/ ban is lifted by spec C: amico-run is now the // named SolveSpec launch gate — it validates + gates the spec before spawning // Julia. The physics-flag bans below still hold: --spec is a spec-file path, // NOT a physics knob; all physics stays in the script.) -const FORBIDDEN = [/--gate\b/, /--system\b/, /--pulse\b/, - /modelcontextprotocol/i, /node:https?\b/, /\bfetch\s*\(/] +const FORBIDDEN = [/--gate\b/, /--system\b/, /--pulse\b/, /modelcontextprotocol/i, /node:https?\b/, /\bfetch\s*\(/]; -describe('S31 grep rule', () => { - it('src/ contains no forbidden tool-layer patterns', () => { - const srcDir = join(__dirname, '..', 'src') +describe("S31 grep rule", () => { + it("src/ contains no forbidden tool-layer patterns", () => { + const srcDir = join(__dirname, "..", "src"); for (const f of readdirSync(srcDir)) { - const text = readFileSync(join(srcDir, f), 'utf8') + const text = readFileSync(join(srcDir, f), "utf8"); for (const re of FORBIDDEN) { - expect(text, `${f} matches forbidden ${re}`).not.toMatch(re) + expect(text, `${f} matches forbidden ${re}`).not.toMatch(re); } } - }) -}) + }); +}); diff --git a/packages/amico-run/test/scheduler.test.ts b/packages/amico-run/test/scheduler.test.ts new file mode 100644 index 00000000..be538dab --- /dev/null +++ b/packages/amico-run/test/scheduler.test.ts @@ -0,0 +1,294 @@ +import { describe, it, expect } from "vitest"; +import { Scheduler, type SchedulerEvent } from "../src/scheduler.js"; +import { + ConfigError, + type Executor, + type Finished, + type RunEvent, + type RunHandle, + type SubmitOpts, +} from "../src/types.js"; +import { EventQueue } from "../src/event_queue.js"; + +// 1.1 Scheduler (#56) — serial queue built TO the ratified Executor contract +// (Track C spec, locked 2026-07-02). The load-bearing behaviors under test: +// - serial: entry N+1 submits only after entry N's `finished` RESOLVES; +// - (b) abort() is a REQUEST, not a kill — post-abort() the run is still +// alive and the queue must NOT advance until `finished` lands; +// - (c) no warming timeout — the Scheduler owns no timers at all; +// - S12 — downstream sees only the executor's RunHandle, passed through. + +/** Controllable fake executor: each submit() returns a handle whose `finished` + * the TEST resolves. Records submit order/args. */ +class FakeExecutor implements Executor { + submits: Array<{ scriptPath: string; opts?: SubmitOpts }> = []; + handles: Array<{ handle: RunHandle; finish: (f: Finished) => void; aborted: boolean[] }> = []; + /** scripts whose submit() should throw ConfigError */ + failFor = new Set(); + + async submit(scriptPath: string, opts?: SubmitOpts): Promise { + this.submits.push({ scriptPath, opts }); + if (this.failFor.has(scriptPath)) throw new ConfigError(`bad config: ${scriptPath}`); + const n = this.submits.length; + let finish!: (f: Finished) => void; + const finished = new Promise((r) => { + finish = r; + }); + const aborted: boolean[] = []; + const handle: RunHandle = { + runId: `run-${n}`, + runDir: `/runs/run-${n}`, + events: new EventQueue(), + finished, + // Contract (b): abort resolves only when finished does (request, not kill). + abort: async () => { + aborted.push(true); + await finished; + }, + }; + this.handles.push({ handle, finish, aborted }); + return handle; + } +} + +const tick = () => new Promise((r) => setTimeout(r, 0)); + +function collect(s: Scheduler): SchedulerEvent[] { + const seen: SchedulerEvent[] = []; + s.onEvent((e) => seen.push(e)); + return seen; +} + +describe("Scheduler — serial queue (#56)", () => { + it("runs entries strictly serially: N+1 submits only after N `finished` resolves", async () => { + const ex = new FakeExecutor(); + const s = new Scheduler(ex); + const a = s.enqueue({ scriptPath: "a.jl" }); + const b = s.enqueue({ scriptPath: "b.jl" }); + await tick(); + expect(ex.submits.map((x) => x.scriptPath)).toEqual(["a.jl"]); // b NOT submitted yet + ex.handles[0].finish({ status: "completed", exitCode: 0 }); + await tick(); + expect(ex.submits.map((x) => x.scriptPath)).toEqual(["a.jl", "b.jl"]); + const [ha, hb] = [await a.handle, await b.handle]; + expect(ha.runId).toBe("run-1"); + expect(hb.runId).toBe("run-2"); + }); + + it("S12: the resolved handle IS the executor RunHandle (identity passthrough)", async () => { + const ex = new FakeExecutor(); + const s = new Scheduler(ex); + const r = s.enqueue({ scriptPath: "a.jl" }); + await tick(); + expect(await r.handle).toBe(ex.handles[0].handle); + }); + + it("passes SubmitOpts through to executor.submit verbatim", async () => { + const ex = new FakeExecutor(); + const s = new Scheduler(ex); + const opts: SubmitOpts = { lab: "lab-7", runsRoot: "/tmp/rr", julia: { project: "/p" } }; + s.enqueue({ scriptPath: "a.jl", opts }); + await tick(); + expect(ex.submits[0].opts).toBe(opts); + }); + + it("contract (b): abort() does NOT advance the queue — only `finished` does", async () => { + const ex = new FakeExecutor(); + const s = new Scheduler(ex); + const a = s.enqueue({ scriptPath: "a.jl" }); + s.enqueue({ scriptPath: "b.jl" }); + await tick(); + const ha = await a.handle; + void ha.abort(); // request termination… + await tick(); + await tick(); + expect(ex.submits).toHaveLength(1); // …but the run is still alive: b must NOT start + ex.handles[0].finish({ status: "aborted", exitCode: 143 }); // FINISHED lands + await tick(); + expect(ex.submits).toHaveLength(2); // now b starts + }); + + it("emits the lifecycle: queued → started → finished, with queue position", async () => { + const ex = new FakeExecutor(); + const s = new Scheduler(ex); + const seen = collect(s); + s.enqueue({ scriptPath: "a.jl" }); + s.enqueue({ scriptPath: "b.jl" }); + await tick(); + ex.handles[0].finish({ status: "completed", exitCode: 0 }); + await tick(); + ex.handles[1].finish({ status: "failed", exitCode: 1 }); + await tick(); + expect(seen).toEqual([ + { kind: "queued", queueId: "q1", position: 0 }, + { kind: "queued", queueId: "q2", position: 1 }, + { kind: "started", queueId: "q1", runId: "run-1", runDir: "/runs/run-1" }, + { kind: "finished", queueId: "q1", runId: "run-1", status: "completed", exitCode: 0 }, + { kind: "started", queueId: "q2", runId: "run-2", runDir: "/runs/run-2" }, + { kind: "finished", queueId: "q2", runId: "run-2", status: "failed", exitCode: 1 }, + ]); + }); + + it("cancel() while queued: never submitted, cancelled event, handle rejects", async () => { + const ex = new FakeExecutor(); + const s = new Scheduler(ex); + const seen = collect(s); + s.enqueue({ scriptPath: "a.jl" }); + const b = s.enqueue({ scriptPath: "b.jl" }); + await tick(); + expect(b.cancel()).toBe(true); + ex.handles[0].finish({ status: "completed", exitCode: 0 }); + await tick(); + expect(ex.submits.map((x) => x.scriptPath)).toEqual(["a.jl"]); // b never ran + expect(seen.some((e) => e.kind === "cancelled" && e.queueId === "q2")).toBe(true); + await expect(b.handle).rejects.toThrow(/cancel/i); + }); + + it("cancel() after start returns false and the run is untouched (abort via the handle instead)", async () => { + const ex = new FakeExecutor(); + const s = new Scheduler(ex); + const a = s.enqueue({ scriptPath: "a.jl" }); + await tick(); + await a.handle; + expect(a.cancel()).toBe(false); + expect(ex.handles[0].aborted).toHaveLength(0); // cancel is NOT an abort + }); + + it("a submit() ConfigError rejects that handle, emits error, and the queue advances", async () => { + const ex = new FakeExecutor(); + ex.failFor.add("bad.jl"); + const s = new Scheduler(ex); + const seen = collect(s); + const bad = s.enqueue({ scriptPath: "bad.jl" }); + const ok = s.enqueue({ scriptPath: "ok.jl" }); + await tick(); + await expect(bad.handle).rejects.toThrow(/bad config/); + expect(seen.some((e) => e.kind === "error" && e.queueId === "q1")).toBe(true); + await tick(); + expect(ex.submits.map((x) => x.scriptPath)).toEqual(["bad.jl", "ok.jl"]); // queue not wedged + expect((await ok.handle).runId).toBe("run-2"); // FakeExecutor counts the failed submit too + }); + + it("concurrent: true is a NAMED SEAM — rejected loudly (parallel lane is Phase 4)", () => { + const s = new Scheduler(new FakeExecutor()); + expect(() => s.enqueue({ scriptPath: "a.jl" }, { concurrent: true })).toThrow(ConfigError); + expect(() => s.enqueue({ scriptPath: "a.jl" }, { concurrent: true })).toThrow(/Phase 4/); + }); + + it("multiple listeners both receive events; a disposed listener stops receiving", async () => { + const ex = new FakeExecutor(); + const s = new Scheduler(ex); + const a: SchedulerEvent[] = []; + const b: SchedulerEvent[] = []; + const disposeA = s.onEvent((e) => a.push(e)); + s.onEvent((e) => b.push(e)); + s.enqueue({ scriptPath: "x.jl" }); + await tick(); + expect(a.length).toBeGreaterThan(0); + expect(b.length).toBe(a.length); + disposeA(); + ex.handles[0].finish({ status: "completed", exitCode: 0 }); + await tick(); + expect(b.length).toBeGreaterThan(a.length); // b kept receiving after a disposed + }); + + it("a throwing listener cannot wedge the pump or starve other listeners", async () => { + const ex = new FakeExecutor(); + const s = new Scheduler(ex); + const good: SchedulerEvent[] = []; + s.onEvent(() => { + throw new Error("bad listener"); + }); + s.onEvent((e) => good.push(e)); + s.enqueue({ scriptPath: "x.jl" }); + await tick(); + ex.handles[0].finish({ status: "completed", exitCode: 0 }); + await tick(); + expect(good.some((e) => e.kind === "finished")).toBe(true); // pump survived + }); + + it("contract (d): a rogue `finished` REJECTION is survived — error event, queue advances", async () => { + // `finished` never rejects per contract; a broken executor must still not + // wedge every queued run behind it. (Pins the defensive branch — a mutation + // deleting it must fail here.) + class RogueExecutor extends FakeExecutor { + async submit(scriptPath: string, opts?: SubmitOpts): Promise { + const h = await super.submit(scriptPath, opts); + if (scriptPath === "rogue.jl") return { ...h, finished: Promise.reject(new Error("boom")) }; + return h; + } + } + const ex = new RogueExecutor(); + const s = new Scheduler(ex); + const seen = collect(s); + s.enqueue({ scriptPath: "rogue.jl" }); + const ok = s.enqueue({ scriptPath: "ok.jl" }); + await tick(); + await tick(); + expect( + seen.some((e) => e.kind === "error" && /finished rejected: boom/.test((e as { message: string }).message)), + ).toBe(true); + expect(ex.submits.map((x) => x.scriptPath)).toEqual(["rogue.jl", "ok.jl"]); // queue advanced + expect((await ok.handle).runId).toBe("run-2"); + }); + + it("a SYNC-throwing submit (contract-violating executor) cannot blow the stack or strand the queue", async () => { + // The dangerous shape: a big backlog of sync-throwers ACCUMULATES behind one + // pending run, then drains in a single chain when it finishes. With a direct + // finally re-pump that chain is real recursion (RangeError → stranded queue); + // the microtask deferral keeps it flat. (Enqueuing sync-throwers onto an idle + // scheduler never recurses — each enqueue drains its own entry — so the + // backlog-behind-a-pending-run setup is load-bearing for this pin.) + class SyncThrower implements Executor { + good = new FakeExecutor(); + submit(scriptPath: string, opts?: SubmitOpts): Promise { + if (!scriptPath.startsWith("bad-")) return this.good.submit(scriptPath, opts); + throw new ConfigError(`sync boom: ${scriptPath}`); // sync, no Promise + } + } + const ex = new SyncThrower(); + const s = new Scheduler(ex); + s.enqueue({ scriptPath: "first.jl" }); // holds the queue while the backlog builds + await tick(); + const bad = Array.from({ length: 8000 }, (_, i) => s.enqueue({ scriptPath: `bad-${i}.jl` })); + const good = s.enqueue({ scriptPath: "good.jl" }); + ex.good.handles[0].finish({ status: "completed", exitCode: 0 }); // release → drain the 8000 in one go + const h = await good.handle; // resolves only if the whole backlog drained + expect(h.runId).toBe("run-2"); + expect(s.depth).toBe(1); // just the good run, still running + await expect(bad[0].handle).rejects.toThrow(/sync boom/); + await expect(bad[7999].handle).rejects.toThrow(/sync boom/); + }); + + it("an untouched ScheduledRun.handle never surfaces an unhandledRejection (cancel path)", async () => { + // Pins the internal handle.catch(() => {}) suppression explicitly — callers + // that only consume lifecycle events never touch `handle`, and a cancel's + // rejection must not trip the process. + const seen: unknown[] = []; + const trap = (r: unknown): void => { + seen.push(r); + }; + process.on("unhandledRejection", trap); + try { + const s = new Scheduler(new FakeExecutor()); + s.enqueue({ scriptPath: "a.jl" }); + const b = s.enqueue({ scriptPath: "b.jl" }); + expect(b.cancel()).toBe(true); // rejects b.handle — nobody is listening + await tick(); + await tick(); + expect(seen).toEqual([]); + } finally { + process.off("unhandledRejection", trap); + } + }); + + it("contract (c): the Scheduler owns no timers (no warming timeout to hard-code)", async () => { + // Structural pin: remote cold-start ≫ local seconds, so ANY scheduler-side + // timeout would violate the per-executor warming budget. Assert the source + // has no timer calls at all (microtasks are fine — they encode no duration). + const { readFileSync } = await import("node:fs"); + const { fileURLToPath } = await import("node:url"); + const src = readFileSync(fileURLToPath(new URL("../src/scheduler.ts", import.meta.url)), "utf8"); + expect(src).not.toMatch(/setTimeout|setInterval|setImmediate|Date\.now/); + }); +}); diff --git a/packages/amico-run/test/schemas.test.ts b/packages/amico-run/test/schemas.test.ts index 879978af..0e5f3ca4 100644 --- a/packages/amico-run/test/schemas.test.ts +++ b/packages/amico-run/test/schemas.test.ts @@ -1,59 +1,62 @@ -import { describe, it, expect } from 'vitest' -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { validateManifest, validateFinished, validateResult } from '../src/schemas.js' +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { validateManifest, validateFinished, validateResult } from "../src/schemas.js"; // These wrappers delegate to the shared @amicode/schema (single source of truth); // this suite is the delegation smoke + the field-precise contract they expose. const goodManifest = { - schema_version: '1', run_id: 'r20260610-101245Z-ab12', script_path: '/s.jl', - lab: 'default', lab_id: 'default', created_at: '2026-06-10T10:12:45Z', - orchestrator_version: '0.1.0', julia: { binary: 'julia' }, -} + schema_version: "1", + run_id: "r20260610-101245Z-ab12", + script_path: "/s.jl", + lab: "default", + lab_id: "default", + created_at: "2026-06-10T10:12:45Z", + orchestrator_version: "0.1.0", + julia: { binary: "julia" }, +}; -describe('validateManifest', () => { - it('accepts a conforming manifest', () => - expect(validateManifest(goodManifest)).toEqual({ ok: true, errors: [] })) - it('reports each missing/mistyped field by path', () => { - const r = validateManifest({ ...goodManifest, run_id: 42, julia: {} }) - expect(r.ok).toBe(false) - expect(r.errors.join(' ')).toContain('run_id') // wrong-typed top-level field - expect(r.errors.join(' ')).toContain('binary') // /julia missing required "binary" - }) - it('rejects unknown schema_version (v2 is now valid — spec C bump)', () => { - expect(validateManifest({ ...goodManifest, schema_version: '99' }).ok).toBe(false) - expect(validateManifest({ ...goodManifest, schema_version: '2' }).ok).toBe(true) - }) -}) +describe("validateManifest", () => { + it("accepts a conforming manifest", () => expect(validateManifest(goodManifest)).toEqual({ ok: true, errors: [] })); + it("reports each missing/mistyped field by path", () => { + const r = validateManifest({ ...goodManifest, run_id: 42, julia: {} }); + expect(r.ok).toBe(false); + expect(r.errors.join(" ")).toContain("run_id"); // wrong-typed top-level field + expect(r.errors.join(" ")).toContain("binary"); // /julia missing required "binary" + }); + it("rejects unknown schema_version (v2 is now valid — spec C bump)", () => { + expect(validateManifest({ ...goodManifest, schema_version: "99" }).ok).toBe(false); + expect(validateManifest({ ...goodManifest, schema_version: "2" }).ok).toBe(true); + }); +}); -describe('validateFinished', () => { - it('accepts {status, exit_code}', () => - expect(validateFinished({ status: 'aborted', exit_code: 143 }).ok).toBe(true)) - it('rejects bad status and non-integer exit_code', () => { - expect(validateFinished({ status: 'ok', exit_code: 0 }).ok).toBe(false) - expect(validateFinished({ status: 'failed', exit_code: 1.5 }).ok).toBe(false) - }) -}) +describe("validateFinished", () => { + it("accepts {status, exit_code}", () => + expect(validateFinished({ status: "aborted", exit_code: 143 }).ok).toBe(true)); + it("rejects bad status and non-integer exit_code", () => { + expect(validateFinished({ status: "ok", exit_code: 0 }).ok).toBe(false); + expect(validateFinished({ status: "failed", exit_code: 1.5 }).ok).toBe(false); + }); +}); -describe('validateResult (reader-side)', () => { - it('requires schema_version, fidelity number, iterations integer', () => { +describe("validateResult (reader-side)", () => { + it("requires schema_version, fidelity number, iterations integer", () => { // The formalized contract carries schema_version on result.toml (0.1a adds the // emit; the Julia round-trip enforces it). An artifact lacking it is rejected. - expect(validateResult({ schema_version: '1', fidelity: 0.999, iterations: 200, wall_seconds: 12.5 }).ok).toBe(true) - expect(validateResult({ fidelity: 0.999, iterations: 200 }).ok).toBe(false) // no schema_version - expect(validateResult({ schema_version: '1', iterations: 200 }).ok).toBe(false) // no fidelity - }) -}) + expect(validateResult({ schema_version: "1", fidelity: 0.999, iterations: 200, wall_seconds: 12.5 }).ok).toBe(true); + expect(validateResult({ fidelity: 0.999, iterations: 200 }).ok).toBe(false); // no schema_version + expect(validateResult({ schema_version: "1", iterations: 200 }).ok).toBe(false); // no fidelity + }); +}); // Anti-regression (N4): schemas.ts must remain a thin DELEGATION, never re-define // a schema/validator. Guards the "one validator path" invariant (#15 AC7). -describe('schemas.ts is delegation-only (no re-introduced schema)', () => { - const src = readFileSync(join(__dirname, '..', 'src', 'schemas.ts'), 'utf8') - it('imports the shared @amicode/schema', () => - expect(src).toMatch(/from ["']@amicode\/schema["']/)) - it('does not hand-roll validation (no local check helper / additionalProperties / required arrays)', () => { - expect(src).not.toMatch(/additionalProperties/) - expect(src).not.toMatch(/function check\b/) - expect(src).not.toMatch(/errors\.push/) - }) -}) +describe("schemas.ts is delegation-only (no re-introduced schema)", () => { + const src = readFileSync(join(__dirname, "..", "src", "schemas.ts"), "utf8"); + it("imports the shared @amicode/schema", () => expect(src).toMatch(/from ["']@amicode\/schema["']/)); + it("does not hand-roll validation (no local check helper / additionalProperties / required arrays)", () => { + expect(src).not.toMatch(/additionalProperties/); + expect(src).not.toMatch(/function check\b/); + expect(src).not.toMatch(/errors\.push/); + }); +}); diff --git a/packages/amico-run/test/slow/integration.test.ts b/packages/amico-run/test/slow/integration.test.ts index 8cbb5636..87dd4219 100644 --- a/packages/amico-run/test/slow/integration.test.ts +++ b/packages/amico-run/test/slow/integration.test.ts @@ -1,38 +1,39 @@ -import { describe, it, expect } from 'vitest' -import { execFileSync } from 'node:child_process' -import { join } from 'node:path' -import { tmpRoot, readToml } from '../helpers.js' -import { validateManifest, validateFinished, validateResult } from '../../src/schemas.js' +import { describe, it, expect } from "vitest"; +import { execFileSync } from "node:child_process"; +import { join } from "node:path"; +import { tmpRoot, readToml } from "../helpers.js"; +import { validateManifest, validateFinished, validateResult } from "../../src/schemas.js"; // Slow tier (spec §8): real Piccolo solves through the real CLI. Dev machine only — not CI. // Requires: julia on PATH + a Piccolo project (pass via AMICO_TEST_JULIA_PROJECT to *the test*, // which forwards it as an explicit --project flag — the orchestrator itself stays env-free). -const PROJECT = process.env.AMICO_TEST_JULIA_PROJECT -const BUNDLE = join(__dirname, '..', '..', 'dist', 'amico-run.js') +const PROJECT = process.env.AMICO_TEST_JULIA_PROJECT; +const BUNDLE = join(__dirname, "..", "..", "dist", "amico-run.js"); function solveAndValidate(script: string): void { - const root = tmpRoot() - const stdout = execFileSync('node', [ - BUNDLE, join(__dirname, script), - '--runs-root', join(root, 'runs'), '--project', PROJECT!, '--lab', 'devlab', - ], { encoding: 'utf8', timeout: 600_000 }) + const root = tmpRoot(); + const stdout = execFileSync( + "node", + [BUNDLE, join(__dirname, script), "--runs-root", join(root, "runs"), "--project", PROJECT!, "--lab", "devlab"], + { encoding: "utf8", timeout: 600_000 }, + ); - expect(stdout).toMatch(/AMICODE_ITER iter=/) - expect(stdout).toMatch(/AMICODE_FINISHED status=completed exitCode=0 runDir=(.+)/) - const runDir = stdout.match(/runDir=(.+)/)![1].trim() - expect(validateManifest(readToml(join(runDir, 'run.toml'))).ok).toBe(true) - expect(validateFinished(readToml(join(runDir, 'FINISHED'))).ok).toBe(true) - const result = readToml(join(runDir, 'result.toml')) - expect(validateResult(result).ok).toBe(true) - expect(result.fidelity as number).toBeGreaterThan(0.99) + expect(stdout).toMatch(/AMICODE_ITER iter=/); + expect(stdout).toMatch(/AMICODE_FINISHED status=completed exitCode=0 runDir=(.+)/); + const runDir = stdout.match(/runDir=(.+)/)![1].trim(); + expect(validateManifest(readToml(join(runDir, "run.toml"))).ok).toBe(true); + expect(validateFinished(readToml(join(runDir, "FINISHED"))).ok).toBe(true); + const result = readToml(join(runDir, "result.toml")); + expect(validateResult(result).ok).toBe(true); + expect(result.fidelity as number).toBeGreaterThan(0.99); } -describe.skipIf(!PROJECT)('slow: real Piccolo solves through amico-run', () => { - it('x-gate solve produces a fully conforming run dir', () => { - solveAndValidate('solve_x_gate.jl') - }, 600_000) +describe.skipIf(!PROJECT)("slow: real Piccolo solves through amico-run", () => { + it("x-gate solve produces a fully conforming run dir", () => { + solveAndValidate("solve_x_gate.jl"); + }, 600_000); - it('h-gate solve produces a fully conforming run dir', () => { - solveAndValidate('solve_h_gate.jl') - }, 600_000) -}) + it("h-gate solve produces a fully conforming run dir", () => { + solveAndValidate("solve_h_gate.jl"); + }, 600_000); +}); diff --git a/packages/amico-run/test/subcommands.test.ts b/packages/amico-run/test/subcommands.test.ts index 9936dc36..6f374fae 100644 --- a/packages/amico-run/test/subcommands.test.ts +++ b/packages/amico-run/test/subcommands.test.ts @@ -1,22 +1,22 @@ -import { describe, it, expect, beforeAll } from "vitest" -import { execFileSync } from "node:child_process" -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" -import { tmpdir } from "node:os" -import { join } from "node:path" -import { readToml } from "./helpers.js" +import { describe, it, expect, beforeAll } from "vitest"; +import { execFileSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { readToml } from "./helpers.js"; -const BUNDLE = join(__dirname, "..", "dist", "amico-run.js") +const BUNDLE = join(__dirname, "..", "dist", "amico-run.js"); beforeAll(() => { - execFileSync("node", [join(__dirname, "..", "esbuild.config.mjs")], { cwd: join(__dirname, "..") }) -}) + execFileSync("node", [join(__dirname, "..", "esbuild.config.mjs")], { cwd: join(__dirname, "..") }); +}); function run(args: string[], env: Record = {}): { code: number; stdout: string; stderr: string } { try { - const stdout = execFileSync("node", [BUNDLE, ...args], { encoding: "utf8", env: { ...process.env, ...env } }) - return { code: 0, stdout, stderr: "" } + const stdout = execFileSync("node", [BUNDLE, ...args], { encoding: "utf8", env: { ...process.env, ...env } }); + return { code: 0, stdout, stderr: "" }; } catch (e) { - const err = e as { status?: number; stdout?: string; stderr?: string } - return { code: err.status ?? -1, stdout: err.stdout ?? "", stderr: err.stderr ?? "" } + const err = e as { status?: number; stdout?: string; stderr?: string }; + return { code: err.status ?? -1, stdout: err.stdout ?? "", stderr: err.stderr ?? "" }; } } @@ -35,12 +35,12 @@ packages = ["JLD2", "CairoMakie", "TOML", "Printf"] [uuids] Piccolo = "c4671d76-df94-11ed-2057-43d4fd632fad" JLD2 = "033835bb-8acc-5ee8-8aae-3f567f8a3819" -` +`; function authoringDir(): string { - const dir = mkdtempSync(join(tmpdir(), "amico-sub-")) - writeFileSync(join(dir, "registry.toml"), REGISTRY) - writeFileSync(join(dir, "index.json"), JSON.stringify({ schema_version: 1, exemplars: [] })) + const dir = mkdtempSync(join(tmpdir(), "amico-sub-")); + writeFileSync(join(dir, "registry.toml"), REGISTRY); + writeFileSync(join(dir, "index.json"), JSON.stringify({ schema_version: 1, exemplars: [] })); writeFileSync( join(dir, "authoring.json"), JSON.stringify({ @@ -51,79 +51,79 @@ function authoringDir(): string { exemplars: join(dir, "index.json"), verify_tolerance: 0.01, }), - ) - return dir + ); + return dir; } describe("resolve subcommand", () => { it("exact vetted shape → tier vetted with template_path + packages", () => { - const dir = authoringDir() + const dir = authoringDir(); const r = run(["resolve", "--platform", "transmon", "--kind", "gate_synthesis", "--size", "1"], { AMICO_AUTHORING_FILE: join(dir, "authoring.json"), - }) - expect(r.code).toBe(0) - const out = JSON.parse(r.stdout) - expect(out.tier).toBe("vetted") - expect(out.template_path).toMatch(/solve_template\.jl$/) - expect(out.packages).toContain("Piccolo") - rmSync(dir, { recursive: true, force: true }) - }) + }); + expect(r.code).toBe(0); + const out = JSON.parse(r.stdout); + expect(out.tier).toBe("vetted"); + expect(out.template_path).toMatch(/solve_template\.jl$/); + expect(out.packages).toContain("Piccolo"); + rmSync(dir, { recursive: true, force: true }); + }); it("unknown shape → tier free WITH the skeleton's minimum package set", () => { - const dir = authoringDir() + const dir = authoringDir(); const r = run(["resolve", "--platform", "ions", "--kind", "gate_synthesis", "--size", "1"], { AMICO_AUTHORING_FILE: join(dir, "authoring.json"), - }) - const out = JSON.parse(r.stdout) - expect(out.tier).toBe("free") - expect(out.packages).toEqual(expect.arrayContaining(["Piccolo", "CairoMakie", "JLD2", "TOML", "Printf"])) - rmSync(dir, { recursive: true, force: true }) - }) -}) + }); + const out = JSON.parse(r.stdout); + expect(out.tier).toBe("free"); + expect(out.packages).toEqual(expect.arrayContaining(["Piccolo", "CairoMakie", "JLD2", "TOML", "Printf"])); + rmSync(dir, { recursive: true, force: true }); + }); +}); describe("sandbox subcommand", () => { it("writes env/Project.toml with [deps] uuids + prints instantiate instructions", () => { - const dir = authoringDir() - const target = mkdtempSync(join(tmpdir(), "amico-ws-")) + const dir = authoringDir(); + const target = mkdtempSync(join(tmpdir(), "amico-ws-")); const r = run(["sandbox", target, "--packages", "Piccolo,JLD2"], { AMICO_AUTHORING_FILE: join(dir, "authoring.json"), - }) - expect(r.code).toBe(0) - expect(existsSync(join(target, "env", "Project.toml"))).toBe(true) - const proj = readToml(join(target, "env", "Project.toml")) - const deps = proj.deps as Record - expect(deps.Piccolo).toBe("c4671d76-df94-11ed-2057-43d4fd632fad") - expect(deps.JLD2).toBe("033835bb-8acc-5ee8-8aae-3f567f8a3819") - expect(r.stdout).toContain("JULIA_PKG_USE_CLI_GIT=true") - expect(r.stdout).toContain("Pkg.instantiate()") - rmSync(dir, { recursive: true, force: true }) - rmSync(target, { recursive: true, force: true }) - }) + }); + expect(r.code).toBe(0); + expect(existsSync(join(target, "env", "Project.toml"))).toBe(true); + const proj = readToml(join(target, "env", "Project.toml")); + const deps = proj.deps as Record; + expect(deps.Piccolo).toBe("c4671d76-df94-11ed-2057-43d4fd632fad"); + expect(deps.JLD2).toBe("033835bb-8acc-5ee8-8aae-3f567f8a3819"); + expect(r.stdout).toContain("JULIA_PKG_USE_CLI_GIT=true"); + expect(r.stdout).toContain("Pkg.instantiate()"); + rmSync(dir, { recursive: true, force: true }); + rmSync(target, { recursive: true, force: true }); + }); it("unknown package (no uuid in registry) → exit 64 naming it", () => { - const dir = authoringDir() - const target = mkdtempSync(join(tmpdir(), "amico-ws-")) + const dir = authoringDir(); + const target = mkdtempSync(join(tmpdir(), "amico-ws-")); const r = run(["sandbox", target, "--packages", "Piccolo,Zygote"], { AMICO_AUTHORING_FILE: join(dir, "authoring.json"), - }) - expect(r.code).toBe(64) - expect(r.stderr).toMatch(/Zygote/) - rmSync(dir, { recursive: true, force: true }) - rmSync(target, { recursive: true, force: true }) - }) + }); + expect(r.code).toBe(64); + expect(r.stderr).toMatch(/Zygote/); + rmSync(dir, { recursive: true, force: true }); + rmSync(target, { recursive: true, force: true }); + }); it("stdlibs need no [deps] entry — they load from @stdlib (spec-20260704-113005 §3 defect #2)", () => { - const dir = authoringDir() - const target = mkdtempSync(join(tmpdir(), "amico-ws-")) + const dir = authoringDir(); + const target = mkdtempSync(join(tmpdir(), "amico-ws-")); // TOML + Printf are stdlibs with NO uuid in the fixture registry — before the // filter this exit-64'd; now they are dropped from [deps] and the run succeeds. const r = run(["sandbox", target, "--packages", "Piccolo,JLD2,TOML,Printf"], { AMICO_AUTHORING_FILE: join(dir, "authoring.json"), - }) - expect(r.code).toBe(0) - const deps = readToml(join(target, "env", "Project.toml")).deps as Record - expect(Object.keys(deps).sort()).toEqual(["JLD2", "Piccolo"]) // stdlibs filtered out - rmSync(dir, { recursive: true, force: true }) - rmSync(target, { recursive: true, force: true }) - }) -}) + }); + expect(r.code).toBe(0); + const deps = readToml(join(target, "env", "Project.toml")).deps as Record; + expect(Object.keys(deps).sort()).toEqual(["JLD2", "Piccolo"]); // stdlibs filtered out + rmSync(dir, { recursive: true, force: true }); + rmSync(target, { recursive: true, force: true }); + }); +}); // Production-path (spec-20260704-113005 §3 defect #2): the EXACT tier-free // resolve output (TIER3_MIN_PACKAGES) must sandbox against the BUNDLED registry, @@ -131,9 +131,9 @@ describe("sandbox subcommand", () => { // (filtered); Piccolo/CairoMakie/JLD2 must all be in the bundled [uuids]. describe("sandbox — bundled-asset production path", () => { function bundledAuthoringDir(): string { - const dir = mkdtempSync(join(tmpdir(), "amico-prod-")) - const registry = join(__dirname, "..", "..", "extension", "templates", "registry.toml") - const exemplars = join(__dirname, "..", "..", "extension", "exemplars", "index.json") + const dir = mkdtempSync(join(tmpdir(), "amico-prod-")); + const registry = join(__dirname, "..", "..", "extension", "templates", "registry.toml"); + const exemplars = join(__dirname, "..", "..", "extension", "exemplars", "index.json"); writeFileSync( join(dir, "authoring.json"), JSON.stringify({ @@ -144,19 +144,19 @@ describe("sandbox — bundled-asset production path", () => { exemplars, verify_tolerance: 0.001, }), - ) - return dir + ); + return dir; } it("TIER3_MIN_PACKAGES sandboxes clean against the bundled registry", () => { - const dir = bundledAuthoringDir() - const target = mkdtempSync(join(tmpdir(), "amico-ws-")) + const dir = bundledAuthoringDir(); + const target = mkdtempSync(join(tmpdir(), "amico-ws-")); const r = run(["sandbox", target, "--packages", "Piccolo,CairoMakie,JLD2,TOML,Printf"], { AMICO_AUTHORING_FILE: join(dir, "authoring.json"), - }) - expect(r.code).toBe(0) - const deps = readToml(join(target, "env", "Project.toml")).deps as Record - expect(Object.keys(deps).sort()).toEqual(["CairoMakie", "JLD2", "Piccolo"]) - rmSync(dir, { recursive: true, force: true }) - rmSync(target, { recursive: true, force: true }) - }) -}) + }); + expect(r.code).toBe(0); + const deps = readToml(join(target, "env", "Project.toml")).deps as Record; + expect(Object.keys(deps).sort()).toEqual(["CairoMakie", "JLD2", "Piccolo"]); + rmSync(dir, { recursive: true, force: true }); + rmSync(target, { recursive: true, force: true }); + }); +}); diff --git a/packages/amico-run/test/telemetry.test.ts b/packages/amico-run/test/telemetry.test.ts index 126489ff..2e3cb1e2 100644 --- a/packages/amico-run/test/telemetry.test.ts +++ b/packages/amico-run/test/telemetry.test.ts @@ -1,28 +1,30 @@ -import { describe, it, expect } from 'vitest' -import { classifyLine } from '../src/telemetry.js' +import { describe, it, expect } from "vitest"; +import { classifyLine } from "../src/telemetry.js"; -describe('classifyLine', () => { - it('parses AMICODE_ITER key=value fields', () => { - const ev = classifyLine('AMICODE_ITER iter=12 f=3.4e-5 inf_pr=1.2e-8', 'stdout') +describe("classifyLine", () => { + it("parses AMICODE_ITER key=value fields", () => { + const ev = classifyLine("AMICODE_ITER iter=12 f=3.4e-5 inf_pr=1.2e-8", "stdout"); expect(ev).toEqual({ - kind: 'iter', - raw: 'AMICODE_ITER iter=12 f=3.4e-5 inf_pr=1.2e-8', - fields: { iter: '12', f: '3.4e-5', inf_pr: '1.2e-8' }, - }) - }) - it('classifies DONE as done', () => { - expect(classifyLine('DONE fidelity=0.9999', 'stdout').kind).toBe('done') - }) - it('AMICODE_ITER on stderr is just log (convention is stdout-only)', () => { - expect(classifyLine('AMICODE_ITER iter=1', 'stderr').kind).toBe('log') - }) - it('malformed tokens are skipped, never throw', () => { - const ev = classifyLine('AMICODE_ITER iter=1 ====garbage', 'stdout') - expect(ev.kind).toBe('iter') - }) - it('everything else is log with stream tagged', () => { - expect(classifyLine('Ipopt banner', 'stderr')).toEqual({ - kind: 'log', stream: 'stderr', line: 'Ipopt banner', - }) - }) -}) + kind: "iter", + raw: "AMICODE_ITER iter=12 f=3.4e-5 inf_pr=1.2e-8", + fields: { iter: "12", f: "3.4e-5", inf_pr: "1.2e-8" }, + }); + }); + it("classifies DONE as done", () => { + expect(classifyLine("DONE fidelity=0.9999", "stdout").kind).toBe("done"); + }); + it("AMICODE_ITER on stderr is just log (convention is stdout-only)", () => { + expect(classifyLine("AMICODE_ITER iter=1", "stderr").kind).toBe("log"); + }); + it("malformed tokens are skipped, never throw", () => { + const ev = classifyLine("AMICODE_ITER iter=1 ====garbage", "stdout"); + expect(ev.kind).toBe("iter"); + }); + it("everything else is log with stream tagged", () => { + expect(classifyLine("Ipopt banner", "stderr")).toEqual({ + kind: "log", + stream: "stderr", + line: "Ipopt banner", + }); + }); +}); diff --git a/packages/amico-run/test/verify.test.ts b/packages/amico-run/test/verify.test.ts index 01a5d70b..2b25c1e0 100644 --- a/packages/amico-run/test/verify.test.ts +++ b/packages/amico-run/test/verify.test.ts @@ -1,27 +1,27 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest" -import { mkdtempSync, mkdirSync, existsSync, readFileSync, writeFileSync, chmodSync, rmSync } from "node:fs" -import { tmpdir } from "node:os" -import { join } from "node:path" -import { runVerification } from "../src/verify.js" -import { readToml } from "./helpers.js" -import type { AuthoringConfig } from "../src/authoring.js" -import type { SpecStamp } from "../src/types.js" +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, mkdirSync, existsSync, readFileSync, writeFileSync, chmodSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runVerification } from "../src/verify.js"; +import { readToml } from "./helpers.js"; +import type { AuthoringConfig } from "../src/authoring.js"; +import type { SpecStamp } from "../src/types.js"; -let root: string +let root: string; beforeEach(() => { - root = mkdtempSync(join(tmpdir(), "amico-verify-")) -}) + root = mkdtempSync(join(tmpdir(), "amico-verify-")); +}); afterEach(() => { - delete process.env.AMICO_VERIFY_RUNNER - rmSync(root, { recursive: true, force: true }) -}) + delete process.env.AMICO_VERIFY_RUNNER; + rmSync(root, { recursive: true, force: true }); +}); // A fake harness = a node script that writes verification.toml into argv[1] (the run dir). function fakeHarness(name: string, body: string): string { - const p = join(root, name) - writeFileSync(p, `#!/usr/bin/env node\n${body}\n`) - chmodSync(p, 0o755) - return p + const p = join(root, name); + writeFileSync(p, `#!/usr/bin/env node\n${body}\n`); + chmodSync(p, 0o755); + return p; } function authoring(harness?: string): AuthoringConfig { @@ -30,47 +30,47 @@ function authoring(harness?: string): AuthoringConfig { support_set: [], verify_harness: harness, verify_tolerance: 0.01, - } + }; } -const FREE_SPEC: SpecStamp = { canonical: "{}", tier: "free" } +const FREE_SPEC: SpecStamp = { canonical: "{}", tier: "free" }; describe("runVerification", () => { it("harness writes verification.toml → left intact", async () => { - const runDir = join(root, "run") - mkdirSync(runDir) + const runDir = join(root, "run"); + mkdirSync(runDir); const harness = fakeHarness( "h.js", `const fs=require('fs'),p=require('path');fs.writeFileSync(p.join(process.argv[2],'verification.toml'),'schema_version = "1"\\nagree = true\\nfidelity_rerolled = 0.998\\n')`, - ) - process.env.AMICO_VERIFY_RUNNER = "node" - await runVerification(runDir, FREE_SPEC, authoring(harness)) - const v = readToml(join(runDir, "verification.toml")) - expect(v.agree).toBe(true) - expect(v.fidelity_rerolled).toBe(0.998) - }) + ); + process.env.AMICO_VERIFY_RUNNER = "node"; + await runVerification(runDir, FREE_SPEC, authoring(harness)); + const v = readToml(join(runDir, "verification.toml")); + expect(v.agree).toBe(true); + expect(v.fidelity_rerolled).toBe(0.998); + }); it("missing harness path → fallback verification.toml agree=false + error", async () => { - const runDir = join(root, "run") - mkdirSync(runDir) - await runVerification(runDir, FREE_SPEC, authoring(join(root, "nonexistent.jl"))) - const v = readToml(join(runDir, "verification.toml")) - expect(v.agree).toBe(false) - expect(String(v.error)).toMatch(/harness/) - }) + const runDir = join(root, "run"); + mkdirSync(runDir); + await runVerification(runDir, FREE_SPEC, authoring(join(root, "nonexistent.jl"))); + const v = readToml(join(runDir, "verification.toml")); + expect(v.agree).toBe(false); + expect(String(v.error)).toMatch(/harness/); + }); it("harness exits nonzero WITHOUT writing → fallback agree=false + error", async () => { - const runDir = join(root, "run") - mkdirSync(runDir) - const harness = fakeHarness("h.js", `process.exit(3)`) - process.env.AMICO_VERIFY_RUNNER = "node" - await runVerification(runDir, FREE_SPEC, authoring(harness)) - const v = readToml(join(runDir, "verification.toml")) - expect(v.agree).toBe(false) - expect(existsSync(join(runDir, "verification.toml"))).toBe(true) - }) + const runDir = join(root, "run"); + mkdirSync(runDir); + const harness = fakeHarness("h.js", `process.exit(3)`); + process.env.AMICO_VERIFY_RUNNER = "node"; + await runVerification(runDir, FREE_SPEC, authoring(harness)); + const v = readToml(join(runDir, "verification.toml")); + expect(v.agree).toBe(false); + expect(existsSync(join(runDir, "verification.toml"))).toBe(true); + }); it("no harness configured at all → fallback agree=false (never verification-less)", async () => { - const runDir = join(root, "run") - mkdirSync(runDir) - await runVerification(runDir, FREE_SPEC, authoring(undefined)) - expect(existsSync(join(runDir, "verification.toml"))).toBe(true) - expect(readToml(join(runDir, "verification.toml")).agree).toBe(false) - }) -}) + const runDir = join(root, "run"); + mkdirSync(runDir); + await runVerification(runDir, FREE_SPEC, authoring(undefined)); + expect(existsSync(join(runDir, "verification.toml"))).toBe(true); + expect(readToml(join(runDir, "verification.toml")).agree).toBe(false); + }); +}); diff --git a/packages/extension/.DS_Store b/packages/extension/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..ea98653f20ef3694df3968f1b1248258a18199e1 GIT binary patch literal 10244 zcmeHNO=uHA6n@jBX{)7H57L|4oJ0`X;=w`>u|xy~krph1&?ZfZZIf(Ff9NrS1+f<| z9@Sz{uO3A&DuM^4AkvE>_T)*?o_g^2y_w1Go83+7MUXH9yKgh^z5TxT?d+s8DFBGk zrQ9HZ0|1?PknQcpQ$!;_9ii5ydq&U-%!k48X}4IGz3riB+hG;33RnfK0#*U5!2h5C zerHqb+^eiTTLr8FRsl}|njaiI$fmTdDl4ZBJjo>h+BkO0hS$Axf!5JBrEOJN^k5Sz z6|G7YdWj)aI`%s{E~RZ%S(Oe#FFu4u7J7vuBs$*Tk>Max%G$G4z$y?{fY$CY$igg? z3VFVMA3NiFif%fcEx1|i>Abq#er)91%M;w2YvL_m;&+|GG<@T8Fat$!4McGu$$cDi z_4iQv*^b2Ya*M8y8s#@|)hrN`xXT7A^sOSQ_HfMmKQ1O~-*uhDcbOxGy#^i()a#su z1z0o?hbQ;wI;D?e-hO`XW9Fw7{C##IUNvgPZfR< z)yakM9cN79dtQ#e_LSuE%F>D}r6 ze)X&x-kU4+b@S~Z{cIb?8@%BL@!p4A%gD0?)0+QL{9Hix`Oy3!)?&M7@ETUS9@od) zE;ziBzt1x2QR5(i?t9}SIS3?8tBFwh}+R8Ye*ECxrx z_3dy3=dy9v;}Wa-K}UvV7ZeG)DQ;=*ho+f!KzhmqGSq6|f4lrUD|K9#4;AZF+A# zR3+`TJ9tdtL5eumAr5)ugrD literal 0 HcmV?d00001 diff --git a/packages/extension/AGENTS.md b/packages/extension/AGENTS.md index c12f94b2..c0b66fad 100644 --- a/packages/extension/AGENTS.md +++ b/packages/extension/AGENTS.md @@ -13,7 +13,7 @@ and the Run Inspector renders the live solve. ## Voice -You're a *friend* — "Amico" is Italian for it — who's done pulse design with this +You're a _friend_ — "Amico" is Italian for it — who's done pulse design with this researcher for years. You know the toolchain, the failure modes, the literature. Sound like it — not a generic assistant. @@ -29,8 +29,8 @@ Sound like it — not a generic assistant. - **Honest to a fault.** Charm never covers for a caveat. Say what isn't wired, what's untrusted (a `free`-tier fidelity is untrusted until the re-rollout agrees — and you say so), and what might blow up. -- **Italian, sparing.** A *bravo* on a clean solve, an *andiamo* to kick off, - *piano piano* when it's grinding — seasoning, never costume. One touch, not five. +- **Italian, sparing.** A _bravo_ on a clean solve, an _andiamo_ to kick off, + _piano piano_ when it's grinding — seasoning, never costume. One touch, not five. - **Atomic.** One question per turn, readable in two seconds. ## Workflow (this is the whole job) @@ -69,8 +69,8 @@ workspace owns `solve.jl` — never author in `/tmp`). ``` 5. **Assemble `~/.amico/problems//solvespec.json`**: `{schema_version:"2", script_path:"…/solve.jl", lab_id:"default", - executor:"local", tier:"", env:{kind, project?}, source:, - hashes:{system_hash, formulation_hash}}` — read the hashes from the LAST +executor:"local", tier:"", env:{kind, project?}, source:, +hashes:{system_hash, formulation_hash}}` — read the hashes from the LAST matching events in `~/.amico/problems//events.jsonl` (the `hash` field on the newest `system`/`formulation` events). 6. **Launch through the gate, detached.** Pass `--project` matching the tier's @@ -189,7 +189,7 @@ Stages, in order: Piccolissimo **free-phase CZ path**), honest about depth; 3. no skill matches → **offer free-tier from-scratch authoring anyway** (public packages, **unvetted**, re-rollout-verified). "No template" is never a decline. - Show the model Hamiltonian when you know it. + Show the model Hamiltonian when you know it. - transmon: $\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, @@ -237,7 +237,7 @@ Stages, in order: **Transmon: single qubit only via the vetted template.** The bundled vetted template builds ONE `TransmonSystem` (scalar `ω`/`δ`) and embeds a single-qubit target: X, Y, Z, H, S, T, √X, and arbitrary single-qubit unitaries. Multi-qubit -*transmon* gates (CNOT, CZ, iSWAP on transmons) have no vetted template or +_transmon_ gates (CNOT, CZ, iSWAP on transmons) have no vetted template or exemplar — but they are **not declined**: they route through the **free-tier** offer (author from scratch, **unvetted**, re-rollout-verified), with that caveat stated up front. (Piccolo's `MultiTransmonSystem` exists; a from-scratch coupled @@ -247,6 +247,7 @@ CZ is the exception:** it resolves to the composed `rydberg-cz` exemplar lists it — honestly caveated (see the PLATFORM stage). **Choose parameters for the regime** (the defaults converge to F > 0.999): + - `levels`: 3 (default) or 4 for more leakage realism. **Avoid 5+** — added levels worsen conditioning and leakage and inflate solve cost, so convergence degrades; if the user insists, warn it may not converge. @@ -279,6 +280,7 @@ script, running with cwd = the run dir, must emit: trajectory from the primal, and prints the lines. **A script that skips these lines gets a dead live plot** — the Inspector sits on "warming up" until completion, then shows a no-pulse-data hint. + - `iter_.png` every few iterations — **archival/publication artifact** (`plot_pulse` is canonical there); the Inspector no longer displays PNGs. See the per-iter plotting idiom below — **`LivePulsePlotCallback`** once the bundled @@ -337,6 +339,7 @@ correct loader in this Piccolo. ## Julia project The Julia project to pass as `--project` is: + **{{JULIA_PROJECT}}**. Always pass it. ## Style diff --git a/packages/extension/CONTRACT.md b/packages/extension/CONTRACT.md index 54147ac4..5ed4f576 100644 --- a/packages/extension/CONTRACT.md +++ b/packages/extension/CONTRACT.md @@ -13,19 +13,19 @@ A run lives at `~/.amico/runs///`, where `runId` is `run.toml` **first** and `FINISHED` **last**; the script (cwd = the run dir) emits the rest. -| Artifact | Writer | Contents | -|---|---|---| -| `run.toml` | amico-run (first) | `schema_version = "1"`, snake_case keys: `run_id`, `lab`, `lab_id`, `script_path`, `created_at`, `orchestrator_version`, and a `[julia]` table (`binary`, optional `project`/`sysimage`). | -| `run.log` | amico-run (stdout tee) | One `AMICODE_ITER iter= f= inf_pr=<…> inf_du=<…>` line per Ipopt iteration (drives the live stats row), plus a final `DONE fidelity=<…>` line and any Julia traceback. | -| `iter_.png` | script | Per-iteration pulse/fidelity plot. `N` is the iteration with **unbounded digits** (`iter_0`, `iter_10`, … `iter_0060`). The inspector globs `iter_*.png`. | -| `result.toml` | script (atomic) | Written `result.toml.tmp` then renamed. At least `fidelity` (float) and `iterations` (int); `wall_seconds` optional. | -| `FINISHED` | amico-run (last, terminal) | `status = "completed" | "failed" | "aborted"` and `exit_code` (int). Its presence is the **only** completion signal — the inspector fires `onFinished` solely on a valid `FINISHED`, so a killed solve shows "running", never a false success. | +| Artifact | Writer | Contents | +| -------------- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `run.toml` | amico-run (first) | `schema_version = "1"`, snake_case keys: `run_id`, `lab`, `lab_id`, `script_path`, `created_at`, `orchestrator_version`, and a `[julia]` table (`binary`, optional `project`/`sysimage`). | +| `run.log` | amico-run (stdout tee) | One `AMICODE_ITER iter= f= inf_pr=<…> inf_du=<…>` line per Ipopt iteration (drives the live stats row), plus a final `DONE fidelity=<…>` line and any Julia traceback. | +| `iter_.png` | script | Per-iteration pulse/fidelity plot. `N` is the iteration with **unbounded digits** (`iter_0`, `iter_10`, … `iter_0060`). The inspector globs `iter_*.png`. | +| `result.toml` | script (atomic) | Written `result.toml.tmp` then renamed. At least `fidelity` (float) and `iterations` (int); `wall_seconds` optional. | +| `FINISHED` | amico-run (last, terminal) | `status = "completed" | "failed" | "aborted"`and`exit_code`(int). Its presence is the **only** completion signal — the inspector fires`onFinished`solely on a valid`FINISHED`, so a killed solve shows "running", never a false success. | Two convenience files live at the **lab runs root** (`~/.amico/runs//`): -| File | Writer | Contents | -|---|---|---| -| `index` | amico-run (`appendIndex`) | Append-only, tab-separated `\t\t` per run. | +| File | Writer | Contents | +| -------- | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| `index` | amico-run (`appendIndex`) | Append-only, tab-separated `\t\t` per run. | | `latest` | amico-run (`updateLatest`) | Symlink → the most recent ``; written via temp-then-rename so the watcher sees an atomic swing. The inspector follows `latest`. | ## Frozen schemas diff --git a/packages/extension/DEMO_CHECKLIST.md b/packages/extension/DEMO_CHECKLIST.md index 47eff76e..10792754 100644 --- a/packages/extension/DEMO_CHECKLIST.md +++ b/packages/extension/DEMO_CHECKLIST.md @@ -8,7 +8,7 @@ net; rows 4–6 are the live run. - [ ] **Install clean** — followed `RUNBOOK.md` end-to-end on the target machine; total time recorded below (target ≤ 60 min). - [ ] **Healthcheck green** — `node packages/extension/scripts/healthcheck.mjs` exits `0` (julia + pinned Piccolo project · opencode `/event` · `amico-run` · Bedrock creds). -- [ ] **Fallback armed** — Command Palette → **"Amicode: Replay demo run"** stages the bundled solve and the Run Inspector renders it (iter frames + final fidelity + promote prompt), with **no Julia, no opencode, no creds**. Confirm this works *before* relying on the live path. +- [ ] **Fallback armed** — Command Palette → **"Amicode: Replay demo run"** stages the bundled solve and the Run Inspector renders it (iter frames + final fidelity + promote prompt), with **no Julia, no opencode, no creds**. Confirm this works _before_ relying on the live path. ## Live run @@ -25,11 +25,11 @@ net; rows 4–6 are the live run. **Recorded timings (fill in at the dry-run):** -| Step | Time | -|---|---| -| Julia install | | -| `install.sh` (instantiate + precompile + VSIX) | | -| Healthcheck | | -| First live solve (cold) | | -| Replay fallback | instant | -| **Total** | | +| Step | Time | +| ---------------------------------------------- | ------- | +| Julia install | | +| `install.sh` (instantiate + precompile + VSIX) | | +| Healthcheck | | +| First live solve (cold) | | +| Replay fallback | instant | +| **Total** | | diff --git a/packages/extension/DISTILLER.md b/packages/extension/DISTILLER.md index 40139a2c..dd80de46 100644 --- a/packages/extension/DISTILLER.md +++ b/packages/extension/DISTILLER.md @@ -16,6 +16,7 @@ first run `KNOWLEDGE.md` / `problems/` may be empty or absent — that just mean no cards exist yet; create what you need under `amicode/`. Your input is ONE JSON job object (the message you were invoked with): + - `{"kind":"run","run_id":"r...","runs_root":"...","vault":"...","ops":"..."}` - `{"kind":"sweep","session_ids":[...],"vault":"...","ops":"..."}` — distill these sessions - `{"kind":"onboarding","vault":"...","ops":"..."}` — materialize the profile @@ -69,10 +70,12 @@ Your input is ONE JSON job object (the message you were invoked with): `run.toml` may carry `session_id` and `workspace` (newer runs — prefer them). Otherwise recover: + ``` sqlite3 "file:...opencode.db?mode=ro" \ "SELECT DISTINCT session_id FROM part WHERE data LIKE '%%';" ``` + ALL matched sessions are contributing `sessions:`. The **launching** session is the one whose matching part contains the launch command itself (`amico-run`); fallback: the earliest mention by `part.time_created`. The workspace is the @@ -81,6 +84,7 @@ session's `amicode_*` records. A run that joins to nothing still gets a card (note "orphan run — no session recovered"); never drop it silently. Useful transcript queries (ids are 30 chars — never truncate them): + ``` -- substantive check / entity writes and launches for a session: SELECT json_extract(data,'$.tool') FROM part WHERE session_id='' @@ -99,19 +103,19 @@ SELECT json_extract(data,'$.text') FROM part WHERE session_id='' type: amicode-problem slug: x-gate-transmon platform: transmon -problem_kind: gate_synthesis # gate_synthesis | state_prep -target: X # gate name or state name (e.g. cat-state) -status: solved # solved | attempted | failed -best_fidelity: 0.99995 # ONLY from result.toml; omit if none -best_run: r20260703-095831Z-e5b7 # omit if none -pulse_ref: pulses/x-gate-transmon-v1 # null if no successful pulse +problem_kind: gate_synthesis # gate_synthesis | state_prep +target: X # gate name or state name (e.g. cat-state) +status: solved # solved | attempted | failed +best_fidelity: 0.99995 # ONLY from result.toml; omit if none +best_run: r20260703-095831Z-e5b7 # omit if none +pulse_ref: pulses/x-gate-transmon-v1 # null if no successful pulse solve_count: 8 first_seen: 2026-07-03 last_seen: 2026-07-04 sessions: [ses_..., ses_...] -sys_params: # STRUCTURED regime scalars (L1 §2.1.1) — - levels: 3 # the deterministic "high"-confidence gate. - drive_max: 0.2 # Emit the platform's gating scalars: +sys_params: # STRUCTURED regime scalars (L1 §2.1.1) — + levels: 3 # the deterministic "high"-confidence gate. + drive_max: 0.2 # Emit the platform's gating scalars: # transmon: levels (int), drive_max (float) # cavity/bosonic: fock_cutoff (int), chi (float), alpha or fock_index # atoms: levels, rabi_max, delta_max, distance @@ -122,15 +126,19 @@ sys_params: # STRUCTURED regime scalars (L1 §2.1.1) # on ## System + ## Formulation + ## History + - solves , , F ∈ [, ]. ## Lessons + - ``` @@ -175,15 +183,17 @@ New `-v` ONLY when fidelity strictly improves on the card's Entity → card mapping (`/onboarding/events.jsonl`; replay in order, later entries win — update-in-place, never duplicate): + - `profile` entity (`name`,`role`,`org`,`platforms`,`goals`) → `PROFILE.md`: ```markdown # Profile — + - Role: - Org / lab: - Platforms: - Environment: [](environment/.md) — -- Devices: [](devices/.md) # one line per device, if any +- Devices: [](devices/.md) # one line per device, if any - Goals: - Onboarded: (re-run onboarding to update) ``` @@ -240,6 +250,7 @@ params, write a **thin card** (platform + script pointer + "params not extracted") — never skip the demo, never fabricate. Demo card frontmatter (mirror the problem card + these): + ``` type: amicode-demo slug: stanford-bosonics-cat @@ -254,6 +265,7 @@ sys_params: { fock_cutoff: 20, chi: 0.0000328, alpha: 2 } # if readable ``` DEMOS.md line: + ``` - [stanford-bosonics-cat](demos/stanford-bosonics-cat.md) — cavity state_prep cat-state, N_fock=20, script scripts/optimize_cat_alpha2.jl ``` @@ -265,4 +277,4 @@ but never merge with the user's own solves (source distinguishes them). ## Finishing a job 1. Write the files. 2. Pathspec-scoped commit (Hard rule 1). 3. Final message: -one line, e.g. `distilled r...-e5b7 → x-gate-transmon (updated, F=0.99995, v1 banked)`. + one line, e.g. `distilled r...-e5b7 → x-gate-transmon (updated, F=0.99995, v1 banked)`. diff --git a/packages/extension/RUNBOOK.md b/packages/extension/RUNBOOK.md index bad50a6f..5b38a67e 100644 --- a/packages/extension/RUNBOOK.md +++ b/packages/extension/RUNBOOK.md @@ -3,16 +3,17 @@ Target: a clean macOS/Linux machine → Amicode demo-ready. Times are estimates; the dominant cost is the first Julia precompile. -| # | Step | ~Time | -|---|------|------| -| 1 | Install Julia: `curl -fsSL https://install.julialang.org \| sh` (then restart your shell) | 5 min | -| 2 | Get the VSIX: in the amicode repo, `pnpm install && pnpm --filter amicode-v2 package` | 5 min | -| 3 | `bash packages/extension/scripts/install.sh` — instantiates the pinned Julia project (precompiles) + installs the VSIX + writes `~/.amico/lab.toml` | 15–25 min | -| 4 | Configure the LLM **through opencode** (amico reads opencode's resolution; it stores no key of its own). Give opencode a provider credential via any path it supports — the simplest is a provider API key in the environment (e.g. `export ANTHROPIC_API_KEY=sk-ant-…`), or `~/.config/opencode` / `opencode auth login`. Then select a matching model in `~/.config/opencode/opencode.jsonc`, e.g. `"model":"anthropic/claude-sonnet-4-6"` (Bedrock: `"model":"amazon-bedrock/us.anthropic.claude-sonnet-4-6"` + `"provider":{"amazon-bedrock":{"region":"us-east-1"}}` and AWS creds in env/`~/.aws`). The healthcheck + chat confirm a provider resolves via opencode's live `/config/providers`. | 5 min | -| 5 | `node packages/extension/scripts/healthcheck.mjs` → expect all ✓, exit 0 | 2 min | -| 6 | Open VS Code → Amicode chat → run a test gate; confirm the Run Inspector renders | 10 min | +| # | Step | ~Time | +| --- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | +| 1 | Install Julia: `curl -fsSL https://install.julialang.org \| sh` (then restart your shell) | 5 min | +| 2 | Get the VSIX: in the amicode repo, `pnpm install && pnpm --filter amicode-v2 package` | 5 min | +| 3 | `bash packages/extension/scripts/install.sh` — instantiates the pinned Julia project (precompiles) + installs the VSIX + writes `~/.amico/lab.toml` | 15–25 min | +| 4 | Configure the LLM **through opencode** (amico reads opencode's resolution; it stores no key of its own). Give opencode a provider credential via any path it supports — the simplest is a provider API key in the environment (e.g. `export ANTHROPIC_API_KEY=sk-ant-…`), or `~/.config/opencode` / `opencode auth login`. Then select a matching model in `~/.config/opencode/opencode.jsonc`, e.g. `"model":"anthropic/claude-sonnet-4-6"` (Bedrock: `"model":"amazon-bedrock/us.anthropic.claude-sonnet-4-6"` + `"provider":{"amazon-bedrock":{"region":"us-east-1"}}` and AWS creds in env/`~/.aws`). The healthcheck + chat confirm a provider resolves via opencode's live `/config/providers`. | 5 min | +| 5 | `node packages/extension/scripts/healthcheck.mjs` → expect all ✓, exit 0 | 2 min | +| 6 | Open VS Code → Amicode chat → run a test gate; confirm the Run Inspector renders | 10 min | ## Troubleshooting (healthcheck failures) + - `✗ julia+project` → re-run `install.sh`; check `julia --version`. - `✗ opencode /event` → `pnpm --filter amicode-v2 fetch:opencode`; re-run. - `✗ amico-run` → `pnpm -r build` (stages `bin/`) or reinstall the VSIX. diff --git a/packages/extension/TESTING.md b/packages/extension/TESTING.md index 5bb9c1b1..abb3d6cc 100644 --- a/packages/extension/TESTING.md +++ b/packages/extension/TESTING.md @@ -42,7 +42,7 @@ restarts reuse it. **Run Inspector** pops with the live pulse, expect **F ≥ 0.999** in ~1–2 min warm. 4. **Fast path** — new session, type "optimize an X gate on my transmon, defaults" — should skip the interview and launch directly. -5. **Rydberg** — pick "neutral-atom Rydberg" in the interview: expect the *honest scope* +5. **Rydberg** — pick "neutral-atom Rydberg" in the interview: expect the _honest scope_ behavior (System recorded, formulation captured for follow-up — no dead reckoning). An **experimental** CZ template exists (`templates/solve_rydberg_cz.jl`, QuEra gate-zone params, public-Piccolo-only) but is NOT yet vetted — its first NLP iteration is diff --git a/packages/extension/demo/run/result.toml b/packages/extension/demo/run/result.toml index d7c71b6c..68a8fe60 100644 --- a/packages/extension/demo/run/result.toml +++ b/packages/extension/demo/run/result.toml @@ -4,6 +4,8 @@ schema_version = "1" wall_seconds = 109.01594400405884 [params] +system = "transmon" +gate = "X" levels = 3 drive_max = 0.2 T = 10.0 diff --git a/packages/extension/demo/run/run.log b/packages/extension/demo/run/run.log index 6606c166..b7c8128c 100644 --- a/packages/extension/demo/run/run.log +++ b/packages/extension/demo/run/run.log @@ -58,7 +58,7 @@ QuantumControlProblem Hint: show_problem(qcp; detail=:full) for pulse plot + sparsity -AMICODE_PULSE_META drives=2 knots=50 labels="a_1","a_2" bounds=-0.2:0.2,-0.2:0.2 +AMICODE_PULSE_META drives=2 knots=50 labels="u_1","u_2" bounds=-0.2:0.2,-0.2:0.2 AMICODE_ITER iter=0 f=7.930693e+01 inf_pr=3.306e+00 inf_du=4.344e-01 AMICODE_PULSE iter=0 dt=0.204082 a=0.00122269,0.079988,0.0285582,0.0540801,0.159005,-0.163859,0.10975,0.0792598,0.225433,-0.0367432,0.16558,0.14032,0.0462271,0.108334,0.179825,-0.0977399,0.165655,0.246999,-0.0193538,-0.211037,-0.174111,-0.125857,-0.221001,0.215075,-0.0648649,-0.127936,0.127302,0.00942714,-0.00511523,0.0107351,0.149222,0.00920709,-0.024628,-0.0666657,-0.0586522,-0.157342,-0.028419,0.0810429,-0.150415,-0.037007,-0.00844921,-0.0615253,0.176932,-0.0139109,0.138256,0.0532826,0.0636576,-0.0740906,-0.00579776,-0.0402367;-0.0235979,-0.00903938,0.0218624,-0.0386896,0.246838,0.241264,0.0996578,0.17691,0.252771,-0.145362,0.0510349,0.0378336,0.0339014,-0.0113967,0.109632,0.0389645,-0.0589498,-0.0433134,-0.170006,-0.0632173,0.116623,-0.0892016,0.11167,0.168127,0.138323,0.0396419,-0.069816,0.0302567,0.0163749,-0.102325,-0.0864511,0.0495011,-0.0660819,0.107856,0.00929036,0.049109,0.045827,0.178588,-0.196605,-0.0222713,0.0668082,-0.074412,-0.0316369,0.0245491,-0.112651,-0.0198992,0.0551696,0.0887624,-0.010815,-0.177116 diff --git a/packages/extension/dev/pulseplot_harness/index.html b/packages/extension/dev/pulseplot_harness/index.html index 5e7190e8..45835f05 100644 --- a/packages/extension/dev/pulseplot_harness/index.html +++ b/packages/extension/dev/pulseplot_harness/index.html @@ -1,40 +1,68 @@ - + - - - pulseplot harness (#66) - - - - - -

- - - - - -
-
- - + body { + --vscode-foreground: #333; + --vscode-editor-background: #fcfcfb; + --vscode-descriptionForeground: #717171; + --vscode-charts-blue: #1a85ff; + --vscode-charts-orange: #a05a00; + --vscode-charts-purple: #8b47b7; + --vscode-charts-green: #2e7d32; + background: var(--vscode-editor-background); + color: var(--vscode-foreground); + font-family: system-ui, sans-serif; + font-size: 13px; + margin: 0; + padding: 16px; + height: 100vh; + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 12px; + } + body.dark { + --vscode-foreground: #ccc; + --vscode-editor-background: #1a1a19; + --vscode-descriptionForeground: #9d9d9d; + --vscode-charts-blue: #3794ff; + --vscode-charts-orange: #c17800; + --vscode-charts-purple: #9b6bc4; + --vscode-charts-green: #4d9e51; + } + #controls { + display: flex; + gap: 8px; + align-items: center; + } + #plot-host { + flex: 1; + display: flex; + min-height: 0; + } + #bench-out { + font-family: monospace; + font-size: 12px; + } + + + +
+ + + + + +
+
+ + diff --git a/packages/extension/dev/pulseplot_harness/main.ts b/packages/extension/dev/pulseplot_harness/main.ts index b7174a72..f77d5e97 100644 --- a/packages/extension/dev/pulseplot_harness/main.ts +++ b/packages/extension/dev/pulseplot_harness/main.ts @@ -8,7 +8,15 @@ import { pulseplot } from "../../media/ui/components/pulseplot"; -const HARNESS_META = { drives: 2, knots: 50, labels: ["a_1", "a_2"], bounds: [[-0.2, 0.2], [-0.2, 0.2]] as [number, number][] }; +const HARNESS_META = { + drives: 2, + knots: 50, + labels: ["a_1", "a_2"], + bounds: [ + [-0.2, 0.2], + [-0.2, 0.2], + ] as [number, number][], +}; const root = document.getElementById("plot-host")!; const plot = pulseplot("Harness idle — press play."); @@ -39,16 +47,24 @@ let timer: ReturnType | undefined; let iter = 0; const playBtn = document.getElementById("play")!; playBtn.addEventListener("click", () => { - if (timer) { clearInterval(timer); timer = undefined; playBtn.textContent = "▶ play"; return; } + if (timer) { + clearInterval(timer); + timer = undefined; + playBtn.textContent = "▶ play"; + return; + } plot.meta(HARNESS_META); playBtn.textContent = "⏸ pause"; timer = setInterval(() => { plot.update(syntheticRecord(iter++)); if (iter > 60) iter = 0; - }, 200); // the host's 5 Hz cadence + }, 200); // the host's 5 Hz cadence }); -document.getElementById("clear")!.addEventListener("click", () => { plot.clear(); iter = 0; }); +document.getElementById("clear")!.addEventListener("click", () => { + plot.clear(); + iter = 0; +}); // --- AC8 budget: median + p95 of component update at fixture scale document.getElementById("bench")!.addEventListener("click", () => { @@ -61,7 +77,9 @@ document.getElementById("bench")!.addEventListener("click", () => { times.push(performance.now() - t0); } times.sort((a, b) => a - b); - const med = times[150].toFixed(3), p95 = times[285].toFixed(3), max = times[299].toFixed(3); + const med = times[150].toFixed(3), + p95 = times[285].toFixed(3), + max = times[299].toFixed(3); const verdict = times[285] <= 16 ? "PASS (≤16ms)" : "FAIL (>16ms)"; document.getElementById("bench-out")!.textContent = `update() over 300 frames @ 2×50: median ${med}ms · p95 ${p95}ms · max ${max}ms → ${verdict}`; @@ -69,24 +87,34 @@ document.getElementById("bench")!.addEventListener("click", () => { }); // --- optional recorded replay -fetch("./pulse-events.json").then((r) => (r.ok ? r.json() : undefined)).then((events?: Array>) => { - if (!events) return; - const btn = document.createElement("button"); - btn.textContent = "▶ replay recording"; - btn.addEventListener("click", () => { - if (timer) { clearInterval(timer); timer = undefined; } - let i = 0; - timer = setInterval(() => { - const e = events[i++]; - if (!e) { clearInterval(timer!); timer = undefined; return; } - if (e.type === "pulsemeta") plot.meta(e as never); - else if (e.type === "pulse") plot.update(e as never); - }, 200); +fetch("./pulse-events.json") + .then((r) => (r.ok ? r.json() : undefined)) + .then((events?: Array>) => { + if (!events) return; + const btn = document.createElement("button"); + btn.textContent = "▶ replay recording"; + btn.addEventListener("click", () => { + if (timer) { + clearInterval(timer); + timer = undefined; + } + let i = 0; + timer = setInterval(() => { + const e = events[i++]; + if (!e) { + clearInterval(timer!); + timer = undefined; + return; + } + if (e.type === "pulsemeta") plot.meta(e as never); + else if (e.type === "pulse") plot.update(e as never); + }, 200); + }); + document.getElementById("controls")!.append(btn); }); - document.getElementById("controls")!.append(btn); -}); // --- URL-hash automation for headless-ish eyeballing: #autoplay #bench #dark if (location.hash.includes("dark")) document.body.classList.add("dark"); if (location.hash.includes("autoplay")) (document.getElementById("play") as HTMLButtonElement).click(); -if (location.hash.includes("bench")) setTimeout(() => (document.getElementById("bench") as HTMLButtonElement).click(), 800); +if (location.hash.includes("bench")) + setTimeout(() => (document.getElementById("bench") as HTMLButtonElement).click(), 800); diff --git a/packages/extension/esbuild.config.mjs b/packages/extension/esbuild.config.mjs index 6b04467e..d336deef 100644 --- a/packages/extension/esbuild.config.mjs +++ b/packages/extension/esbuild.config.mjs @@ -32,6 +32,18 @@ const targets = [ minify: false, logLevel: "info", }, + // catalog-card dev preview webview bundle (#47 scaffold) + { + entryPoints: ["src/catalog_card_webview.ts"], + bundle: true, + platform: "browser", + target: "es2022", + format: "iife", + outfile: "dist/catalog_card_webview.js", + sourcemap: true, + minify: false, + logLevel: "info", + }, // bottom-panel Run Inspector webview bundle { entryPoints: ["src/inspector_webview.ts"], diff --git a/packages/extension/julia/README.md b/packages/extension/julia/README.md index 09892424..fd59eaf9 100644 --- a/packages/extension/julia/README.md +++ b/packages/extension/julia/README.md @@ -1,3 +1,5 @@ # Pinned, vetted Julia env (Piccolo 1.19) bundled in the VSIX. + # Provisioned to ~/.amico/julia via `Pkg.instantiate()` (scripts/install.sh). + # Regenerate: re-instantiate ~/.amico/julia, then copy Project.toml + Manifest.toml here. diff --git a/packages/extension/media/brand.css b/packages/extension/media/brand.css index 9f3c55cc..fc55f1a3 100644 --- a/packages/extension/media/brand.css +++ b/packages/extension/media/brand.css @@ -13,8 +13,8 @@ :root { /* color */ - --color-accent: #FFF676; - --color-on-accent: #000000; + --color-accent: #fff676; + --color-on-accent: #000000; --color-ok: var(--vscode-testing-iconPassed); --color-fail: var(--vscode-errorForeground); --color-run: var(--vscode-progressBar-background); diff --git a/packages/extension/media/layout.css b/packages/extension/media/layout.css index 12aeb420..3b7fcb86 100644 --- a/packages/extension/media/layout.css +++ b/packages/extension/media/layout.css @@ -1,16 +1,51 @@ /* layout.css — formal layout selectors. Composition only; values from brand.css. */ -* { box-sizing: border-box; } -.stack { display: flex; flex-direction: column; gap: var(--space-md); } -.row { display: flex; align-items: center; gap: var(--space-md); } -.wrap { flex-wrap: wrap; } -.grid-fit { display: grid; grid-template-columns: repeat(auto-fit, minmax(var(--grid-min, 112px), 1fr)); gap: var(--space-sm); } +* { + box-sizing: border-box; +} +.stack { + display: flex; + flex-direction: column; + gap: var(--space-md); +} +.row { + display: flex; + align-items: center; + gap: var(--space-md); +} +.wrap { + flex-wrap: wrap; +} +.grid-fit { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(var(--grid-min, 112px), 1fr)); + gap: var(--space-sm); +} /* metric-row: content-width tiles, left-aligned, wrapping — NOT stretched to fill (that made a scalar like the iteration count occupy a huge tile). */ -.metric-row { display: flex; align-items: stretch; gap: var(--space-sm); flex-wrap: wrap; } -.grow { flex: 1; } -.push-end { margin-left: auto; } -.scroll-y { overflow-y: auto; } -.gap-xs { gap: var(--space-xs); } -.gap-sm { gap: var(--space-sm); } -.gap-lg { gap: var(--space-lg); } -.pad-lg { padding: var(--space-lg); } +.metric-row { + display: flex; + align-items: stretch; + gap: var(--space-sm); + flex-wrap: wrap; +} +.grow { + flex: 1; +} +.push-end { + margin-left: auto; +} +.scroll-y { + overflow-y: auto; +} +.gap-xs { + gap: var(--space-xs); +} +.gap-sm { + gap: var(--space-sm); +} +.gap-lg { + gap: var(--space-lg); +} +.pad-lg { + padding: var(--space-lg); +} diff --git a/packages/extension/media/ui/atoms/button.ts b/packages/extension/media/ui/atoms/button.ts index e8886dca..1febcf5f 100644 --- a/packages/extension/media/ui/atoms/button.ts +++ b/packages/extension/media/ui/atoms/button.ts @@ -2,7 +2,9 @@ import { defineStyle } from "../style"; -defineStyle("button", ` +defineStyle( + "button", + ` .btn { font-family: var(--text-font); font-size: var(--text-small); color: var(--vscode-button-secondaryForeground, var(--vscode-foreground)); background: var(--vscode-button-secondaryBackground, transparent); @@ -11,7 +13,8 @@ defineStyle("button", ` cursor: pointer; display: inline-flex; align-items: center; gap: var(--space-xs); } .btn:hover:not(:disabled) { border-color: var(--color-accent); } .btn:disabled { opacity: 0.4; cursor: default; } -`); +`, +); export interface ButtonAtom { el: HTMLButtonElement; @@ -23,6 +26,13 @@ export function button(label: string, onClick: () => void): ButtonAtom { el.className = "btn"; el.type = "button"; el.textContent = label; - el.addEventListener("click", () => { if (!el.disabled) onClick(); }); - return { el, enable: (on: boolean) => { el.disabled = !on; } }; + el.addEventListener("click", () => { + if (!el.disabled) onClick(); + }); + return { + el, + enable: (on: boolean) => { + el.disabled = !on; + }, + }; } diff --git a/packages/extension/media/ui/atoms/icon.ts b/packages/extension/media/ui/atoms/icon.ts index 2ebacf9d..8d1fa808 100644 --- a/packages/extension/media/ui/atoms/icon.ts +++ b/packages/extension/media/ui/atoms/icon.ts @@ -1,17 +1,36 @@ -// Icon atoms. The mark is the <0||0> brand ket — intentionally not native. +// Icon atoms. The mark is the Harmoniqs H-robot SILHOUETTE — same glyph + +// animation language as the fork's AmicoSpinner (spinner.tsx): the screen slit +// is knocked out via fill-rule evenodd, NO face pixels (illegible at small +// sizes — the full-face Mark is for large canvases only), currentColor so it +// rides the theme, gentle pulse-opacity, static under prefers-reduced-motion. import { defineStyle } from "../style"; -defineStyle("mark", ` - .mark { font-family: var(--text-mono); - letter-spacing: 1px; font-weight: 700; - border: var(--border-width) solid var(--border-color); - border-radius: var(--border-radius); padding: 1px 7px; } -`); +defineStyle( + "mark", + ` + .mark { display: inline-flex; align-items: center; } + .mark svg { width: 20px; height: 17.5px; display: block; color: var(--vscode-foreground); } + @media (prefers-reduced-motion: no-preference) { + .mark svg { animation: mark-pulse 1.2s ease-in-out infinite both; } + } + @keyframes mark-pulse { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; } } +`, +); + +const SVG_NS = "http://www.w3.org/2000/svg"; export function mark(): HTMLSpanElement { const el = document.createElement("span"); el.className = "mark"; - el.textContent = "<0||0>"; + const svg = document.createElementNS(SVG_NS, "svg"); + svg.setAttribute("viewBox", "0 0 64 56"); + svg.setAttribute("fill", "currentColor"); + svg.setAttribute("aria-label", "Amicode"); + const body = document.createElementNS(SVG_NS, "path"); + body.setAttribute("fill-rule", "evenodd"); + body.setAttribute("d", "M2 2h16v14h28V2h16v52H46V40H18v14H2Z M9 19h46v18H9Z"); + svg.append(body); + el.append(svg); return el; } diff --git a/packages/extension/media/ui/atoms/pill.ts b/packages/extension/media/ui/atoms/pill.ts index d7b3ab26..ff5cc9a9 100644 --- a/packages/extension/media/ui/atoms/pill.ts +++ b/packages/extension/media/ui/atoms/pill.ts @@ -1,8 +1,13 @@ // Pill atom — a status indicator. State is a class applied here, in TS. +// Variations: the default carries a status dot (live run states — the dot +// pulses while running); `dot: false` yields a plain badge (labels like +// "recommended" that describe a THING, not a process). import { defineStyle } from "../style"; -defineStyle("pill", ` +defineStyle( + "pill", + ` .pill { font-size: var(--text-small); font-weight: 600; letter-spacing: 0.5px; text-transform: uppercase; padding: var(--space-xs) var(--space-md); border-radius: var(--border-radius-round); @@ -10,25 +15,33 @@ defineStyle("pill", ` display: inline-flex; align-items: center; gap: var(--space-sm); } .pill::before { content: ""; width: var(--square-dot); height: var(--square-dot); border-radius: 50%; background: currentColor; } + .pill.no-dot::before { content: none; } .pill.idle { color: var(--color-dim); } .pill.running { color: var(--color-run); } .pill.running::before { animation: pill-pulse 1.1s ease-in-out infinite; } .pill.done { color: var(--color-ok); } .pill.failed { color: var(--color-fail); } @keyframes pill-pulse { 0%,100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.35; transform: scale(0.7); } } -`); +`, +); export type PillState = "idle" | "running" | "done" | "failed"; +export interface PillOptions { + /** Status dot before the label (default true). Badges pass false. */ + dot?: boolean; +} + export interface PillAtom { el: HTMLSpanElement; set(state: PillState, label: string): void; } -export function pill(state: PillState = "idle", label = state): PillAtom { +export function pill(state: PillState = "idle", label: string = state, opts: PillOptions = {}): PillAtom { const el = document.createElement("span"); + const variant = opts.dot === false ? " no-dot" : ""; const set = (s: PillState, l: string) => { - el.className = "pill " + s; + el.className = "pill " + s + variant; el.textContent = l; }; set(state, label); diff --git a/packages/extension/media/ui/atoms/text.ts b/packages/extension/media/ui/atoms/text.ts index f1bef606..721fca78 100644 --- a/packages/extension/media/ui/atoms/text.ts +++ b/packages/extension/media/ui/atoms/text.ts @@ -2,13 +2,16 @@ import { defineStyle } from "../style"; -defineStyle("text", ` +defineStyle( + "text", + ` .mono { font-family: var(--text-mono); } .dim { color: var(--color-dim); } .small { font-size: var(--text-small); } .label-k { font-size: var(--text-label); text-transform: uppercase; letter-spacing: 0.6px; font-weight: 600; color: var(--color-dim); } -`); +`, +); export interface TextAtom { el: HTMLSpanElement; @@ -19,5 +22,10 @@ export function text(className = "", initial = ""): TextAtom { const el = document.createElement("span"); if (className) el.className = className; el.textContent = initial; - return { el, set(t) { el.textContent = t; } }; + return { + el, + set(t) { + el.textContent = t; + }, + }; } diff --git a/packages/extension/media/ui/brand_accent.ts b/packages/extension/media/ui/brand_accent.ts new file mode 100644 index 00000000..3d998a99 --- /dev/null +++ b/packages/extension/media/ui/brand_accent.ts @@ -0,0 +1,149 @@ +// Brand accent solver — the Harmoniqs yellow, theme-calculated. +// +// #FFF676 is the canonical brand accent (brand.css). At ~96% lightness it +// sings on dark themes and vanishes on light ones, so each webview computes +// the DEPLOYED accent from the active theme at boot: hold the brand's OKLCH +// hue + chroma, and if contrast against the theme's editor background already +// meets target, ship the brand hex EXACTLY (dark themes — decision: brand- +// exact wherever physics allows); otherwise walk lightness down to the +// closest-to-brand value that passes (light themes get a deeper gold). +// --color-on-accent is picked black/white by contrast on the computed fill — +// yellow itself is never text (fills + borders only). +// +// Pure math up top (unit-tested in node); applyBrandAccent() is the DOM +// applier — sets --color-accent/--color-on-accent at :root and recomputes on +// theme switches (VS Code mutates body attributes when the theme changes). + +const BRAND_HEX = "#FFF676"; +const CONTRAST_TARGET = 3.0; // WCAG non-text UI component minimum + +type RGB = [number, number, number]; // 0..1 + +export function parseColor(s: string): RGB | undefined { + const t = s.trim(); + const hex = t.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i)?.[1]; + if (hex) { + const h = hex.length === 3 ? [...hex].map((c) => c + c).join("") : hex; + return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255) as RGB; + } + const rgb = t.match(/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/i); + if (rgb) return [+rgb[1] / 255, +rgb[2] / 255, +rgb[3] / 255] as RGB; + return undefined; +} + +const toHex = (rgb: RGB): string => + "#" + + rgb + .map((c) => + Math.round(Math.min(1, Math.max(0, c)) * 255) + .toString(16) + .padStart(2, "0"), + ) + .join("") + .toUpperCase(); + +// -- OKLCH (Björn Ottosson's OKLab) ----------------------------------------- + +const lin = (c: number): number => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)); +const gam = (c: number): number => (c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055); + +export function srgbToOklch([r, g, b]: RGB): { L: number; C: number; h: number } { + const [lr, lg, lb] = [lin(r), lin(g), lin(b)]; + const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb); + const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb); + const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb); + const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s; + const a = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s; + const bb = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s; + return { L, C: Math.hypot(a, bb), h: (Math.atan2(bb, a) * 180) / Math.PI }; +} + +export function oklchToSrgb({ L, C, h }: { L: number; C: number; h: number }): RGB { + const a = C * Math.cos((h * Math.PI) / 180); + const b = C * Math.sin((h * Math.PI) / 180); + const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3; + const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3; + const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3; + return [ + gam(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s), + gam(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s), + gam(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s), + ] as RGB; +} + +/** In-gamut conversion: reduce chroma until every channel lands in sRGB. */ +function oklchToSrgbClamped(c: { L: number; C: number; h: number }): RGB { + let C = c.C; + for (let i = 0; i < 20; i++) { + const rgb = oklchToSrgb({ ...c, C }); + if (rgb.every((v) => v >= -0.001 && v <= 1.001)) return rgb; + C *= 0.85; + } + return oklchToSrgb({ ...c, C: 0 }); +} + +// -- WCAG contrast ----------------------------------------------------------- + +export function relativeLuminance([r, g, b]: RGB): number { + return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b); +} + +export function contrast(a: RGB, b: RGB): number { + const [hi, lo] = [relativeLuminance(a), relativeLuminance(b)].sort((x, y) => y - x); + return (hi + 0.05) / (lo + 0.05); +} + +// -- The solve --------------------------------------------------------------- + +export interface BrandAccent { + /** Lines: borders, focus rings, ☑ marks — solved to ≥3:1 vs the theme bg. */ + accent: string; + /** Fills: button backgrounds — stays the brand lemon on EVERY theme (black + * text on #FFF676 is ~19:1); on light themes the component's boundary + * comes from a border in `accent`, never from darkening the fill (a + * 3:1-darkened gold passes WCAG math but reads muddy under text). */ + accentFill: string; + /** Text on accentFill, contrast-picked. */ + onAccent: string; + /** True when the LINE accent shipped as the unmodified brand hex (dark themes). */ + brandExact: boolean; +} + +export function solveBrandAccent(background: string): BrandAccent { + const bg = parseColor(background) ?? parseColor("#1e1e1e")!; + const brand = parseColor(BRAND_HEX)!; + const onAccent = contrast([0, 0, 0], brand) >= contrast([1, 1, 1], brand) ? "#000000" : "#FFFFFF"; + + if (contrast(brand, bg) >= CONTRAST_TARGET) { + return { accent: BRAND_HEX, accentFill: BRAND_HEX, onAccent, brandExact: true }; + } + // Light theme: hold brand hue+chroma, binary-search the HIGHEST lightness + // that still meets target — the closest-to-brand gold that survives. This + // is the LINE color only; the fill stays brand. + const { C, h, L: brandL } = srgbToOklch(brand); + let lo = 0.15, + hi = brandL; + for (let i = 0; i < 40; i++) { + const mid = (lo + hi) / 2; + if (contrast(oklchToSrgbClamped({ L: mid, C, h }), bg) >= CONTRAST_TARGET) lo = mid; + else hi = mid; + } + const rgb = oklchToSrgbClamped({ L: lo, C, h }); + return { accent: toHex(rgb), accentFill: BRAND_HEX, onAccent, brandExact: false }; +} + +// -- DOM applier ------------------------------------------------------------- + +/** Compute the accent from the live theme and pin it at :root; re-solve when + * VS Code swaps themes (body attributes mutate). Call once per webview boot. */ +export function applyBrandAccent(): void { + const apply = (): void => { + const bg = getComputedStyle(document.body).getPropertyValue("--vscode-editor-background"); + const { accent, accentFill, onAccent } = solveBrandAccent(bg); + document.documentElement.style.setProperty("--color-accent", accent); + document.documentElement.style.setProperty("--color-accent-fill", accentFill); + document.documentElement.style.setProperty("--color-on-accent", onAccent); + }; + apply(); + new MutationObserver(apply).observe(document.body, { attributes: true }); +} diff --git a/packages/extension/media/ui/components/catalogcard.ts b/packages/extension/media/ui/components/catalogcard.ts new file mode 100644 index 00000000..e6d15be8 --- /dev/null +++ b/packages/extension/media/ui/components/catalogcard.ts @@ -0,0 +1,287 @@ +// Catalog entry card (#47, UX2) — the atom of the catalog. Krishna's p5 +// sketch, top to bottom: chip header → pulse plot (with a quiet plot-attached +// action row — the resume hand-off, unlabeled by design) → metadata / +// pulse-data / high-level-metrics panels → model catalog (sibling chips). +// +// Data contract: `entry` is schema-true (catalog-entry.schema.json fields +// verbatim); `entry.proposed` is the clearly-separated extension block whose +// fields render with the "proposed" treatment (they are the feedback artifact +// for the Krishna field-selection questions — Jack's lane to resolve); +// `pulse` is the optional hydrated block sharing pulseplot's meta/values +// shapes (models a CatalogStore hydrating pulse_path on open). + +import { defineStyle } from "../style"; +import { text } from "../atoms/text"; +import { metric } from "./metric"; +import { chip, type ChipFields } from "./chip"; +import { pulseplot, type PulsePlotMeta, type PulsePlotRecord } from "./pulseplot"; + +defineStyle( + "catalogcard", + ` + .catalogcard { display: flex; flex-direction: column; gap: var(--space-md); + padding: var(--space-lg); min-width: 0; + background: var(--bg-box); + border: var(--border-width) solid var(--border-color); + border-radius: var(--border-radius); } + .catalogcard .cc-head { display: flex; align-items: center; gap: var(--space-md); flex-wrap: wrap; } + .catalogcard .cc-plot { min-height: 200px; display: flex; } + .catalogcard .cc-panels { display: grid; gap: var(--space-sm); + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); } + .catalogcard .cc-panel { border: var(--border-width) solid var(--border-color); + border-radius: var(--border-radius); + padding: var(--space-sm) var(--space-md); + display: flex; flex-direction: column; gap: var(--space-xs); } + .catalogcard .cc-kv { display: flex; justify-content: space-between; gap: var(--space-md); + font-size: var(--text-small); min-width: 0; } + .catalogcard .cc-kv .v { font-family: var(--text-mono); overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; } + .catalogcard .proposed { border-bottom: 1px dashed var(--color-dim); opacity: 0.75; } + .catalogcard .metric.proposed { border-style: dashed; border-bottom: var(--border-width) dashed var(--border-color-hero); opacity: 0.8; } + .catalogcard .cc-metrics { display: flex; flex-wrap: wrap; gap: var(--space-sm); } + .catalogcard .cc-metrics .metric { flex: 1 1 120px; min-width: 0; } + .catalogcard .cc-siblings { display: flex; gap: var(--space-sm); overflow-x: auto; + padding-bottom: var(--space-xs); } + .catalogcard .cc-actions { display: flex; gap: var(--space-sm); margin-left: auto; } + .catalogcard .cc-actions button { font-family: var(--text-font); font-size: var(--text-small); + padding: var(--space-xs) var(--space-md); cursor: pointer; + border: 1px solid var(--vscode-button-border, transparent); + border-radius: 2px; + color: var(--vscode-button-secondaryForeground, var(--vscode-foreground)); + background: var(--vscode-button-secondaryBackground, var(--bg-box)); } + .catalogcard .cc-actions button:hover { background: var(--vscode-button-secondaryHoverBackground, var(--bg-box)); } +`, +); + +/** Schema-true catalog-entry fields (catalog-entry.schema.json v1). */ +export interface CatalogEntry { + schema_version: string; + run_id: string; + lab_id: string; + fidelity: number; + pulse_path: string; + gate?: string; + created_at?: string; + params?: Record; + /** NOT in the schema — proposed extensions, rendered visibly marked. */ + proposed?: { + tags?: string[]; + index?: number; + iterations?: number; + wall_seconds?: number; + /** User-assigned system name — human identity over machine family. */ + system_name?: string; + }; +} + +export interface CardPulse { + meta: PulsePlotMeta; + record: PulsePlotRecord; +} + +export interface CatalogCard { + el: HTMLDivElement; +} + +/** The pulse actions — the save → tune → warm-start → promote ladder. + * Tune: refine THIS pulse (resume the chat interview with this run as + * context). Warm-Start: seed a NEW solve from this pulse's trajectory. + * Promote: send the pulse toward hardware (Intonato path; stub until then). + * Vocabulary note for the field-selection round: "promote" also means + * promote-to-catalog elsewhere (the prompt that opens this card). */ +export const PULSE_ACTIONS: ReadonlyArray<{ id: string; label: string }> = [ + { id: "tune", label: "Tune" }, + { id: "warmstart", label: "Warm-Start" }, + { id: "promote", label: "Promote" }, +]; + +export interface CatalogCardOpts { + pulse?: CardPulse; + siblings?: ChipFields[]; + /** Wired by the host; the row renders only when provided. */ + onAction?: (id: string) => void; +} + +export function catalogcard(entry: CatalogEntry, opts: CatalogCardOpts = {}): CatalogCard { + const el = document.createElement("div"); + el.className = "catalogcard"; + + // 1 — chip header + const head = document.createElement("div"); + head.className = "cc-head"; + // User-named system wins the identity slot (proposed-marked via `tag` + // styling in the chip); the derived family is the fallback. + const named = entry.proposed?.system_name; + head.append( + chip({ gate: entry.gate, system: named ?? systemDescriptor(entry.params), ...entry.proposed }).el, + text("mono small dim", entry.run_id).el, + ); + // Pulse actions live in the header, right-justified — with the identity, + // acting on the pulse it names. VS Code button styling, uniform secondary. + if (opts.onAction) { + const actions = document.createElement("div"); + actions.className = "cc-actions"; + for (const a of PULSE_ACTIONS) { + const b = document.createElement("button"); + b.textContent = a.label; + b.addEventListener("click", () => opts.onAction!(a.id)); + actions.append(b); + } + head.append(actions); + } + el.append(head); + + // 2 — pulse plot (hydrated; degrades to pulseplot's empty state) + const plotHost = document.createElement("div"); + plotHost.className = "cc-plot"; + const plot = pulseplot("Pulse not hydrated — entry carries pulse_path only."); + if (opts.pulse) { + plot.meta(opts.pulse.meta); + plot.update(opts.pulse.record); + } + plotHost.append(plot.el); + el.append(plotHost); + + // 3 — panels: metadata · pulse data · high-level metrics + const panels = document.createElement("div"); + panels.className = "cc-panels"; + panels.append( + panel("metadata", [ + kv("run", entry.run_id), + kv("system", systemDescriptor(entry.params) ?? "—"), + kv("gate", entry.gate ?? "—"), + // the user-assigned name (chip identity) vs the derived family above + ...(entry.proposed?.system_name ? [kv("name", entry.proposed.system_name, true)] : []), + ...(entry.proposed?.tags?.length ? [kv("tags", entry.proposed.tags.join(", "), true)] : []), + kv("created", entry.created_at ?? "—"), + // solver telemetry — provenance, not pulse quality (proposed fields) + ...(entry.proposed?.iterations !== undefined ? [kv("iterations", String(entry.proposed.iterations), true)] : []), + ...(entry.proposed?.wall_seconds !== undefined + ? [kv("wall", `${entry.proposed.wall_seconds.toFixed(0)}s`, true)] + : []), + ]), + panel("pulse data", [ + kv("pulse", entry.pulse_path.split("/").slice(-2).join("/")), + ...Object.entries(entry.params ?? {}).map(([k, v]) => kv(k, String(v))), + ]), + metricsPanel(entry, opts.pulse), + ); + el.append(panels); + + // 4 — model catalog: sibling chips + if (opts.siblings?.length) { + el.append(text("label-k", "model catalog").el); + const sibs = document.createElement("div"); + sibs.className = "cc-siblings"; + for (const s of opts.siblings) sibs.append(chip(s).el); + el.append(sibs); + } + + return { el }; +} + +/** Hamiltonian-based identity for the chip: the system FAMILY only + * (params.system, e.g. "transmon"). Level counts are modeling resolution, + * not identity — the same device simulated at 3 vs 4 levels is one system — + * so they stay in the pulse-data panel's params rows, not the chip. The + * real structured Hamiltonian-identity key (family + subsystem topology + + * parameters) is a Phase-3 CatalogStore schema decision. */ +function systemDescriptor(params?: Record): string | undefined { + const sys = params?.system; + return typeof sys === "string" ? sys : undefined; +} + +function panel(title: string, rows: HTMLElement[]): HTMLDivElement { + const p = document.createElement("div"); + p.className = "cc-panel"; + p.append(text("label-k", title).el, ...rows); + return p; +} + +function kv(k: string, v: string, proposed = false): HTMLDivElement { + const row = document.createElement("div"); + row.className = "cc-kv"; + row.append(text("dim", k).el, text(proposed ? "v proposed" : "v", v).el); + return row; +} + +function metricsPanel(entry: CatalogEntry, pulse?: CardPulse): HTMLDivElement { + const p = document.createElement("div"); + p.className = "cc-panel"; + p.append(text("label-k", "high-level metrics").el); + + // All four wear the same hero metric styling; provisional ones (definition + // or data source still a domain-owner decision) get a dashed border. + const heroCard = (label: string, value: string, proposed = false): HTMLDivElement => { + const m = metric(label, { variant: "hero" }); + m.value(value); + if (proposed) m.el.classList.add("proposed"); + return m.el; + }; + + const row = document.createElement("div"); + row.className = "cc-metrics"; + p.append(row); + + row.append(heroCard("fidelity", entry.fidelity.toFixed(5))); + + // Gate time — real: params.T (template units are ns). + const T = entry.params?.T; + if (typeof T === "number") row.append(heroCard("gate time", `${T} ns`)); + + // Spectral bandwidth — computed from the hydrated knots: the frequency + // containing 95% of the pulse's AC power (DC/mean removed; ZOH samples at + // 1/dt). Definitional choices (threshold, DC handling, per-drive max) are + // domain-owner calls → proposed-marked, definition in the label. Units GHz + // for the template's ns time base. + const bw = spectralBandwidth(pulse); + if (bw !== undefined) row.append(heroCard("bandwidth (95% pwr)", `${bw.toPrecision(3)} GHz`, true)); + + // Robustness — fidelity sensitivity to parameter error. Needs perturbed + // rollouts recorded at solve time; NOT derivable from saved artifacts, so + // it renders as an empty proposed card (intent, not an invented number). + row.append(heroCard("robustness", "—", true)); + + return p; +} + +/** 95%-power occupied bandwidth, max across drives: smallest f such that the + * cumulative one-sided power spectrum (DC removed) reaches 95% of total. + * Plain O(N²) DFT — knots are ≤ a few hundred points. Undefined without + * pulse data, a usable dt, or any AC power. */ +function spectralBandwidth(pulse?: CardPulse): number | undefined { + if (!pulse || !(pulse.record.dt > 0)) return undefined; + const dt = pulse.record.dt; + let worst: number | undefined; + for (const drive of pulse.record.values) { + const n = drive.length; + if (n < 2 || drive.some((v) => !Number.isFinite(v))) continue; + const mean = drive.reduce((a, b) => a + b, 0) / n; + const x = drive.map((v) => v - mean); + const half = Math.floor(n / 2); + const power: number[] = []; + for (let k = 1; k <= half; k++) { + // one-sided, DC excluded + let re = 0, + im = 0; + for (let t = 0; t < n; t++) { + const ph = (-2 * Math.PI * k * t) / n; + re += x[t] * Math.cos(ph); + im += x[t] * Math.sin(ph); + } + power.push(re * re + im * im); + } + const total = power.reduce((a, b) => a + b, 0); + if (total <= 0) continue; + let cum = 0; + for (let k = 0; k < power.length; k++) { + cum += power[k]; + if (cum >= 0.95 * total) { + const f = (k + 1) / (n * dt); // bin k+1 → frequency + if (worst === undefined || f > worst) worst = f; + break; + } + } + } + return worst; +} diff --git a/packages/extension/media/ui/components/chip.ts b/packages/extension/media/ui/components/chip.ts new file mode 100644 index 00000000..7ecd0b7e --- /dev/null +++ b/packages/extension/media/ui/components/chip.ts @@ -0,0 +1,52 @@ +// Chip component (#47) — the catalog entry's compact handle, per the p5 +// sketch: gate · system · tag · #index. Identity is HAMILTONIAN-based (a +// pulse's validity is a property of the system it was optimized against); +// the lab is provenance and lives in the card's metadata panel, not here. +// Fields the catalog-entry schema doesn't carry yet (tag, index) render with +// the "proposed" treatment — visually present, visibly not-yet-real (the +// card is the feedback artifact for exactly that field selection). + +import { defineStyle } from "../style"; +import { text } from "../atoms/text"; + +defineStyle( + "chip", + ` + .chip { display: inline-flex; align-items: center; gap: var(--space-sm); + padding: var(--space-xs) var(--space-md); + border: var(--border-width) solid var(--border-color); + border-radius: var(--border-radius-round); + background: var(--bg-box); font-size: var(--text-small); + white-space: nowrap; } + .chip .chip-gate { font-weight: 600; } + .chip .chip-sep { color: var(--color-dim); opacity: 0.6; } + .chip .proposed { border-bottom: 1px dashed var(--color-dim); opacity: 0.75; } +`, +); + +export interface ChipFields { + gate?: string; + /** System descriptor (e.g. "transmon·3lvl") — Hamiltonian-based identity. */ + system?: string; + /** User-added tags — not in the catalog-entry schema, proposed (marked). + * The quick-digest handles for hyperparameter sweeps ("high-R", "fast"). */ + tags?: string[]; + /** Not in the catalog-entry schema — proposed (marked). */ + index?: number; +} + +export interface Chip { + el: HTMLSpanElement; +} + +export function chip(f: ChipFields): Chip { + const el = document.createElement("span"); + el.className = "chip"; + const sep = (): HTMLSpanElement => text("chip-sep", "·").el; + + el.append(text("chip-gate mono", f.gate ?? "?").el); + if (f.system) el.append(sep(), text("dim", f.system).el); + for (const t of f.tags ?? []) el.append(sep(), text("proposed", t).el); + if (f.index !== undefined) el.append(sep(), text("proposed mono", `#${String(f.index).padStart(4, "0")}`).el); + return { el }; +} diff --git a/packages/extension/media/ui/components/metric.ts b/packages/extension/media/ui/components/metric.ts index 0def7e42..d4866221 100644 --- a/packages/extension/media/ui/components/metric.ts +++ b/packages/extension/media/ui/components/metric.ts @@ -3,7 +3,9 @@ import { defineStyle } from "../style"; import { text } from "../atoms/text"; -defineStyle("metric", ` +defineStyle( + "metric", + ` .metric { background: var(--bg-box); border: var(--border-width) solid var(--border-color); border-radius: var(--border-radius); padding: var(--space-sm) var(--space-md); @@ -14,7 +16,8 @@ defineStyle("metric", ` /* hero = the number that matters: accent border, larger value. */ .metric-hero { border-color: var(--border-color-hero); } .metric-hero .v { font-size: var(--text-hero); font-weight: 600; } -`); +`, +); export type MetricVariant = "counter" | "small" | "hero"; diff --git a/packages/extension/media/ui/components/pulseplot.ts b/packages/extension/media/ui/components/pulseplot.ts index 678e5420..dcb09514 100644 --- a/packages/extension/media/ui/components/pulseplot.ts +++ b/packages/extension/media/ui/components/pulseplot.ts @@ -11,7 +11,9 @@ import { defineStyle } from "../style"; import { text } from "../atoms/text"; -defineStyle("pulseplot", ` +defineStyle( + "pulseplot", + ` .pulseplot { display: flex; flex-direction: column; gap: var(--space-xs); flex: 1 1 240px; min-width: 0; min-height: 240px; background: var(--bg-plot); @@ -30,15 +32,25 @@ defineStyle("pulseplot", ` .pulseplot.pp-empty .pp-panel, .pulseplot.pp-empty .pp-axis { display: none; } .pulseplot .pp-hint { place-self: center; margin: auto; opacity: 0.55; font-style: italic; } .pulseplot:not(.pp-empty) .pp-hint { display: none; } -`); +`, +); -const MAX_KNOTS = 512; // above this, stride-decimate before rendering -const W = 1000; // viewBox coordinate space (preserveAspectRatio=none) +const MAX_KNOTS = 512; // above this, stride-decimate before rendering +const W = 1000; // viewBox coordinate space (preserveAspectRatio=none) const H = 100; -const PAD = 0.08; // y-domain padding around the bounds band +const PAD = 0.08; // y-domain padding around the bounds band -export interface PulsePlotMeta { drives: number; knots: number; labels: string[]; bounds: [number, number][] } -export interface PulsePlotRecord { iter: number; dt: number; values: number[][] } +export interface PulsePlotMeta { + drives: number; + knots: number; + labels: string[]; + bounds: [number, number][]; +} +export interface PulsePlotRecord { + iter: number; + dt: number; + values: number[][]; +} interface Panel { el: HTMLDivElement; @@ -101,15 +113,18 @@ export function pulseplot(idleHint = "No pulse data yet."): PulsePlot { const y = yScale(lo, hi); const band = svgEl("rect"); band.setAttribute("class", "pp-band"); - band.setAttribute("x", "0"); band.setAttribute("width", String(W)); + band.setAttribute("x", "0"); + band.setAttribute("width", String(W)); band.setAttribute("y", String(y(hi))); band.setAttribute("height", String(y(lo) - y(hi))); const mkLine = (cls: string, v: number): SVGLineElement => { const line = svgEl("line"); line.setAttribute("class", cls); - line.setAttribute("x1", "0"); line.setAttribute("x2", String(W)); - line.setAttribute("y1", String(y(v))); line.setAttribute("y2", String(y(v))); + line.setAttribute("x1", "0"); + line.setAttribute("x2", String(W)); + line.setAttribute("y1", String(y(v))); + line.setAttribute("y2", String(y(v))); return line; }; const limits: [SVGLineElement, SVGLineElement] = [mkLine("pp-limit", hi), mkLine("pp-limit", lo)]; @@ -124,7 +139,7 @@ export function pulseplot(idleHint = "No pulse data yet."): PulsePlot { el.append(panel); return { el: panel, svg, step, band, limits, zero, bounds: m.bounds[i] }; }); - el.append(axis); // shared time axis, bottom panel only + el.append(axis); // shared time axis, bottom panel only } function update(r: PulsePlotRecord): void { @@ -156,7 +171,8 @@ export function pulseplot(idleHint = "No pulse data yet."): PulsePlot { /** y-scale: bounds band → viewBox with PAD headroom; non-finite clamps to edge. */ function yScale(lo: number, hi: number): (v: number) => number { const pad = PAD * (hi - lo || 1); - const min = lo - pad, max = hi + pad; + const min = lo - pad, + max = hi + pad; return (v) => { const t = Number.isFinite(v) ? (v - min) / (max - min) : v > 0 ? 1 : 0; return H - Math.min(1, Math.max(0, t)) * H; diff --git a/packages/extension/media/ui/components/sparkline.ts b/packages/extension/media/ui/components/sparkline.ts index f64ed5a0..110aab90 100644 --- a/packages/extension/media/ui/components/sparkline.ts +++ b/packages/extension/media/ui/components/sparkline.ts @@ -3,9 +3,12 @@ import { defineStyle } from "../style"; -defineStyle("sparkline", ` +defineStyle( + "sparkline", + ` .sparkline { display: block; margin-top: var(--space-xs); } -`); +`, +); const SVGNS = "http://www.w3.org/2000/svg"; @@ -13,9 +16,16 @@ const SVGNS = "http://www.w3.org/2000/svg"; export function makeSparkBuffer(capacity: number) { const buf: number[] = []; return { - push(v: number) { buf.push(v); if (buf.length > capacity) buf.shift(); }, - values(): number[] { return buf.slice(); }, - reset() { buf.length = 0; }, + push(v: number) { + buf.push(v); + if (buf.length > capacity) buf.shift(); + }, + values(): number[] { + return buf.slice(); + }, + reset() { + buf.length = 0; + }, }; } @@ -27,7 +37,9 @@ export interface Sparkline { export function sparkline(capacity = 60): Sparkline { const buf = makeSparkBuffer(capacity); - const W = 120, H = 26, PAD = 2; + const W = 120, + H = 26, + PAD = 2; const svg = document.createElementNS(SVGNS, "svg") as SVGSVGElement; svg.setAttribute("viewBox", `0 0 ${W} ${H}`); svg.setAttribute("width", String(W)); @@ -43,9 +55,14 @@ export function sparkline(capacity = 60): Sparkline { // Only positive, finite objectives on a log axis; largest at top so a // converging (descending) objective reads as a descending line. const vs = buf.values().filter((v) => v > 0 && Number.isFinite(v)); - if (vs.length < 2) { poly.setAttribute("points", ""); return; } + if (vs.length < 2) { + poly.setAttribute("points", ""); + return; + } const logs = vs.map((v) => Math.log10(v)); - const lo = Math.min(...logs), hi = Math.max(...logs), span = hi - lo || 1; + const lo = Math.min(...logs), + hi = Math.max(...logs), + span = hi - lo || 1; const pts = logs.map((l, i) => { const x = PAD + (i / (logs.length - 1)) * (W - 2 * PAD); const y = PAD + (1 - (l - lo) / span) * (H - 2 * PAD); @@ -56,7 +73,13 @@ export function sparkline(capacity = 60): Sparkline { return { el: svg, - update(v: number) { buf.push(v); render(); }, - reset() { buf.reset(); render(); }, + update(v: number) { + buf.push(v); + render(); + }, + reset() { + buf.reset(); + render(); + }, }; } diff --git a/packages/extension/media/ui/views/inspector.ts b/packages/extension/media/ui/views/inspector.ts index b6e66912..52cf193c 100644 --- a/packages/extension/media/ui/views/inspector.ts +++ b/packages/extension/media/ui/views/inspector.ts @@ -1,10 +1,14 @@ -// Inspector view — pure composition of atoms/components + layout selectors. -// Owns the message protocol (runlabel / iteration / warming / completed / -// pulsemeta / pulse / ping) shared with run_inspector.ts. +// Inspector view — per-run panes (1.3) over the post-#67 native pulse protocol. +// Owns the runId-keyed message protocol shared with run_inspector.ts: +// runlabel · iteration · warming · completed · pulsemeta · pulse (+ activate/ping) +// every message carries `runId`; the view keeps ONE `panel` per runId and shows +// the ACTIVE one (host sends `activate`). A late/throttled message for a +// background run updates ITS pane only — never the visible pane's badge/plot +// (no cross-talk; #67's plot-only-pulse property preserved per pane). // -// The live pulse renders NATIVELY from pulse data (#66) — the per-iter PNGs -// remain run-dir/archival artifacts but are no longer displayed. A run whose -// log carries no pulse lines shows a hint instead of a plot. +// The live pulse renders NATIVELY from pulse data (#66); per-iter PNGs remain +// archival. Pane MARKUP is the design lane (UX4 #49) — this is the plumbing +// reshape (freeze 2: the runId-keyed protocol, not the DOM). import { defineStyle } from "../style"; import { mark } from "../atoms/icon"; @@ -17,11 +21,18 @@ import { sparkline } from "../components/sparkline"; import { controlEnablement, type ControlStatus } from "../control-state"; import { formatElapsed, computeEta, ratePerSec } from "../../../src/run_timing"; -defineStyle("inspector-view", ` +defineStyle( + "inspector-view", + ` body { margin: 0; height: 100vh; font-family: var(--text-font); font-size: var(--text-body); color: var(--vscode-foreground); } .brand { font-weight: 600; } -`); + /* Panes carry .stack (display:flex from layout.css). Use two-class selectors so + these win over .stack on specificity — not on stylesheet order. */ + .pane:not(.active) { display: none; } + .pane.active { display: flex; } +`, +); const IDLE_HINT = "No solve in progress — fire one from the Amicode chat, or run “Replay demo run”."; const WARMING_HINT = "Julia warming up — compiling the solver (~1–2 min). The pulse will stream here."; @@ -32,7 +43,18 @@ export interface InspectorView { onMessage(msg: unknown): void; } -export function createInspectorView(post: (msg: unknown) => void): InspectorView { +/** One run's pane — the β single-run view, now instanced per runId. No + * single-run globals: everything (status, plot, metrics, gotPulse) is closed + * over here, so N panes never share state. */ +interface Panel { + el: HTMLElement; + apply(msg: Record): void; + /** Hidden panes pause their 1 Hz timing ticker (review/audit #8) — the strip + * re-renders and resumes on activation. */ + setActive(active: boolean): void; +} + +function createPanel(post: (msg: unknown) => void, runId?: string): Panel { const status = pill("idle"); const runLabel = text("mono small dim"); const pulse = pulseplot(IDLE_HINT); @@ -47,9 +69,7 @@ export function createInspectorView(post: (msg: unknown) => void): InspectorView const spark = sparkline(); hero.el.append(spark.el); - /** Whether the current run has delivered pulse data — decides the - * completed-without-data hint. Reset on warming (a NEW run started). */ - let gotPulse = false; + let gotPulse = false; // per-pane: decides the completed-without-data hint const brand = document.createElement("div"); brand.className = "row gap-sm brand"; @@ -66,9 +86,9 @@ export function createInspectorView(post: (msg: unknown) => void): InspectorView // Control row — Stop / Save pulse / Open run dir. Each posts to the extension // (run_inspector.ts routes {type:"control", action} to the matching command). - const stopBtn = button("■ Stop", () => post({ type: "control", action: "stop" })); - const saveBtn = button("↓ Save pulse", () => post({ type: "control", action: "save" })); - const openBtn = button("↗ Open run dir", () => post({ type: "control", action: "open" })); + const stopBtn = button("■ Stop", () => post({ type: "control", action: "stop", runId })); + const saveBtn = button("↓ Save pulse", () => post({ type: "control", action: "save", runId })); + const openBtn = button("↗ Open run dir", () => post({ type: "control", action: "open", runId })); const controls = document.createElement("div"); controls.className = "row gap-sm wrap push-end"; controls.append(stopBtn.el, saveBtn.el, openBtn.el); @@ -77,7 +97,9 @@ export function createInspectorView(post: (msg: unknown) => void): InspectorView let hasData = false; const applyControls = () => { const e = controlEnablement(controlStatus, hasData); - stopBtn.enable(e.stop); saveBtn.enable(e.save); openBtn.enable(e.open); + stopBtn.enable(e.stop); + saveBtn.enable(e.save); + openBtn.enable(e.open); }; applyControls(); @@ -87,11 +109,19 @@ export function createInspectorView(post: (msg: unknown) => void): InspectorView let createdAtMs: number | undefined; let maxIter: number | undefined; let latestIter = 0; - const iterStamps: number[] = []; // arrival times → rate + const iterStamps: number[] = []; // arrival times → rate let tick: ReturnType | undefined; - const clearTick = () => { if (tick) { clearInterval(tick); tick = undefined; } }; + const clearTick = () => { + if (tick) { + clearInterval(tick); + tick = undefined; + } + }; const renderTiming = () => { - if (createdAtMs === undefined) { timing.set(""); return; } + if (createdAtMs === undefined) { + timing.set(""); + return; + } const parts = [`elapsed ${formatElapsed((Date.now() - createdAtMs) / 1000)}`]; const r = ratePerSec(iterStamps); if (r !== undefined) { @@ -107,23 +137,27 @@ export function createInspectorView(post: (msg: unknown) => void): InspectorView footer.append(timing.el, controls); const el = document.createElement("div"); - el.className = "stack pad-lg scroll-y"; + el.className = "pane stack pad-lg scroll-y"; el.style.height = "100vh"; el.append(topbar, pulse.el, grid, footer); return { el, - onMessage(msg: any): void { - if (!msg || typeof msg !== "object") return; + setActive(active: boolean): void { + if (!active) { + clearTick(); + return; + } + if (createdAtMs !== undefined) { + renderTiming(); + if (!tick) tick = setInterval(renderTiming, 1000); + } + }, + apply(msg: Record): void { switch (msg.type) { - case "ping": { - post({ type: "pong", seq: msg.seq, t0: msg.t0 }); - break; - } - case "runlabel": { + case "runlabel": runLabel.set(String(msg.text ?? "")); break; - } case "timing": { if (msg.terminal) { clearTick(); @@ -144,7 +178,9 @@ export function createInspectorView(post: (msg: unknown) => void): InspectorView feasibility.value((msg.eq_viol as number).toExponential(2)); optimality.value((msg.kkt_error as number).toExponential(2)); status.set("running", "running"); - controlStatus = "running"; hasData = true; applyControls(); + controlStatus = "running"; + hasData = true; + applyControls(); latestIter = msg.iter as number; iterStamps.push(Date.now()); if (iterStamps.length > 12) iterStamps.shift(); @@ -152,22 +188,20 @@ export function createInspectorView(post: (msg: unknown) => void): InspectorView spark.update(msg.f_val as number); break; } - case "warming": { - // A NEW run started but has no data yet — clear the previous run's - // plot + stats so the old pulse doesn't linger while the new solve - // compiles/warms up. + case "warming": gotPulse = false; pulse.waiting(WARMING_HINT); for (const m of metrics) m.clear(); hero.label("objective"); status.set("running", "warming up"); - controlStatus = "warming"; hasData = false; applyControls(); - iterStamps.length = 0; latestIter = 0; // new run — reset rate history + controlStatus = "warming"; + hasData = false; + applyControls(); + iterStamps.length = 0; + latestIter = 0; // new run — reset rate history spark.reset(); break; - } case "completed": { - // Authoritative terminal state from the watcher (FINISHED on disk). const ok = msg.status === "completed"; const stopped = msg.status === "stopped"; // stopped = graceful user stop (neutral, dim); completed = success; @@ -178,23 +212,81 @@ export function createInspectorView(post: (msg: unknown) => void): InspectorView hero.label("fidelity"); hero.value((msg.fidelity as number).toFixed(5)); } - if (!gotPulse) pulse.waiting(NO_DATA_HINT); // old runs / non-emitting scripts - controlStatus = (ok ? "completed" : stopped ? "stopped" : "failed"); applyControls(); + if (!gotPulse) pulse.waiting(NO_DATA_HINT); // old runs / non-emitting scripts + controlStatus = ok ? "completed" : stopped ? "stopped" : "failed"; + applyControls(); break; } - case "pulsemeta": { - pulse.meta({ drives: msg.drives, knots: msg.knots, labels: msg.labels, bounds: msg.bounds }); + case "pulsemeta": + pulse.meta({ + drives: msg.drives as number, + knots: msg.knots as number, + labels: msg.labels as string[], + bounds: msg.bounds as [number, number][], + }); break; - } - case "pulse": { - // Plot-only: never touches the status pill (a throttled record can - // legally land after "completed"; the badge must not regress). + case "pulse": + // Plot-only (never the badge): a throttled record can land after + // "completed", and for a background run must not touch the visible pane. gotPulse = true; - hasData = true; applyControls(); - pulse.update({ iter: msg.iter, dt: msg.dt, values: msg.values }); + hasData = true; + applyControls(); + pulse.update({ iter: msg.iter as number, dt: msg.dt as number, values: msg.values as number[][] }); break; - } } }, }; } + +export function createInspectorView(post: (msg: unknown) => void): InspectorView { + const panels = new Map(); + let active: string | undefined; + + // Shell holds the panes; an empty-state hint shows until the first run. + const empty = text("dim", IDLE_HINT); + empty.el.className = "pad-lg dim"; + + const el = document.createElement("div"); + el.style.height = "100vh"; + el.append(empty.el); + + const panelFor = (runId: string): Panel => { + let p = panels.get(runId); + if (!p) { + p = createPanel(post, runId); + panels.set(runId, p); + el.append(p.el); + } + return p; + }; + + const activate = (runId: string): void => { + active = runId; + empty.el.style.display = "none"; + for (const [id, p] of panels) { + p.el.classList.toggle("active", id === runId); + p.setActive(id === runId); + } + if (!panels.has(runId)) panelFor(runId).el.classList.add("active"); // pane may arrive before data + }; + + return { + el, + onMessage(msg: any): void { + if (!msg || typeof msg !== "object") return; + if (msg.type === "ping") { + post({ type: "pong", seq: msg.seq, t0: msg.t0 }); + return; + } + if (msg.type === "activate") { + if (typeof msg.runId === "string") activate(msg.runId); + return; + } + // Every other message is runId-keyed → route to that run's pane. A message + // with no runId (legacy/none) falls back to the active pane. + const runId = typeof msg.runId === "string" ? msg.runId : active; + if (!runId) return; + panelFor(runId).apply(msg as Record); + }, + }; +} diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_AMS-Regular.ttf b/packages/extension/media/vendor/katex/fonts/KaTeX_AMS-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..c6f9a5e7c03f9e64e9c7b4773a8e37ade8eaf406 GIT binary patch literal 63632 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 literal 0 HcmV?d00001 diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 b/packages/extension/media/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..0acaaff03d4bb7606de02a827aeee338e5a86910 GIT binary patch literal 28076 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 G2<`xUL{2yW literal 0 HcmV?d00001 diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Caligraphic-Regular.ttf b/packages/extension/media/vendor/katex/fonts/KaTeX_Caligraphic-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..f522294ff0f3f8c52dfdaef7ebfaa06ebfcfaabf GIT binary patch literal 12344 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 Mepdy)y#Y}F4=Hd9=l}o! literal 0 HcmV?d00001 diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 b/packages/extension/media/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..75344a1f98e37e2c631e178065854c3a81fb842f GIT binary patch literal 6908 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 CFgb+) literal 0 HcmV?d00001 diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Fraktur-Bold.ttf b/packages/extension/media/vendor/katex/fonts/KaTeX_Fraktur-Bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..4e98259c3b54076d684bf3459baeaeae8dbce97a GIT binary patch literal 19584 zcmb_^2Y6&xb@03I&CHwnrq7!`Z+b79uF=d$nxZbP`bsNlS6WFcX^Xb6cWvX2%dWv- z69WNbAc+GQ!)TXc9EV_fLLfk>p%`q6|Bn{(C43NL&42D2X}yc>Kjr(SZ*_hfb^=I~aTMCTM>F29d8Vojw8eDuNg~52bkR=!HY7U*w+z_?HBc+P}WKw6gqs zHG;K zXBzN41kc>&(y`U0Ctjxqd|*t$EhmnjJ~RJ0%`XUIa0cGzPMlgju|Dt*fNTBVq5oeK zB$0&ji=-Z?r2!*#O6^an-N}bnl1Wnk(l3%|3;LXB{wwiI@-p<$65@|4r6Q6{hO_Bx z(x4N$v=mMGGU;pzo=$ku9#6U+(sBZa&0x^sCl6WLeglba_33;Tiw{PVq6YDE^5cY* zVDV@&0Rf|F{V)a$q1V%?tDugi11ue6Or~>YQA}UteR(gp*Y&7^&9m)7SOiS#ZW?eE?EVSOnwbQ5}Oa>1NPyJ{dhVibLZc$DXw=!$?q zJjXE8fh>C6K$_Jap#y#78KYU{6mw7EH|}ospgHn6LPv4{*6LnF|;KFH|5wEV!`i__-~TFvm(4YTcUTYn6Xwb87j9`ad!EHNHS zn=z<$v`9o}V6Ds?z?@Y~#d2jf_E z5#d44AT#+%!UmWrd0O1roQU>=IQ%&SoA#$fbQa`98(7-sH$0t?prYN_9tviyuDB#2 z6{L;!y2Ibe>2oA{rjQIf14f(8>~uK00-=J}?6v9bMvuYQ26N?`Pf&GonvlUUBza?%5``VFh%d&jwOT|#A7`h!kp(6APHakcU`AtFu z{X`_Dg++cp%8@pDJK-0gFEivnDgStrL_^(75nyS-=6{fS^gE#41eT_j3$djk9`ly^ zvpZT|CPD2{`|^dSkM2G8U@R70>r%;VDyMg5z@`AqVDs@Wk%&`8g*w5JZH&F zLEm}iBL;**2}&V-W-yWw!noiE$u#-};ec^L3Lsmj9~2B*CG9Vhys2WnqeXg{AjguX zZ3A`t-BDj~Mib5Kbr<`WH8s{iQq0L8GN#Q_Q|@%oYPQ<4$*u)ew_Z;Qgi^2{!qe;| zW#|RM3-dTcSSl@%f^Wbs1B=?{&Lp$DQ-jR0#8E*odErCnmljn6eG_Ye)H~BNJst5# zpJrvHb8+uCWYkpOp}X=X?}6R{XUL~3wfRy3&EC#Tb_(R7Xcou}`VFBZ^dJwdibfz0 zA??pNGYHgO4UWpK^MVT&(dg6b!tE!~{t2mcFrr=AZv0F*`sQ~9q<>>WpH(Vezx{nd zdG;X`qXxeRv}l^$qzL_(=NWHLh<3X4dKPeIvERcEZ-*$JjcQV0-+oddZSOf-DD}L})r$qDLLH|Al^F#svu-B}{ z`UK9^#ZWY!4Rgrtih(v|@soi?b^*^z=t7!Ifkb*LAJ9u_J9(011D#WoVXCmwGkYmDHWNMam5UUGwpZ=Np0s-)T|R&{5V*Mq zJqxiKEE{VI>-HlMm)j-u=bU=-{hLB`CnYU!f1R7}IHz@@K#0+iA^=H$*+{?DYM+*E%^D5zoK7U-8?C7j4x#lK5bZYke zNACO1!i7(`eZ$#dG#XrPU#%RTJd&O$E}WYiJU?_`Y_m^TF0U7KJ>Gat>gsC;e%hKP z(hE9hAl$%Dr&T2ZetLm}yqC`H3NHg5Hs{BRxEqzw-P5xJCo1NLZ|dH6>+8Pp#>##v zV;&spj4bq|)=M|{9xSlE`JsEhbacZZY2R~}Gj{B`&1@bil#jryIf&+8L_Y*?qlQ`K zVm|(A_*m{sYjFt(q_a!B@9DFL!**LZjDE;Fq3th+is5iEgtZ|H{_&rI|1@ENaV+ZV z$H4~lC%$r#AJK4~$3)Na1HN?kD?KL(eECZfN;iV;uW zYO1n-=nk*7Cl@$hF9$to-GvtU=#i*87OA_cxzUalyVJ@_;>JYKzBjq*>vKCzI?zsA z^T$*N*&xcGogN%9y>yJkKJ1#g>?EiYFIZmSyLctpY)+kHX^~pTvfvjWGLpS7SpD7B z=#1fujU)Z>jDk-4-l?r;va!HhGqbl9pXr|Ut$&C~i3RN$q4X>#zxf?@uQ#N=?fCn; zHbNdoD~(6g{r$>-SDhux*DDo;IEquzt2oCRsap?3|v~W6SYE zJU670EnhBz5qyv|z+lisUHqW0qx%<~Q}c>Ty$PR=mQNO(ZJw~Fp(`-*bh>B%iS{{D zr0c+lL7{iptj>R2pV=*<@8}TocNtdO9Z8$+&CZ)IpiG;7;p})M#PZ zU9o{Adz(KZzf1lV(GHSKP#{SJ@&P(>ddv*4Nk|kyj*vh4dDj5?#DHKisW~h~aK0_^ zqIT28dkqDp;b2G?(P%;er@l85*ZImGL9|`7dg!jO_Ke`MxbMjWr{mh&$T8Di!-mKd z@Z0Q?xaO3#WYxG-N=91d+;> zPS%B{^`#enbZ%~B^HTiKhq^6U8>@~V^~4MI4bm%18Fi(KWu!xrQ}oP4-!?JXTUevt zI5V+2e9!sg3&XC0*<#do&k5uXBfIISV|@FY&yznOKTLE2zA+cZ*MN-@d`An%7QVpK zom2&s=_*HaJA!~3T-TBWdB~BR4N@L4EmgQ127i$;iHem^-Lty?9mV{6mreyoPKVdu zX-lZ=)a|5zRvS7zKJ?|hQ=T)DCXr+?%_x)BRJh-!)5tG$UifUmvL+i_dgt<@|TWZu5C;cFHC- z%Qa$px$^GZ0lQg$a?vpsOdK(qhUBL`$-X5NzQIy0s?|!TuWis$KHlA1){j~B9LttC zgZ*x+Hyh;YnhuXkz$qMRtB#dM_BsacRP8-uQ0by2)d?$Uu(v0`zYa8iN)3@8Ap*n@ z_}6@!Ly0p05Cfk{vbh|P1VIzX0}}Xj0NiXVBgkd7ysO(W58P>oFQA(YjneMUF@rPW zsGN$G$*LhmxMZ4mba{H^(0Is9iPQ?4(x(WL zlk%`z&gAWZb=S1m7Z?rtwZ@_TnLRz{z2lOOgEw_=*4nq9(3rB8$0R;ARUGtBzGH1+ zpl?;8(lREUN+nSUc#m@EA?anVVvCX)(->%WmxBceDHSI z!d$o}$1|WTfJKoTObWXhun+T!_RzQ*+WvlQ&-p1RG{;-1O3q=#CYIn)9&-)q*_- zO?xz-x}me1e3x=>%%dr0m}IfrQPrwnTG13oOqQVb&zU#d-Il4MG~XV*&%OR=958JD zzIlpVBi|1z%K~5+^C@K*Lp?SC;5&9meA1svB3SAIPA#ng8C-`VjwIN?$hFOrK5tLl z-eyp1ENy!ej<zKyUJI+ve{!+q-;jlUmaUJk>RSMn%aF5H(^HFdnK0q!?qo zdC<~(;24iP7Cd-aP>2*X?C>?)vTLPoep4S-Nwd(Qx2~0hi6FQ|nB}yxFPVlK(61{@BEZfz>LNp0cQGxVM__zuy)N z*hH6N3RWEUInGJ>Xz~;Ck6;ZIum-OJ`!_J_7M%>Bx+@|e0o#VJ73W&34y->ZJ^ix( zYbg{u-c}Vov}frKw^nY6R3m2}KIb%e^=8%BFe9$ECC#Z1Z4A3uktg**qgmFM%+>ZF z)DevvZIF?<m>Oth=igmW`1W0B6&8K z0v)jnSv$pBKlo3qVg6j1!uH{ZF1%;uwP?qbc6AH7qJW$YYfUntf-z~`@}qC+vy2TL zo2eGQ5h~{OvnHos*HJ_Z?GWkI_3lXR!y|R4ca%$x^d$@fD!YqZ)Uq0vQN;*p0YxH& zM2U%ouG>%7GNJCQVN)d4*>v{F87H^j!T5dhF2ezbXT;WFv6cU}dm(L?2uwI?Xq!%| z0XgI#u?imU;GXc#K1DXe(*jh2RCa8om0R-3FRp=XX4f8gr^YhC2i_3^C;U3K8at1c z>14CHm;jgpR)zj}(PGJR^#Nt4HQmnY)b3g|8P4aeMb4!v+1>WX4tKAaA3PV(NEwO4 zq%_k`Pr*8`4Q+PRW4^ZH>Z-CB~qU&)cJ;SX7uBR8ST6^E&8l~Y&03NX*xO$ z#z;h`)%t=;k1fiU9_T6DtQ05XK3c2PI60N~W=F_UI^Z87JGbxcR9m(7H#_thn~5Z) zN}E0$4`YuaYkr9OGmtsX_bN2b4uBk32;g+>eU@yKE|B+n zkYE^C@RXQ2Ebyjf;Sdb@yn|1K?NrOFg6#|WIKunfZ0^gk#5Hmc)sGeW&KmAq@rLB! zLM<#Kx-i?}rp3vC(uzKyt5c%>cGjaz|170d2Ry1HDY?NYHIrIxNw1qDQAQ?>)OslD zP?kIrw7)_LjP+W4xNxW0AN8Asig=dxT?mDG7W28GFy@=(;){N`?hkBgSE^d=YG=3L24*VXxN;ime48zmft4Z!H5_Su*g1=N9%4 zIE3;+B*#Y%$fDgYa{SJsEkEu)vG#CedL(glAo-*=SzYK-7<@C`gUW0~H@I*0o*%s9 z@ZnoBw{O_bAMkfMP3-;sCK6Tcg9j6j?Vr)OJA{>JVi<8)->x9#^jO#Y)akj| zzj)|yG-;BxJL^nw7E@qvK1bQfD?|w>D6yCV9xuG!;*(lzu-I!sDCc7%ULm%kJwQ+e z-9W}I!w1?Z`-M<4HDfl^$IqPcg9TY-eaA9af?UpIkuM%7IBi`+($vJ~&C7k{MB%f? zy}vA1JIII5DU__o9Oxdl`DSB!jmB8%?bE9Dn(3I>=wQ2sxz!$TF%gv3qN(Xi{v_b7 zYJQhm1Kq@UryST8LF+KGI9LN}u=M6#&rbZH{g^Dkx3vWWdWU^+Yo!hu`jI&o`>}|p zOwx+6!)j9;36UyjR>uWj?7h|COn)Zs&S?(6O({Nbs*^QXEbjatR&|0>#(sA4FTugCWiveUdG2G4z#0Wc!^aRlL3tJP)6!UjFy)|8e0t2&3Ra5ZgNKw{ zq*}6QIQ6{7V~i>%W~nI4n9O70wC#Z3HV_+(lrv>bU`!J1EQNf-qsfHz74~MS zcSpDP&Y+GCqrq&yT_2Pe8Ebd%npFwMnG~#6o)dfrGV4)djkp8$oo+sQaH?#?JF>6h zadtDW93sOrt?+TQKX)D@{S zg)yH>cE8FsQ_h9j zded5+QyYHwfxLO3%lollAXY*KnM|F_m-mDHaLpf3Rp2(x?TZH9?jeea^!#xt?C1eM zA+P~Ecs@bNpCJI-GcG!3NWnfkxO3PEgY38Ey{PJ)UD0yn!9-VhUKg3jy9Z;_PDh|C zYE^E`M77+^SVvN)tHmBkcLz6aZTAE&z+Iuiz%8X^Ct~=(bJoyc$SJyKx9`8OAwAoy6WR2+vQOCg#>;pR77NFzRv?YrZ zbl97@nu_FbB7&2$n4l$Uh)z4UNAuDL(poQJoAP}(daWWne(bK~-HV9{HkE zDHZx1)N@@14V-NIv>UQ%Riqj+OD)lvU`3tpJ2>FEBTqfhveZBqxgIBE>Q_PgYs%#~M#B!AesPQy! zA)X_D3Oo3sD>Ocr)6%fkbHP4c>bd*xA2<_x6Zu8OAAhWsQENgheWrOc@m2D9zQ0g_ z`};G=oEpyVa_@T6#eDyTH<3?EjG{ljU96Yk{sj6g*#{@tLKvSvM@Mh~C-kEL;-bIX z{+)$<_79eC{XhkzW)vMO{;;StTm*c@Wg8Qfw}f03K+H7Y(9^g@U%4lXhQApb--W_cZ zr87FMGepKSxn%o&H#yhSWn*@9zJNJ9+WZN60(~1!t~ubW!y==wTmg@lB@4W?(`*(n z0g};sBo~Hy287Jx`n~zoCn+(lmYC!YI{4mwiT0xYsA?n>trrv1`{tzmp3o&)!AKdg ziq;D0OXYw&v4pwlZ8GRiSR1>6ZWiAq(8AV;8*dL`7n;qeL0wZIQ|xho&tQsRgD>5! zx}X&>Cv^d#WJ%dC)M=;tLfL**z-MyoM}J29e|g+s{HkEd)m}qWVxMZcwvZezTUggT z#8Oo=LE1omV!{chwbGAA{Wdj;PdDKd1}Aixv@mzQCL6FQzyr_-1iLG0bj#;2`ZZeB z_Nn(Thzw`OW&+a+Pl#GPtdkwR&4(6*{j+LO#yj!)L4lDCte;*O@-L6qeL<05(Dz94 z*hl~DsXW||rFB4j`z;D%qu+xS)A+~NoLCI60IV7XkQ>)$8S z^QAG09XxBC)oSFWwyaR7cP47qfJ&-09!Y4@Ui9J}){4X4Q~&JYy;Dr7Ryj8C7N>;k zP8UO*$C{4@zxUP~Hzh;eCZ&B3=mQCw-V3`#COFp?f7EVa1Xv$%!pqO==Y!nNb||op z0_~)I$Pf|cj`T}~KELKb(3}@ic7^0~bKc-+Y+9>vj*d?(bPVd%@=mADvoNF`)+)lJ zwm;w);V^jJ#)g*|vF&z?(}zHmCxf0}kX2nmasgLeuhDRVNoGk_&W`=dRbVKOsZe(kC^*JSmT||pQ8rM=OJQ1A>2Q~ zd#t&fJC_0%V$ipI2uJ{LdZj=u!KdN$8PqY?N-4p`921T)HJRR0+^5XA_H?w{#~0O) z(fjC{N%HyI5qTqLOa_C-)6e++eNa4zqCu5yyghHPIgR#7-?7e`RiQn-NZWqXW%5PM zCH~gS3Y^t;K>lF_Es(W#=Y06Sh`;64cC&Zzuc+g#T{5v&*FP#_OmR|TR=TD)hp3fD zZvVcZFX@*^P4S0xLz=s$Z8(CcU`2Kp`aYaqimv3Mpk4S(2KyKs@pD+NErr_AFuZob zv%+I*C9^Aa{w=JRBGi0npobA!C1Rn}rRX`}3NlnFmJ^We=R2gNW1u9=oeG-70#8hF zk0&IUs2+$====evZe(A+$0reL41roA7(U)xw8&iPPFJmedK6{XlGBXZ)m@($m?+tb z)7gU-t>U1Qb*W?R5xF0G4XXJQauNLtoVM)TvEdif7+Fxtm(jy$V;R0om+o7o6Kysr zZ47Jf$XU?fP;yCz;u@zoyKE@P1+Ibe?bUr5)-hYWXwWkYKDmkuwp%=lr=x6Is8u<~ z9GzWzLz60<<3X%HN18t*DfArNTfry&29+4ESRmx^OIN^ychx-GcHGAHbP*YjtPHLp z(_z{wC`~%(0asg6=Rb9GduPmSYnNUV1I8qmM!!C(Wu87rSw3Cq=#krxjF+t_ z>~wTI9+Vz?{OFp~b?A=6W@%Wow!Jlp-Yjya?v(~*?Pj_236QuPe9`B?YezxiR{lKE z--?g0n+#$1WeJ0k=yUvDODji5(U?>$ASnSOcQE^&h>j`*Hcdo!Y3c~2k*QS@naSQK z5~4*lUsA->$vVUx+3xw2zUU}3ZpiNYF#+^G3N^9SfT^dq7T zu!->4z-b1S0Je&q_zeCDWQ4P8ECl{05F7(!Kty0{=(z`CixO+cB4kh63xo4zUqW_^ z*&IDD6xuCPgUt|DsO*1JF*@CC=&6-T(3+3v!YW-y2=*(zLk`ulXu%fs#O*?bj1@5< z<)t+r1~+pz-79dr$E)al5y{D?rFBxLIKSZ> z40;BRCMFFcEwi6aucwa2X}9NFpmll3PhKQla68rymcSWhyLl(iVTjGFNXOE=4TwaP z3wtftjcYk-ANuA?$zF*lw5rpT$p#ch>`sGO1E3yPAI&~~((Tcn>K}S%f ztXVd@$413zHQY14MI;o{PSXH7wc@ZpJU9_e1S^`*o77zl%w6k3>zQiu&^A)apnm-W=?a}AuIvIr+7pLkS zKi)4$S&Bwk&eiFd^M?-v4@!+H`G~6Enadjo1%8Hs+sI3tfKsq}4zMaVT_i=uf!Crd zAt}~1tWWri31=X9(+6ADwb2RtUH&37UKQb_cjxsBVn}hB@?|4HeLWV&{wqYoqa3j zBM$`U(Y!lnv*%Uy^+b*9DwI^OizM^+>DEq!3V*(76G^*G28%DCEOo2%F1fu!!&UqD zyv`G;=&JhgUbt+Ehz$C3;%V}8aGPFq1k_ghdWpfpBVp&-L~wNfph&8QJbYUUZS6 zOfr^%>zcd68LL6SrwgJpLEZp-A>c&ajr9n$1aB;Xv~b=A9J=ZzTb6-D${=YIl5J5B z%Tj`yjw+2xt8{<%mdC01UuGw2B54kNP2x7+l=BErkb_!+MxJ-3>DE#RC zhpg@SN_D6Qz9P_CRlE(>h&WlGVMai~z`n}D0@MYsEWknL(A$odRAz6%dGEDri*b>B z%w!3(dMEk7O4y>)&wBoJb#W}Bk5!~{xprsXJK=7dgCMT1V<0He0#wu*&T&S>V#?_FF5S&C!Zfzf`ce?51bRZjwpg zo!U3Sa14DPIXVG{iGg6-L#q06I1Z-@ftxOL*=nw{d);-TJ}U3M>DGmiO#kq+(qLw9 zvdtK>0?jsvICsKHI9z>f(F{HfM;Po1ak$w{TY0|$H-KDV0{?Q8>IfB$bW*3X#iDXR z*&$ZihsRWN=ghh?%_wXJrI5sYcrxlT>NK6ctUL8PvF|Hs=}^iej-Bgs1-nQMV_CCL6F}B<(&zC?P%v9`%g}A za#@c1m6!a9qNu(g61cBKJ#BV< z{K)=vls34_f$O(Q=g9Yul(SQTN#_K2pfFjdvd#ysO+jf8yTCpk8XS z8(gA|a@yLSPE}ZjQFx9f%*r(FvFfHol+GQvIC3D`)frW>wOp;# z)w?ep4+RvkD1k55fYb=$0C5r43iwl zh`YpZmnbEhQm^z8nN;>xxl;aa1yU?1KEd=bFDaLlKUVdr7FFj}Z&3ZCTCUEiFQ~7m zf2k>GZr41e`8RDzyP^GxPNS>o-l+S6p3%?gFX{i@ATyjZJj1HkDfm6dKEnRcXf_TS z-(vixX|L(SJbggp_<@bK1Iprm5K8KW9lEX-kVP`EtN&A|fOW%8^?&l;J0j39JpU)K zX@NLFY!gyy4eaYb;kzzhBJPII2I?a`)a!^ml#5VipI4-$TA4`CDNh&=hvL@)I%!cHlPe)8*tntGHNpe8^-4-gLW z{^su~J)xsCgk1o;L=^sfm5@%8brm}iWr!h1IAn}mt{cJoCnGxDcRhS(?gHpu1$!FPBm?g)l24gf~akx#=O zDM4O@k|6GHm3S?Hco~X!`rk$nzflpt{MF7QF?y)J0xNIsc?rce^Ta-o`@eAn@xXqt z0Q5UJIVT8?pAV0MtwINXv6biu0q6j>4;9pi2^Y};Cwra59I>Cck$5*}dS3Xvq}hZX z9O2_zx`_p130jQLqvyqVXR-OC=CjQoHh<9ke)C_NAN^6;w%0brj}0%q@~1&WxCSvb zQ=jFy!6yl3Y_K7k*f-NiTN;7c#Y5c1shI}pUHXI=kXc@KAGA1~4Pv%IRNb}9Flu$N z5^bPJgIhclZIBVS)9s8ls0g=mh0^JXO0}V_a*Kcq8|89l04IRM?2%UYy`Hg|&}KzsBlAHox$@p z4AtsR2Mf2tH9j@k5cu|84j@IfzFcpJ>NCy;<((a$n}N=ji!)qfbQG$(W-VMJkD>hR zEVtF_4HyNWYUhb-Byf8I_x#l83+OXIUM?pfz7*XWv}Ob7+VI&5)cQW3*g(eIE%Y1)_82Ck;@W>sMaeX=T*g# zMXpyQd219D1{A75`XX1q=w8A?<(W-bus|A|1(4ju5=84>seS4ZMgt;Tsx;JLY;vGll`SRyqk=yTWB^%E-qD#YY=^+E%0-YcOsfhx z-SFBj4EliE@hf1-xOEmN=>`0Iq4s)Vy@m+4AklCGldJGej>~YLiq{8ygn+Nb)+c8g z8h3@OHyBVqxND9UE8OD8jYc?rxD>XyK)nq>qdpvMu#qi< zOC#_bm!`-Tg-df}OMpvDWJ`!kYh+7=OIu`%#-%;7CB~&AvL(S~I0D>g@xLKj1WvoT zSOYC!Yl$`@*EAZgHlA!XMz3k~U2QzoYUCnBLm7TeI)SKR96%ZJ{k8(${Ce zDPk$6u3^t>^+`v#439Z{yL+ck{grM4yw9s*i|Yx)NBGE9E4%YsX{5)rW`Q3-z^lHq z2*$FMk2Z3V7+Z`s^8Zy=Q0!&s-VP!n3|=nA^p>Ky5?jo?MbhW7=1!ux>J?>sSDVAb+w~Z&J#pwnyITPcG z5YRF9WUCSey()@^$O}*X&~!C-X$kR_nz!iQkrsIVXrps?HlF)C;#$NB&YzOJE8;Hj zA2H0uo(5Z;8MQ!6<%+Yhtpw76-F9EsI&K-guC;Pq>+ZYPj`yc;R3nW-_}UJ;FYQiP zi!?gI7eW27j$H&xe=XcVps_{*P^|MLV>R}*IKKo5TBSugR%bU@S_~|&MNm&Mqiw>J9zej11^MjxzYzu9bqSy8Rp9lHCI{C z4=m8P%pS56uyAdxkp(Lsc#Zl&7!K*QjU4nDiZt4x7{*+z1G~8%2*SJE8i`D3&;dMW9l9;zIWe$==fuE4JSPU0ai2Z_ zR&XB-tl~ZxIE4FPV2#JC2GBZ>7lt-?yfAc_#|uM8c)T!ll*bE0$9TLjw8`Uzq2s{g z;#C2j;GY^@&~UN^b^~|{GaYwpRN(0}#99DxpEE5G_c_b=!40_2Ie58rb<`X9C;pA| zEfBwPp#|dZH^Mlj){r-~K-}Tx7Kl5%4*Hg_j&}?H#E*At3&h=TYk|1??eJ!~HQpU9 z5O=t<1>z2OLEp}+WwY1>)}av_RbbUU;*!HQs$K5O=uP0&#~+k;^hZvu%hh zm*Hrv4vQv;WwVuVL%iCcJfr7#1A3Hz1>hIQA)Q2Tgjm;F@}1Zs=*ndiB0?CrA{QfR z3o?j!NL+b}>H;N{L9L|n6w!h|ffu#4Ef2bP4Auen&fw>j+ExHRKPe_!6+~^;vgOBB zPl<0Mkf7_{oY| zBSf!`@!GSz+-Dj_KE+dy=i`7QG5B-Twi$VlCa|=fyLg1sH4DDS$Cnc*Y6P;2|jYfK}kW*WxvF-3VAZudWdox0f-yeqIi+ zItQN((A*{BApFI%zRZ^;e#UMBb}FI$4Ct>EFvI$F0^m6Oo(3wgwVr^w(;!=HjYqG_ zI1ZTi00sY>cfcWm$AO>bonSw&_HzI#5%i*9Cr}8!N(%qCMouW;|9dE5Pf!heD0Qmpj(*FOK|F8c4lbjMABLG05{UcTVfxhUA@|}^5f&GuH`G+_8LGa-e zIHr-S^Dh8^!Tg6O`~f#yGcb>-y_pRFK#2MK+^itri*ya{>R+Jz!(4^+xXF8{SQX_f-B}f!XLlfKRm$?h#`SNY0Pb$ z-2nh{)E|4YA9(LFz}s5e8U5&y{rCm+{RdWYAqg7;_aDEcA3yxB{{RFFUvFz*^V5HZ z=pVZx007k2pvDKy-p|s=z%RY zYJ&DbeUP$c{;K~1+>HDJ2?m@o>FP?=49r z?`+n^d4T2A-c-!|^_MAY$zaYKioa-B;@cg(AxaN^G%!lP5(>E4Z(2yRtrMW{Tm7(yfCuF-2B*Wrdz-bsi{$+SgAl zo2o*49#TP$;<6SogMM#z0h$+FsxL z-yStYH9+)`ekIiZN_P_UR?^#Bq=#^i9-p8;u!(GGc-)P@ z%2%x;M&Lh2-HrpCzr(vUA-%!bcgPsnG^dJBkf|j=DG1%dkw-B8atox!=ZVLXfhD}V zi^5dmYjL2{v;$k%h?Y^VaD(VJjEqPkdY2|fy%Vvvt?xd+c@Z^8t{}NK;cJDXG@94d zE+xGHwEG^+>AJBm!9I$&1vNhsw+RCXf>4fX+zwmu>-}4BZw^~~q=I^I!{txLd}xro z;5#vj=8~Gxc_@N}P}kLuIY6-jiRoD3f-;*!*ffHrvAzc+=S?#g=eoj7pTP&4KG`+P zfI(F8S3qn3plfxk__4z2C`6mkqs-Gb?;XYz7CdcIycws8_YahKnmQ46k&~ zdd-V)LN^eY0arx%)i4OcNaC|HwkE=8FW_LkCZ11`OObj}Mwr6S-(1H1e!n&^$>*uT zTW~G-6T0C1dV=mag~=ffgVOkXy>I`5R46-c>odx9lzJ@ zDk0h3drHqi2mQWTP6rt?^oPbD1chGpu1!u;_d^;&eN#^!6x%=un8{XVsx;D4rRtq2 zWy8@Se92DhI{bL&02pFzl)q1^7nX~jlg@U4-L$1+)JToB-n@%c-~|_}hdGv0{vm== zjkU&KYh(W?T~8Er9?PVr1+OB4sFBGHVs|-8Oa44qebxVc=J;o$MEg3kJgdbXU8l)j}pK29COE3(An0KupVIBJK00cDubZ`Kr< zA2p>|{Q(0guoDbPB_4_hnpFH|RpxOitUp&H$Cg^4&aV4C?yTZ{8+*=Y5{}o7@oQ*l zM&avc{l0b2GZi{%`|l2>CV;$r1V!{>PO6efLKd6P4hZY#(=ll-^g?Lt4yA|Eh8NmtglJ8TK=_y)ee;S zYCY)1b1ESdcksJ}+}Cz?T@3>59xlG_WbUV_PJ3KyygOwGnEp9IpSoy%<0+jTHB1t* z)@l|4RG>8~O!=t9ypXtD(II+vRr|Uk0F57-8Mscx@(J{}&OW^le zEmmRh$}a`Ax9h#GAK_|RTQ2=(=UXmCgA5uK1VWvXE8=ID*bEolyRehyYD-O(jBCmp_uaY5sd2@Qtb#qjL2h3Nz-9?bLe zU{lhkt)B(85z|!s<B8y3sUmU?^`E3O zh92*K?&{`7j3{7NMK=_y#nnrsMwUZH4?RYr%b$b{`?^4W*B&Fenz-g=O{B#h3%oJ) z%vYBb(N)v#X?V-%F)_HOpsZ6!iL%@T%iCRJ^BCAoKO9zyd%%wfA=gZkRTmYNPN)~D z>?FLk-~PH?EV9d)e+nRr!@OUu@iRw9Xu#zV*&kig5r3NLCu`8PM%EHZXsEYx{sr!! z0kIluLsOhMkYJ<2nMvMZlcwyAcN8BPUaXOr@*9zq13#cmMlhJz%xj4F1le~PMbK?~ z`)RTc`x<{764oNU>ZPf`{bW-*gb@`w$V)a&?IL2UJ1INiu|wyp&W_e*@oM{zRX4MP zFG-F{k{vsyDoD@_n=aY19K#^TdNAhg4BN2K;$!&;Dx69BS|fvWmduf19hrPqZHbTJ zN@d>p@+#{>=xhH~yG%O#MJ`{yn@S+qQmC8?JUEW+!C1jk-LBk@o`K217;LTaW}>1> zVgxYA^rLKJIwhM$Bg9C)2^nhiI$j5~|;S}U!Jf%h} zKBu=;HgBCLIgPjbbvv^UQFbexqZp)@u(MaQ#kq#slnGqAOmgiN%+^IQb7k(_3l0W) zGs$tN?NOgrnyU1mqwZ6)Z`gLYx0=8_w5^Dy2ET^j&|Bm(Dnz`Yz}gt1G5=SBd}O^V zhdi}^oBgBVDPBrl$wvdUm;R^LbvBOtI@|O2>oSHKsoYi$?}u_;)4>tV>3)IpkP~R= zqX&+X6+2mHOQM54#p~N@d%-yPYh}r*5K5aKXA3J*IeRv1hnY`JTqdZw0=E%8?$oPe)s)4Ix1E?4vEg9{zlntUUrEM1{OK~y}@$&_u_A*VC0|R+wjgD z#Nn)Iz2%|u59VQ!>4Yh?!tIoLs@}wDJgGI^zriqnD z!te~Z3Ja?d9lh+^rVZ*XM}uP8q`Y|GpC);)otd2`O$petVGcu|gI=hzKUJcJ4lo*x zrr#=h3OKng4Sl%v?j3U1wRaU4*z_;q!IXK6miG+ZbqwdYJfl2rxy?)yS?(<`4!93t z_m=0D2yPr+e5nIthyi4Fa#6Kv{*QHV+SUr4xw1q#^L4WSN z;&r2Cgv9J6!L1z;D!rZ`5N4%2Hn%&MyFvBFHJRtWQzJE;r~D!mCs`ZIS07mq@r26n zd)|TOY7?rGv1$~&sef9?O^VTPI3<&LvR5NQ_Gt@}UC(=GS?#uMegXaR7il^7_ep#F zS9fw6WXD2ND!62sFs}06_1S#b?qcOKe-%A%SA6vhnscwBqsHN3W`A#EL2Pi|t7*0i z?u}|x51mL)lK#*bT#XCB6RZ>x5sp_9gcy5cBplBWhX4J?;Vv2xU)rlNd+2PE*0ifG z4y;v|3(^!OqtbIP-iP%$m56AoK6jRb5$Mw4PafUdfGQJ-fS%VA#Z|C_tfqwTA*u`3+C!i6oDi+Fj7y-8bXXu5Pp(O=}zX1=76g07Opbx z#N}!;@+a|#t3s(Z%VbdMERndx{*~ipoi4eE^ItldbnaPJ1E!7jZ$Cs+jdPc5YfM_3;tBi-CU2yq?*n_2EmOA@e>P zBkJf+;XllL$|$`W=t@mu?76HNf-Z$_hULBVj&WSr4k&JA(Osmp?sR9#)^}bn)RCd! zk)ibpM{KA|HaryE?kr}_Mn+}PyKNS+Bz1Rs>smX?t$FVD%U@YR&HWiCa@y{fB77L8 zct@vDlK=9iIPT=|G9Yh+$-jfA3J9hprgQ+#WdB;T9##tq>>0+_g@#A+NoHbcLcJ3z zu-K@u7(F4ZraUn%!=;TWPfxUxOgU2VYV<04;PM8pgy-#At>_zW0y~0~191$bqk?ON zIJ(ecTxla*Vyd%<#dvE^@=r+5Ke*$@d6mEH(zrk=+y)%ai7I*?dMF4LS}h^vMz_|> zu-vJS?`KV6c4r$jqo%EyEjF;qq)JSfe4YG{%7^kulIH$M91`hpc1D=NHNe{7FK`r1 zxtA+fC09Y6k2>&r*y-(;`xsgNao%@eD{%_B$hJ|01WDQ2GYE{mMJfC0nWd_X&YW|n zS$(Xq`l7Tbl`htrv6u=*A1Ml8`uEwhHHRvqA(_zDeJLZ-Y$Sg^Pah0_&6?@SM++M# zArLm9dfwV;^?Mo_@v>|qx{Y;#=n|XTa#j@u+iK%dbBBAk-*~CiMhoYgG4cS+_|jK$ zcsv$urF9czrm1rv*&~BOPf?^bV1f4ctxeG#XirBGL8C>7aZ9~zo0t7`>I=Bo5{^_e z>GAq;i(Tx?e82n6qP8CE^Og_M2!~x42cfb1z-e2D_1UkNcC)dygf+_b3M|&SsnLvG zp5y0@Dyr+CWZ}X*1EZ;kI)=AHMPdgq)hW_ag;SoH(@L6 z^UDR&snCu^scC2m_xRcoUpb!bu~e@;vjsF@BA4=O{pcNN!m!@it>=rPcT}dU$jtQDM6`bnVRNH!q!+`R3YGZ_HI(Ijm-B z_`YnFg<=iD5C`#A(5@#bB!^rJ3X@}U)Gd}%C2!YdW4Ug3`q!D~Xyc$(ccX^}+U|Si z!z3tFvatG^+&FSbw)@H}MpVe}qe)hzkM?wQYAfmVYdr#U!(yLo*zx~hHk^^yEZaK9 zf5{KENRG(5vZdvT7Ad{Ai_e-ZaWr%OMm+a8gk^dBZ5yHMf)+j#^-rlieSK|na1|LU zv^1og;z0JU#S4OJIg`1)nSY+NYTFE6?>vUX%4^7BrO8DsZb-!I>bX&*4Nov&-;CAs`gC z7z6m)1}d+Sob^exQuAzz8ON_YtoDVB-T|E^Jh)81pk3}z9X#5(#4R0>l3=)pVR!zD zXCP!i`%V5!6(9Acw%f7hgeX46o~Y)RUpPme62Rrt4jd`WP4n+#ot2hTwsi=Xm@8l{ zl`6L9K*uS55lZ+R>CULOu-WZaP#X8X5^sxlUy%-4OT zMWyEU|4Q06uHo%H{1@KpQ0DU6M({}O zNa!4;q(sITgbU~J9BVqt9GH$6GOpkl=RTe^qxO%)Ae?j#KV4y@gL#BJ@*cXgqzl=> zo^G_>PB-neywAe@9s2AV?=R(->$A_zoBk+K6aq)#H`0v($H*2zo@rY*;lW1s$dgy+2x$zuTv3@^)};cp^7E>Tsh#DTr#}sE8R>YuPzpgHYW@{{(TZ z{zkR7f{LvHeL)}lci_^TY$Ok_vA52vB@i+mW)_c+BB-e=R*(zT3Y7INip4MOX zBQR8MDLfw7$mq3yPPblF!*a z){c&)uE|cA5!;)i5h5?o@;RClJf1?~MeM12f47=*TWtu}b z@m4m7&sfb6$?T@hK5lN-N_l}~`&%fhcIp0|@Y4EQ&DltKW9|TJeb(H@>DoTT(K+jf z36@#eT_U^3{K7CkMW<6)7ospUCH^*+WpV2iaZ7K6WC>YYB^O!;agZ=7XY0Gh(W|v_ z)CN7D_hhTxFw*l-Qca5jfr2IY}uY@itEwGOurv+saR8&Q1 z|J6NQSaux>*@jxQYi}y*YlAH?6B?Y`9~@O4-mMT<|0(w;6BJ=I9bq$~*z?5OJ+hfw^8FA%a1 zC3#4b;TOEu?q}Nr>3IYMWB08JDZ@G%t`yBncfwZjgtIY)#CvLKrxWZy>F$zQ3?4@z z4%8ObIO&|_i{35s*urdhoNjMBPYWHD{{1E;jku}RDknFz0SLyYJ!b`sKOSh;f!ohv z8U0vO(%Y})>v+EyMB*@gp>O+ai^XQ4bn$5<185!8gd`JBHK52BWvhm5W^`*)Io2i4 z$i|LAc5hMIj%?S{GFqmjy?yWH<)%?NIUK8z7X;_e#@*jhgr86HZ=dY#1JiS2d2`EU zX3teJ=ic{9H=J>ErMsCKvE91v#HAy-S-;0dOy$s@2Orn&YRItr7sOeO(z5q#no2Oq zRaYL_85ZQ~MwH{(NC|rBF;z|FF?LN}WUNt7`T9oRq}pAlb`^?x*H*<6bvXkQTkMpg z7$;P0g*Bzd7mv4+u=rTcWft}YS3(!fD}^aPq8IP~+H;VC*+dhVyg$@_$>8DU#! ze6Cn(S!vt7PJUmcJj88GPofsAC+~jj>Ff}t-G<<+Z9$sp5g0s6d4+%B#uX)7J-cjD z^@rAKfh6op`-rRgwja>@Y`YKZrT>+iE9kV%aAP!CnmrAwtqDl6kT-i8y+y98ckcX) zbo)ddzVEa%Ddq&$H<>*l&B~14m5;XY>{Wy)*_l%rn3ctecWLt1DNPWhc}|4Ywo26# z^Sna&4~)L|Lvx*i{=v9a!K2lU-i?p>bG$d}L2--YVM~mDa{;VW1(S!72Woq97suP< zah+t{-UFB1WO20Uu&*ZLvSHj&qA$`U>fp}srZ;<5ud}`eb(!qyMiOkRR|!~n#!LF8+k8LkXKu| zJX>qv03*bYXIHn`9rUiONK=ZB_bDZh7K}(9jyCEdPl0u((qntlrHVI`Jvv` zhqwI-ERXD+D=FW^wZCaJJNuK>W;RWGq^ruAq=joX z?XVS?9tP~InGIthU9a(PuoXW#m9j6_D?bAqQ6{ZG@B5E5ZA-5XK&mifD~n*AiM}o9 z`BH`3nJGGG5S6CwIm-Z+ry=4;oG3EL`Fd?SaJAoiI)y2$b~!%IF+0<8bRy`{1(-e8 zs&1y41{S|NCiPy#3SB*e2M$Rf}xtgQL zQRv1oC8|i=L6rHJjiKdghF#4;)(wMy3?VS+2Hs{23|d%Y?u{_4Cz==KCsG$Im+~w+ z2u<;9xKbO?2X->Dbl~dD?04?PFpeuUn;Z-u7JX~#>f~cy1#4@KAI$1HkBVKpW^C^+ z1l|21Ple1SjYqm55%9@M5^sMU1DWV402LeNKj8?Rz)%2a3sPfaP8I^qH*s8?D;(k! z-a}ICohe)RwLX=YnyfaBs)uIx%>=FXA3$xQg*=J(@a_vfzj{)JzJPdpnu-RfAEK!3 zA2;|34+}aTkK|Qt_}u$9_N9dS+y!2${^_(@0Bie=p6cNEjkLU%)fCcwn%ZV8jPo1UE6&^b>UoM3@DDbckAUgNGi&YW6Taz8>&5&DY74q<;w%!dzJB00lU)u~<*HUbv-SpFEkB;I zFf%chlwDYqyQBldkd53vwrxB(E!xX2oL!tkWH_AHz}QyJV|;-3qJ7|=i=0c2O=m<$ zmrO@w1DE3kQ*=umTfMKhE~k{CE(a|I6$K@)l`NLK0PXK=@h1;hQAmJIq;P*faZVLik3Cv~D*f=*n`7M9jlH82YMn<+z-S zYi)Qo9e^6`w|{!Zx4yBI1ZfZDLq{*!oo(wJbWbdHvZrdIz?$kcU%*~+r5(Unu3h9k zJw4D4A5BI-17Y!vX!ZuPztET}%D!c3WIH7@^>@_^%K{AzVQ$f&l)`KF%n^R;gWusxS`boj z^anOunQ5!8Nx;L(G+r+J{+Pgm0kFqMhQ9JkV_=(}TzT0oyN8ydENa`fOPq>sCy)x? znu;wjRzL(JQotg*M#YC;QjLhx;v=wqWFp_mCm~tsv%8kL{MXU(TyPfA!`-Th!T5HL7kfa`vbMkAU`uPu?8UoO{~DxmGxsRE zu$ZF1XIjb?dX%#3Q1|+);Yc=g9=}Cl21R+&`8kyT^>9?Y?F*m_p%=o@wmnsZrE4L) z21ID~&oE0!KKIj&CAUq3vhvw<;%{`t_iv#-aXhA=nB{23K8r-Telww^qZl0yhz5Gnt z@f|U^ieN!ZpF0V`Pm?1qAn^>koO=XOp`vkSN9=*6Pw^pBAGgd{u%CzpwJ?ihGkt(z zBSOKPnbPfAt~R*=kHB4t3Lp3Y2;-$SUKup7o1kMyAx%Qp@P?&b5V!r=Hxe^gXqK(v zT@Sd4zKpqDYVKee;1!H#liR&ej5C^(b2S7YCgC@b$Ba6EX&t$ zbXRxO*RG;&d258n+?h-@gd_KmtQY>WuJdRY6Eyi#-`A=wS?*P1g6 zzjC-z*NCvy6>U8*kZ9{s-k# z3Ne+5DtnkMmZkyoY@spquzB*EG2xI-rOZmE*+`QpCL7c9~ zH!0X-xd`Y|Q{FemSvcwQ%SY~4)tM67Q9TAB!xb{B3Z?a3u_4rP#cty0fK>^MU7j)` zSes_{Y)MXe>kuxxkTp4Qs(S28uoav)Im!MutbEky00x(9TU_RAJnxV%?0;yw9ZJ0D zCUhrya(DrojJ_xyI!M1Ze!OhckV429HxQy~(%Bk8Dxr2P(r3Zma@Te^ZWuGQJ9|kk^Tn2ZH!`miJ0riuvYz}Q zjA*Tng12U->fa6WZON5ApJE%@v+F#rui98VAs~>pbwjHY<0;gpj?XC#@XHw9JL_)( z)%EK#bq%Ka;>z9DnnVn>=wzC1@^!!V+!(8$D8O*TAVapUEMUSs$+11!ghW}9B_MKr z65OGdxNP_1Klo!d%Nza1*?R78!MedGZ@;ebDcA!+5dWU$$GQ83<~w5I;c?b%Q;Ern zgHFARgZTYWG$~ayP8Mv4rgV*q?y9TjDS<#MWZP-7>t6rZA$oG4EHrXe#}0!3ZgwUh zj%GV?om=9K>Oq#Bc?iXgJjH8{&yFdFf^T*(eyHolWN*O0u5XkST0q5CJ@jy4g3N>!bIaD_5JON7MMkmlm$9v^?OwLh+9+Hl*jMs59 zjKrTm#zN-kE4Af=r{(kYngM9TfoBtjYpS-h=f@x<-=Ja+>Mr_kQc zjk=j}2R)n*pI*G!2H{EdvwFIJyo=vTQ-~+fbuy%sDBZNxXdU)H?T3%eira(j`?u3C z(2cIru!6UK&miPl;Dwd#3aR6H2qd@a{W_aGTdT`xZO2a!T`h_DYZwogq>a@2M9s1yb{qCHB8OH$6Zk0OY3* z009306A<(B#D$0bPw+#?|F3WWi~tLOOCS^=P9S@r7@!8A0iY$ID_~q;abR!YR^UAl zbPyd7ACMl9S5Q1qSx__3YA|T91h5lue(*d942W=uYe;^`3@8LBTWAvK92jVrG*|@K zd^jLD9k_OQQTR^;1B6{faYTJYcf=UPNhDY#HY5+ECZr2wPUHaO4&*x&9uy0dX;frX zVboaE5j1!-Ewm`KF?1MoSM+`iL=2Um;);=jv4x3^DTSGgxrn8PRrkN~{p9v3>RB-8 zA#gte5J3LRwfq0*`{WxQbOreEM+o`98ThHVuNl4!>Hh_QM)&aVVZ_IpkOP|e;XO9} z1#rK4n7)@MTW!``ZSo3aT&tSTfOCk}*+$Pd{sj*`Gm+}vBysJ~xE8GS#m|;7?v8hd zqpp%OY8#`V^`9r%_p8~XeL)Tj?v!oI)dz`!Hm{u0t4ZDL0Iy(4BS}Dv?ij7y^BK?a zM!O@eY6H(Q3FQ<$s4sV3j^<(Rm&W=0UP3$6(A{;5XFvMxiuIqruGbi_+tb^DI^C8vw*WL(m6%O6S}y1 zgHmzM#npWo$_oC>laao$zP{&MUfl1;m$;1J*;BiWPC-!zzo>A8&F ziu(E|aYo>0U&GFD<-!30NC4SJWY_=J2RA|l3z#1<#1B{YPiJUfMZiCQCJS-_Ol{Ip zqh34@XwU$}cqhyo+#|?j@pfX_FeDVy>J`Vm4N9%Q$jGyXJ6T{j@U;{geS3-01l^DG zN(h_IuhQq=Arqm%Q^P<2w*~y4B+VZ`)vC*$>(cOemF;RNsHOfHqvtGPk^eSfMw(eS zAKyo7ib;-rpDAbhe6ZwC-QLY%;uF1IvsCDV82dQ-W(5lXLp|4E06X9Cxl11wlI*>%bx zRSng@VK<4@=4=&btR_#p<@^T|(K#)R49=Cv(kA8@%<;Aw)Yq|Uu&?m)OPsk(ilkX1 z3@7+tIaz{u@Tkcr*VPw!j|VfCbRbaH{J+-+1{USRx8Jv*Rx2?#KWt}+G0-*5+Q3l= zQCgdeFj|wUD294ylw|iMz7SMI2bH-}kv~r?RAj4&y0keE3ww_t<-ifYwFnMk1g#x- zC*5}nVm6R7c)x4CVAFlA4oajd8TA*uW-=C;Go%9~yFfDsd&Snpt3*LJ68GN(q1Ung zM$e|p-qoq(Qjs^u%DQr4=`$a`jO=1`QCis*AffTaP2=d^;ZvBHOi;nbLo#WCU6*}K z_RE)O5{l|JRwk~HS+YgY;9`kKHVLl|jkIQZq(bTlx){R}BTUh?no8t|Q4cR(SxF3T z+!Weaal`SDoA;@#&~!wT-40qN`QKScBq_c)-&R=&;kDQX)obuu@)7-aq_RT!j1QTAAr)C~{Bp}o_Bwimz6vU+@%(E{L+vpIBP)3)dFV~YN z+4Qn_Sl>xl0#LY3bVCyPJ*M(l#}eM?yC9Y4K)Xg1Py`cU!Eq)zHx1c@-qhd1FY2-= zys3Vq-qdE01QVfwCC>T;6p~&{pi?B~wiFW9H#S75WH;%0YFqrq(wK6_XulrI(l$)G0MO;oYlj+aLF z@%`eRjkh(MrmsXZbQe(2a1YvmcJVB;9jmu@5MK{-5CWziaz5I#U^6#9HjwDxvFnS^ zqp({4ItE7V|IkjS+=ACu1BSN@H^KhRCPaPOw9EopNcLW*JZqq3s32y;h~aEJ&v`($ zxtcBx>>)QMbJkD{n-!cSoJkTKl_)+Z&cS0c^{44(G! z4`3d>tV4U0q|l<>jBv8TM~Rh#!p)(?Osd~lx@hGdr=FN5iJ6B4&vOBqdeP$f?7%Lq zIs!w+t4%9W%=b~4q+_`@-ft&6WW(wirzQ2cZH*`RRt8~qM7;>Scc3~ELS}}Me{@-i%czT?@dIy#>zC4uFY`oLv zJ(~)ndU+@6ot9|FtC$RfQ6%JMVzJm_ghxZ5ZH`PzI9TV%xSn~NxmM#uR5q7PKUHCV zrp|lp4vwzv4ot^k9o<}S!G5$PmfrkZ&435;b-GC0N$8ZiC{ODa6~6h0-;7HFIu|Oa z{L2!GGn8}&lB$1aC#!8tC+k&DdMlh_8Kv>lwl~=o$aq0HqF|Y!`fq(=N^w9DG>{Rq zPNV}iy#NTQMW3=V?C<7^QWZl<5s_O9CQ62>d8o`Hrm24!=qJ)q)lEVT#fbHjoc)o4 zeUvh7hB4s97JZy>EBZbKIxQ8*|Ip8wY^we?3z3>;^hc?L}V$QOl7bjvmJ9f2{kUji1Lbozu~6M-c#ww;%W;a*ycmDt+A3$ zbQ&GJ@;rP1d482U4fmrw>|lA`6xt{?cBzTx_6p2w7aS9Cn&Em2BJ|`#Y%3JZ_gFiT zsD$|WeYu{a9?8?V}K9**&}z^0@*7Im>F70 zv1J8DomH`6%69?y{lmecGXsC78p&={&y4q#x8EVe;-1@83(c5jNA;W4hN?YnAVd0V z($N*pCqZik6$9%(JaoZ?)WS;exSPcA`HCHrXJpuELc68riITIOo z1OJYBixG18H<~AY-GSBkQ@;PVmm2kfLkA#2rn7$cPBdrU9{B}*gG(2Rd&@u)0Fdb6 zp8gki>Fe(plN;)r>KpHZHevuV1W%3r0y_{`k%q;E_4;M~>vX^#bllWH-r&H$Q3ER* z(nU~`Gp^bBbC)7Rm0LuuL#vO|~ zy-Sys^`!H{kqb!;JSsPs98GnSRJym-e?~9?eNZMWdwx>;%Kvj(k>Bzu$bY}~MH-Is zdorwZNe@kEB_Q3a>o)5Nj&aH16|}a?jQ`9mBgVf`R=K~bw%YR*s(~o;@!k2jvNNp( zl9g(>`B_IpeM3Od_Y|xs>k5n`qokedk z-i+0BG^tf@yqf8P!)mkIYP6i|0|Af6;kvhw;e5jLYvm>OMTc&bjC t8Y7bN+%{5Ba9FUtEEX0zNVGe5qwdbljh6cb-e67q{c{!o`u+wW{2zR-kX`@) literal 0 HcmV?d00001 diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 b/packages/extension/media/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..395f28beac23c7b0f7f3a1e714bd8dac253dd3bc GIT binary patch literal 11348 zcmV-aEUVLZPew8T0RR9104!7h4gdfE08HQj04x0f0RR9100000000000000000000 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 Y{My=5Zj3WbM|mX<0#Lulu=APmKiR?A3IG5A literal 0 HcmV?d00001 diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Main-Bold.woff b/packages/extension/media/vendor/katex/fonts/KaTeX_Main-Bold.woff new file mode 100644 index 0000000000000000000000000000000000000000..f38136ac1cc2dcdc9d9b10b8521487468b1f768c GIT binary patch literal 29912 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 bnfbLfgSm6Day;iB;wah|00962|Nj6Fv8wea literal 0 HcmV?d00001 diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Main-Bold.woff2 b/packages/extension/media/vendor/katex/fonts/KaTeX_Main-Bold.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..ab2ad21da6fbe6c171bb869240954d0ead8f68fd GIT binary patch literal 25324 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 PaXk zfy;7nh>eLMgtY9K?UDq@1+tKYB$u6B2oB5V5@PZmB;Z&x_j{_kN0O0~kazDNPoti$ zt~ya0$h9li-+L_o6vth?h2zNR zkyAIP=?jqq9Cyb8t{*sd{qbuLcb~h9>D1qI+;NQO>L+kQ8Q^jMtNs|Db0@C7`L?-z8-5GdKf`fUI(glZ zm0O>>;)5J_>^?kSx_0HZ>q$+=wPVX1$EDw}a_yBXfqTD<`Fw!mgh#Kx?xvep-mpEu zargFO{A1VOc;)q1evUrGaksx6CvYc*<|`*Dq& z#h3^2-52N=xfs{MDKBQ?8b@noQVSXZS)#Henb~4tpx@W1*BiAl+E?ehZ zd_=8KFqk(LqJV<+so0kSURF5WzlxhC1^e(?O^_HB~SCmmYuEMIpmJG$q-PS>!M za4M4E^zqWn2G{IvjFuJA=~ZYT$o#7p z9`bU)kolcEiG8p=a?)A%@zkTMlJmYB2qll-Z1TL%sd)LqcCiu0J6S$A8 z{V{)*{v+4N&2b00YdP11cKp!x`LThRBDBa2;xd$kf_*h$!$yw8Sv&{Sa@>H$2gNL7 zQAw7H0c%{NJ^<9pI3Y{oYxo5>Y}D#}z`Eo4fn?|4ct?FjbS9F4`DvDj}<%(*ypV8%~Hm2_*X&qb5E=Vu@+p&6l!_Jt!tg*e8y zIy2={w=LARhSIw?s17aaG}3!-R4MH(sNSKlo(M%sDlckIGjgb)nHfnh*Bww=bV-lv z)o9PPeG5uxbU+n+d^j$I6XA3fn#`j+hI*XtR9NyjD0ODiwblQJ7^06?93Xx}fcYWZ zx5zH?0cemgC%vGH_vtm6 z4nne$xUZYwzG8q8o{^(bhXQZ()j~lKoc%C@l9`x`D)Bv9;&<-yt8Ndkc*iO?R|CV+ zk98tsPJU=|;6udR@06+Nbk`@JdFArmXAUatN%1wAyoe{o7zFG|q~DTP7zSB#g9R8b z*vwKO{)nz3t7h^U$Ks zp758XM&E=d?7$P|<9LEU%XqBb*G(<<0{nyb_GLP9|0s&3ucls_QrgQ-PEvP zW~>TSG-T=L88J!jBaz`SuO$6GC*N71yhO!};SG^KSxLD@MA7j&gG@ggbcaOVlrQL{ z+Yxc_iXP0!vP?RQB%U3Wy&=C_;_nefL+K#&(xWzrK6}(o%HVzX+I#o|^Z=I!ygrU$ z)G`TjrltJ@FvU#L{Qag)Pi80@b-Kyr%jHPUFy8ZSvYD!0ho3Aw@wSjpde^%Pd0wFh zyiV!y$1No+h^yb^Jsu~?QKA}tg(vTPo94!|_pObQIQO*0j}D4wH(+pUE#-=bTCzRB z9)HtNX+hMt4>@Jg@0`uM<61H|Q5xPCGB(`Qp$0ul-+WY-v$*F0?zhNk5`cFkz2Ic` z^xNzQibxm%7_Q@$d+rfczo6WG5APyQk*9(xZczk61Sjrr6VMXjJ^*h`dO>9vvCO?i zWDr)8K9&T0JxQm(hmPfv4mHs!@Pc!o)WCOBkc8*JJ-+sLTmB4ZI}orTK;4^MeqKEH zNvgO4iY!yQz^n8hy)ISMUR|P!*M%8~#+_g2HuKYNqcbUrsj%+V2F-9!m-mIZ za>uE^@a#J@a`n^O58R*YtYxYnyge5)VtI+G-Rb}KdyGrqdr}vDCszerpd8~8BS5Jf zfM7Fv$xMZVP|&|@Zhd|Jew+lhndn%vt&?9E3-jcdsH&pe8NH@(*cVc$K*f6fq`R}% zCrW~#y35_$>Q4GjkF@%?tA9@@6|{g)V_ebP9vZM%g~BhY6n z7fZ3V+ym+5Ol7#SrI;)#@9=rj182IbNk`bo9SNEde}ac7gkz#0dJd0>E?qCyMlxR6 z$0H0L?}c z4ml#JKEB^&6e|6o?8&vC(wl(0W58XM;m-1VZ6dajC)-H05C!Zox7hN0B<#BzZqUzSR?6dse9;ZQPB@}mih1zjSL#8hc3$+rG_e0is4i$ zBvGmag4rQ6?DKg2`2v-xLlkm7zG%wfa8B$W3Tp0%Ci8qmbOzNJd0#Qz*E{MVd2`&a zzo90P!q)D}_(oBynNELsFcFM)DzMgq&!L;)&7%p!FG1oZrNC23&80hfgSFXY${W@l zPE8SvF0bJlX1qVRHb%4b9o$A>D9(6aw6!_l0n^J8(<%LoFZ}FR3qIyRGt2;Dph|`Z z(^q4Lftf(dU56qeyFxLKH(bb=vwqF#a{A)ZSC^D%M3ao9WPb2!pVT!HAf{q=C-TX# zAR>;DsEZI!E$MXeil`Jn7c>QD&x6$s-XeJ*~+`&vS<8^w2q3&3qC#9%fpP=rgqUsvow6Mho0A*3& z9XGr&&r{tk$>pAHSIGW*mXdzY#>wuXWF+1Vk4tg~gDzie|AA1a?8$4OpDdBRyhy}Y zR1!p=E;&x{gM)zA*tsbl+k2x|gf=}5NN)tE_j8-zvrg12X=qap%*q^4OdB_m0~P_> zIs<1~urhgIc7)k;(A=UsE*j9hHuGZu`BBOcJ@C;xX8e9b>FTR(2*#b>tZZr>GmWvr z4Asr@Q&+oh=evFG@RZV>iJL(!6c0i?RY5nx0{KaAr%FXcg6dEyznMs-c&4K#-8Ivt znsPc4t=t^(UAOP(quPKvkA?- z4hZyFy`}mXsEkKLL0dsvRD>?)Eui}?&$=O*h{e6zosNkUVNnf6czIwDE^#Cwz(XDE z4|z40Q&Ivw4PN8fNnZcxUb!n%c2P&)Z#xA+fRkOXs61FtIB|mTM3N0T=2nvtQRS5> z-hs$mqP~|_o7r+saC*#-{ezbf^{s)K?12JoJ+tVSx;VmU&_F z2lCj^Sk?#>%@&vuWb7LZg_w0DObIb2E<(dIWiOGRraNTg9Y;1Da>8ZaGN}|K|84t6 zQWH)x8q6m~sGDC238LWd>=TMgYW4F626t*i5RK{}_2jDL`oPArFS&Tb(4aH!oZOR9 z3!~BCrYoJ1G10MeAYgoGNF#LCBPveClg#Cu<<&Ru8tD==ud4fVMOVy_O%LdaXn+2vVa@J|m51s_T4CI!D`*JwlAyHoPc(ZfC za7wAvXNt8-U^-tN&dc`@N3M8gUo`lV`hDx{E`qNzkMeq!0^)PmRUtFYLR`A>()r#Uy` z0p-PrM`d11+alLMCgvUUt!xF{Y35VC8ZUGNgGA%wh0$v-@UKX;5U-@XJTVi+k<}ku z>~7&%`_?wJo>j)P62415OKy9Wpt&sBTmwdsAQ*)UAKV-&PHCZWy<3!3Me@}WQJ*Tu zG8fI^o2B8M(cG>D5BzJ%p*r2=YPU>HD?h_s%by~Ta2jV|E^dcl!Oc9NjO9#==MTRW zG6b@TY&48>ed~v0tJ?}u{}m`zs>)4w9Uxiw}=5e*f}+KD6NL>n&&;zct?jXRJ|=uMTH=m zlc>NGRds{~Sq|?PU0b7WKmQmBd1SZ0W1wnyXkj)&ZlsiP`MrN_#-YJS1s zMcvKZxu{2g!6sDj=>0*msjD;Oq>IFp@C2i}zvK?7-gKsC^#>QE=P37qwO=6D@cZ0u z?l3TL`QB2DVSw>F1J3$kEN=ywmVE^{%nM=A+P*)-5W_>E$dF;lizP+CRO?cZ4K7-@ zw3IsRb)6r)=}4xuu)!ah$%M#v?mz7@bG%IC;O+YV@0_OkX@PT+<5VvxiS8dc_r)IXpi8KYD>$ zkDy0NN0%bm#^CNPdyXi?c(Aws^54ybcvDL6-kiiXcn^4Ff#diN@W_;9*%oHXjPo+g8L#&>YK3Calw~LqD+IEEwDAMx0h|a)V8lW} zpK%@^wm`DFnTOL-1e(?WMQfhE$XPGBZ4~`pz*o|Hd~01H66=R{aCW zwmnAwklO;QKALA%z0Lj10RasgV`8mdv#7{)fdxKfU|sB?b3-8uK`d;;0H*qwJI)Im zMxNZ?p9&t@zjtHR5Bto61d=E)NmrB49P&kOdV5g8+y|aH5;lhyOrcbQgJw(sw6N}E zq$A|JoA+Ha@xY$G?K|iDM&V;h1W|~f$f3RWI{CwksjhnOo=w}2>OwpvCR3EUgw-`3 zUXCQ_$RAN|4Ai|D)Gcxoknrx#xDNicC~JjgmLxL{wG^e8X89YH~rB8lXoQK9Y`?sAJ-P;qPg6i=04M3O`~owR&;4nO%d1m_=D@ct$rLr|Z$-GN4sBR1cBq>B>y(%yUP;u1)qjRM zz*OUbpo^40QU-50>-j^111y2>Yw9Z^Ttb`Ufvrhk%K)}m?77c%F}7ZVtbU-PTP!2v zAj`Jkwk#jG8Q5f;4qTZ~@JIum#}CtXl6@KIwXxJIO2~%O@OdODj!2+05tkYyl?)a9 zNuQ{NfP_g{c#p};iCi=#y9I#^58H~ZH+ggCalgS}<>3Zd*bxQ9GDujqkP{qMjijMN z&k6i(w-HsJ^+_yDS^Za0^+;OW2s_B_u}O~`I(1#)c@))yqyRAU%-T=+M8M*fCq6r#D{z#CAg~@qw%Q z-+MsTJRS1(j$e^=TJ4oW5#^q_S~|G0x41zUT+YEEo9%j%R~_-i-A>6&VL48gCOU_= z81At>Y5#k#AKa=C;;Owy_&vtNUYz$Wjy<@)Z`<~4Ou_o#Iywje`okjsB`RiFY{O*h z$IudY04JL*Zh4LFhu_0&9PDx z29#&A>Y{gyZfL<_4Bomw5n~@<4s)aKv^R44ipzTABY%`lgnJz@(O8cpVyz^TzlZr= zhXc2>j^&>X-!n~Swsgkx-Bvb~RaQIg-myKSCARkIXlXZ@Pl4z8^G~ln|c6JVMDKYXL>L_db|gk1MLt zPg!<{_^@K!VvUPEE1UJf8xNlr9(ssZ*$Kt>H@~YmMd&v`Fq{4 zUM%XT5;w%c7VD0WgQ>>~yL(u!P>Yg-g1RcT3}&0F8UHee!4glrTU_lgoyMYKKscvd zU@^2QKJaqG-(uw!IYIPL06C2s7S@nqJ+Gn83#aX3AOUhASNH~) z-v`#QS%Vh)c^<4`^BH3yjNaoB1ksV|_syR7p0GyZ;L|2g?taDuq3G>*QkR8*+PXdy z+<~L{+!gEk?4d8d2`kdFY6JzDZX=Tu+DH)DeL;44cPA6uAmITRN)jMgzF+r{kqLU~ zobh?W4Nw8B!0RjuXrZhnN^K6HVY2$CU+wl18O9SHXdi)DT|FmCl0X8CQ7*I=dx6p5 zuVVDPe8A!I*C8=*vNF*{3rNO@SXXB% zk|}$Jz;n;=K#@qxt5N59vkB-a_kKhSEA$Vz0M`YNIv;GU9NCJ*Vw$!u4NKQ@6o_|2~9U5f`k_fXh1-BV3P6%s2b|A8YfMIbrP{UeWjNiAnI` zD$M&66d2&*k+ug%z9~;nl;e$=NHya3xpi+_eg%!!nL|dz@j=Oms=IG+QF2{mMAxy# z0(VbhR-@&#v8dp=8w4d8Sr`t6bkr5`pu)ICg??@yw}Pnk^2NDEQbW|5xAS{8S24(~ z!V<8b4VQ%jJCKv%zyTo)LcSgJS#pfbCcHljT}FU$E&lUNh^_ozgZ@-;2%5VxullMb z>a1iJ?~aAelQZ_FWz=;z!oeL*DKI+|cI_OF`n*W;9wQ`)cthO#d zJQ?yB3z()1JHKupFU2!$uCmZ(f6G?%Hsk!^pBhxTKBK344!X65zody_U66!eRFI;n ziWd8CZco-ZF+j%1IzJuD9}cu40`k}5$4eEZn2VR)u8mAU6?%qt4Z8FD`>U=4y$adDE1^1~AirN$_*?sDRoY4C=H2@}wKt`L zt|LP#iKRprDi(y8;DAGb)w={_RhY|k?Hd{N5tBG~*Ylk#z3~pEN_FAuf}%)bM!Ijb z$Ej4k%n>MGov*z$Cs$;OxX;rZ~1|YhPL8l4n{GHpG#Oj+xSW3&rd!KLt9->s$Asa4Gi)0 zWJG;)x41%dM-u#kf0ortY;nWtNtr3}I88k5T{#$XyZ-!h>sEi=Gy2*OeNOj&5_r@{ z?t{XmvT~8BqpR1*%XfjUUj?Lp14w58sh2^@>JT8zuwk%bsIrh{fKe>)e5+aoDM8!+ zW%j#ZqH@n?z-@xDxBOqr((`{9fJxA6215MRdqv(EOJFsNluD(dOXW3B_mRkoN2jVN zYPtUkRL|nN6}`!-?=mxc$ckvyyft9sv(k7JZljV9Zkcl#yhA{OV}r*3&Wnnhh9guJ z@~_$lBI*oK&|#ZYNQ;)8C1V0-ed6qyLcywNnSzsv{J^XZ}Te} zzxwsnKXU@nD0~pfdsT6Dr}cr!KJzl(53D{8tWGi>u`=P7-a|s5P=t3c8ORjZ`dX&L z3bLe@@dd(ILY1L;ofH=kLXtZv)FO;>02CTvULzQ3Ot5^$M#C~vU`D7HUY7lBx5ef? ziF_YnFI+&?>6H2-e5fn9hdA=0f>I7aDTep$_C?{jV3~C!i4^6jTl(vKxxzbCN%B;V zMURC7JgW_JAatU`B%hulQaQv}P9UNp`7cZF@kWt;2@a=Jfp!iJ1_Jj~%FGo4BOc7> zRxH1fkcVdYXf1p>Tt|4>wi5$1GQt>&EbO)+!j^8oy0{oUy zBLP0QY*rQmYb$3PyLC%J1D8zJ-N>e~>LWWbL3amE6px3q0DqwB&GpSyI8QW-)p|%AOzqok4B^9fR#BG1qAn23Tpl> zPy87#s-lwz?*Lb*(D5nv#%Da9SMb4(Rb&i?z9uJUUe0&Fn)%|4xT&&Z1{nV z4ZsGLNT9dcRcTCXg;++D{kQpr2`PBbW=JenAdG_r*7>Z>kadz2rJw$ks2W*CRY+qY zwrNvBQX__-?Lpft&Pa{vzjprYS)FW7Y zAN>R>|4~oMGrGVDf<#-%6HtUPV{1*ZNIpj&!bEuwk%SNPvdZ7W8V=lk1qyP{4wUAk z1WgidB*=1|;w4qm{RFr>y!J=*)Y?CDE<6Do3EZI&v{hV*2OV~a=$#~TI>FncV}4)D`J%;Kg=Q1 zNG<0M`VEXml+Bmbwwy+qF3Y6c-yO^3DRG5ElI+zYf~>iG@q+B1EUJUox^j{~o$^Jz z262`K%aT@7_HWs<`xX>UB)~I7j|Sz3-|7_IQeY_vnexPQi4F&?NJVm_=prfK*zE4C zPs}&u($fsCs{mITRmdG!%Trz)El1dDrCn16;xb3`yx`k1ft)`?!wvu>J79(BVi3WH z0fAs9!3lNZ+`PyO^{@3 zC7=nBKjL~p->8*8#gZH*!=!<+4&Z?xpO5R1oWUu4UXQ!KTj@SL=|r=Lfz^`vcWpi4 z66MHSPv~>$bhjB!6|#v$XIS@nZ|}?rBTkordXnj}QLoRPPF-=k8VhygYKdSeA-e;$ z_yUZd%{dPO!xP+IV0imX_CgHXa|^L7;6Buf4}Du;RjeYp^HvDHM& z*F8^^b!*?^D$|suMk~PXL%Y%Ub{?5ur^(y4*0zPrw;UbbCi*sypG;E657UusvXKZD zh6hu3ZXat=CEj~@cxdYtwO+S>L(FiLKNpOvD6kyJz$1k^<)2^N~7WN;7_ zx3GgFBSO$!IW(CLc4s50lpy(K$;(6dCnu}{;?ztygZht31Izj2s7*tM18iL`{2r-y zfAVdxW~>%h!1}OpQ)($4Ol14wVKYH}{$skSBI(w;eE*B12P>m)S}B@n0;Al(TAdDp z8dYu<)EMtiun6B?&9Wjg%h81{vjyCeY1@0X1>SNEfQojFGh5}e^%+Y3uI%i&zUWom zsq9r7sJoGulb&E=c$frF?|Z81@`cle;7)do>eO);DhD?>g~60p({kx#!Z3_osamW! z(p?)9Nka5SLduW-o;1d`4g}?RC>iF;6lM7E=2bO+kYlShEjUz93f>YH91!Z!6(;*J-|5d5V zWO%-BW_p{Tbs37!hfWhLjbhQB_v{_)nRJhm0*@4J-4dBPB%CpggAcvH~i9G6EBdPw6b#`=(s+8{zhh+rJ z)tI27VMY#g&N%$laZJ6tm!0p8qjH*o8KAXPFy1LsG{E${6)PZ$qO0`I@o_^(_sk)* z@Ck~$w8P6+%Tz^m%3XI6>&g{wc12KUIJ)-zwFY?_@HtRnsJvJ+!NrW8Ezm$inb8)m zm)LT>&da1Nb0B%k6Jgk348T-B9F#hV^`JwjClgA0MQ3u*10NkrCYlP6g62@JIGT_7 zeR@^!>}^ET^^m*Uj3jv{d}*HV4DeS9^hB~Z5lK{&-_RYySvcod zxuGwp-P2Av<&T)X$!N&O%Hxd@mAoa~;?fO(>>6;XwVn`SC0?)P%eHlum|3_4$1a<|0Hd&#cw@`&vUIzVxR_&YBivi1-ax)Tl`k%I zbnxL+XPU^Fl5f;>3(o8OR3)+!gm{WdKmwK==D3r0%KZysGVbFI9~KUkXy8INwrsTc z+0VH=g}y+T-&Nfp#KV~mNmSylA=AkR#ucw~P)7Ka_Ls2YASll8!4OXyJyKfE>Rz&Y zA>s%!nclPZ3vxNI#nwRkSx>_B^|QVu>vi12=80np)lr7KT&$0&sc&I34+q;M7)a|1K(jEtI`Yb>&|NQ@;jYZVqOKRxXW@!Ak)T zEzdmIC3vtwt`k}?8+J1-=(mGWOS3bXwhdcsQxvxxU<+&rKk>(4p>J@b?-p8&H*$k&$Si`OA7gxKrFef2s^|A8!!a~2sf zeSnCQ9O>!YzR3Gd9~b=9VklB{NRCHa@GI%S6TS$mc@!Pdk`&&r*Au-iZ;5y1Q!H3Y zC0)k8Z2{v1@9j}>xttr#snmG{dT7BHdqsU=@spexIM z^u4ZpSphBd6r)-TYKzva8=ZMpZN2lCs8esN63?)yX+tGN=^5>s(UtC8IMo}-h66^3 z&*Vk9WCYr3e`~Y(v!<zxM*@l_2h4^s=IH&6wAo$j&P9^KkZV*8ie3ltQ%8TH+%sSDC(@OeKc3J!JQ`Y* zWMp-6Qn zG*5BPV09c!Grtp5SOhe!-YXV0*b>jUqHXj55X+UfFyGe980cf5S`AFN0!GZh2mrrjq6UW&4q!2FCN`s zs-bYF^5DSR26R^{)M4?P7IYh1HIhpyC~x0W>5gvjDoG^a;u5S<40Q~DO`K{Z5wS-L z>c)c4WvV{i)q=eP`hZD|gHf{8OA4?VZ-KEcjR=dZ7}U1&17KGSCO=4<9$AZSDo2vP z6)^oE#`&PZXcQ{l6MDuN@wkS|0&hlZ6qVfIzM-?=5?Hp5f(ZVy&Xn& zaZ`3yiaqCbCc@r$5~XCee_~+PS76pw|LwXzZTl3EpVy!KuTYRRTI0V>K^ATRs$eVK z1Ly%6y{DQq^Syy$e>gMU8Blu$n*nsnW}^;Sl>IVzQ1kDql|V(k;1DK@F}P=SWO0p> zkr<*hlJa^SD3uLKJnDpQir4!(svqdw5_N_h&Ynjyb4ws*zY%N^v$ly*(#Rg|H;nng zNJ4ddT?Qc|NR#|Xb_H{3#pw=pEkzyal-{D?0dVg!aIuHQ{I(8Z@C>xN4M3-L9=1}# zHmGd{*eF=OT?=gX#S6SPOT(~VZ6ye9$*`dWZSaB8A z>suA%_lCL)jd-{>EP67o{NjV2ptpgZdlp|&y|&rOQh-Cfg@Jgu2bu`7T!)|W-_ge+ zy)ec)>svX$Ulry{$nrQHL)U#vklUQp(yd4jf+v6+NW=hGgbZxl31PNbzzT}dT-(u- zWj*U~^j5NpsRSHwGsAImj89!LJAF7VcOlyrQoP>L9#nuK5KAhPQ}wrA!~oS9$r5HG zd$0syu4s>Eww2&a#=`RINp(*n*U)`tH4#dO6mdS?#qujCKVfn)08N$x^vuUBvx;6n zfMF>@^jBD9#Rmo#`!S%(EVhRq&zA03Mw6Fp(?LYZv=pGOHmoKS1L{?eP0KWk_nfZ>nu4uX)K%|%0gu5N;ni<@uIekJC1!6 zKEV080(dLSa#DFj>p)&B1H%^47&cjlQ_;2?Hgd^Pk(Wmx9F40wbOfH)MO>0vinw6D zg`RFfqkI6noAmnqMXwWE)d(cvc6oWPfC3L_QPxwt5p|2bSl2p@)mG)j#~#9J0;@Q& z9?VDv$l0^O;AoFV*lhdlR!3aue8P-n4;XtRm)SO2_5?#bs&y@HZ`Ji!Wj)s2*d1H` z7kDINfe76QEnz|(kp3(ZQWAOv8e~3#9S2NfOKjR1K-X$?gGN96TquaFCyxTIIElIN zi`N3!V@EWi@$I#ACOCx5ttbnqU{uMB;&M8%agL~XyuGnRf>bA_GuR&?0Wo3~AFNkm z)q|xjN1{v;iAU$>G_o8dLtDM-sq`3@3at$c*m5_TAW-feAlUu^OR}izz~bz8c+^q} zifcm3XPqib%IUrhXoN=N-RjE0tFI!R zCeFEm-&oEk7Pd@E4!4_srZBxUIgv|yCG}0jS5%K3J9fOVC7iu+C>)N_(+hWwPkrRF zP3WA68d!T)rEH#}Y@ANskXCO!x$BrNCbtO^Dyl@`Dzn~qa%DL;ntqmSx-Ol*^Uk~O zDk!nr`j4E1hFSYLtj!CkfIJ16cmuOG(4@>p)cr6L>x^jY-bQ1X1}|_PWeIATb?e)* z3#@?Em^5J!y9Hy$Hm>Z?;9VO>2vsb+V)GLAox4q!%cZ(|!=!;O36u?;;#DPhbdgd? z@`?*b;qz)6#u>HqUNrAhs$aLzyCIkIN7G)f6L}R;;XN5msdy!~SIEcQ8vmaJ>>ofKVvyU~2P?zK8ZnoxQEw?E;NJ3CiV|rpf!Q)I zBF3+eQwxnm*>OymTzE&cj;vtDNb1v*-Lo#q8M*UV4Xdx+2xn)?z0jt;=5CcQ?`gv(Gj2h6^0D5e5XE7Gtf}%K=tE-|_ zi0g@y9$!b%NiIW=Wq|A@YDp0MkDc)Ny08<~zWbx`YJ`X zEh(}?8mv1iyZrLC?{%Tyj$BXOnn+I)t+Xi>Iq??j+_g`8^~9c;jY<+*Lp*sHoAjVE zUk~ltI*~_h75p`1|3PmSf27+Ui%r-6Os@a&UpK)|p8ii|^|=>ORrNtg{xz^l%8Pft zp})keaRDL<#$&a7Hm6?HN7o{pm14kUyEr4r+Az+WDJJ*bd>MXCpKZ`=j)ufT_?hCg z3UpiEm`zmsNp=1sG?|O_W>`rmnOum@bRcc@9)nusHTAiBDx!dP zTNk-MIqgONyy&VGE?@7%Q-0>K`Yj~&zB_!O_|*rOuEI`4$TlMdZ5br&Ze+em>u{Ff}Sf5w2 zGz2pmur;=shNfVcZHo@mm?pN8xj;$e1J}RjZXUK8G*pC$5fCDjevb!F1H{?MMkB z*Fq_lS2lq4M-FWE2adkOz%rBGH#F(?#lo8)IYY^vZ6&eNFJ^q9{Qm7`KrClt({KJ# zI4%YcyNIq7n9ceATAkbriJ5@Jbg^{=wl>J1YcC*K{9u`qfDRS0&UJ13!dTM>uTW!Y zlyc&NMI+))_jdKxHZdBBit3W9g8%rq>?#j;XT#|bUy1NX;TGO%Wl_f3>qVNJ7)&SY z6_oO*sxOev)?;5#2j&%0%BT9`!Fa+wE0eeC*!AlFekBY^keBH}`Y@LQc2KDd?6mqU zLRMohtC*9D%n4!gSMMX61nd{`07`h!_pa-uN)a_3(rwr>ieM`!fj*4=)Oa-H!x=F` z0XS>?9&{=Rrd4nCbDE`h(j(R`V3PGLX2k710<1IwBeFvh&z@x&V3Z1FWz~`4Sx!^P z*p*1+6#0MY3jH*CC|dh~wel|@Tig6&p0pB5ro^}CiYlTnhCV3!>8BH+TtL{$cCrb$ zP!`PJDIps`D&%M6$EW}Z;eI}=28@y2@-rBVK#y3^lS(xfa4UrrlB&dm7G9!C;!T2+ z-W=(5Ym+0`L4|i?_aE4Lx+t*yuSlA_O7F({RfgSJWtg=sE)Z##k7CMgmj`EHt|YQ& zxG>clb_l8-5q&G8vT?cT!X_=)PLt3#r;44yq)YP1uEs7WS%_wQs$WIbCAu02_e&Zi zPa%SG<7u*mTPp#<+;MhiL}?pR`R*LAri+!NLW7xPzKXiIWVWVKzR0J^*a79q;VC+W)!OlMehF!6zcc5cws&AJzFjJY9J)8CZ`LnG3<32gX@8 z|Jw_8=TDK9U^bZbE61?~2R2|JWctO|o}j;p{^hV=IS7xyrJ(@wR{6CjKtJ@F(O;nJ zdK&Qe{kuKtc9sp(LQ_S1zc-7Wpp_X@SpmaE?dRl`_0DRFB~N$Bv!PBNAvarVExzzA zW%N$B+FM_*vz!lfMAGiKj74*D?%OQZWIfVyy8(=?mh6OEwv0V5LeM9`WK`#wmA49} zVOp@61@M_kGh||a3x9!!nh4eCHKM4?aVQP?oJLnqufCC2WuE-A=opS{ncMT8Fa0_F z0>UU%O&nkw1`{-j)8CNPbDzD6R4>2&&)L32Poo0;UUX&$VdV}nPq11-yx;N!@nDON zFm#9ytjJe9U#|kRwS7J2D~l{l?nAnPInTBi$q>r~6}4FiU+vANSfF9)!>`QjIdE?> z7U=xp2d+93(!}7iM#DJ89bAp*LB|0qb81)qBURY;2-2QKYFH! zj0~Cx70W+^wgknCX1fc|FwKB{Na#O-CbQrygK370y;`?!^--?LKtbWPFNA$+7|>Ar zz}5wsf<_I!iJ(@nrMk$VXGe5akD!iO3#WXT$O+6Q9@A*Zp|QQMSOGMzDE@=FENj6L zogJMdMs64!)M^nl9U@nvNmlR;X5#5B2)nUS!ROxhKC6`3!}eWn7dgL~r=LH11lyyb zi;#K!i}ulBSBoqnv06?EE=S9KK@h8XoGW}moan6$a>IX!4oZzW>DbB)=d2W zf@Zo5{8PN7X0ofjfl#K4)@4R!F5(5s$li40-x+k_h8aI@?{on@b#Wj5dXN4i1Wo#{ zy?SXi*4p6WLe*vMXS3dokItaixyYo5)ry})PHhfYzz&VjY8M0{;EYzT%u+Q7u3Ot- zFpbhG%K@{=HWxAF$1DotO#X`Vc`jNT&b(porcIQ8-@Ex6ZjWy&cP3>~GMsQ>_Oo=> zH&?$QIH*RZ`2#y0#PX0n+D=ySUNs|(_DRB-N3c;0f6txJnAmZ$xFrB5TA6t~VsNz_da2#3(}Exr2pJYQEfZ+=Aobsh0^laK zHXUUm)lwX7Bjm4>ua|n}oT#UXMz9{UuN&q63F(NNSF2+a)BUp!Z0r$1(}|?_jQ3We z#Nk)Xwq3fdyXzK~yGG~5_}CT~`J0j9oCD6LC_Cd_lL(G{50h-rcc@yMsApWLDB}f( zF6raL$Arq3^kc8JQKWYRG{{E)^AKox5Ie%$3k`DW`oouROTb#>t;H*5C9LGYC3>}5 z?Rm^1Fm)oswy+=DO^|U8i=ij4 zRkUqKT%w+Q*X3y4bq)`)^?$%_H10{?5J8VSXgG!vEE(c1_nViq(v;cC%n{kC+Ze+Z zxVN5)m2Alwzmg=A#NA(WDK20B?`0SVl633AYPU;ZuvIBzMjQvX1^L&ReFR$q(8S?44tty8RC3;PmyU!wI)f{U?@bynT2G zF>YTY9tt=kOp3k%%s&Opv)(G~%*$+1n@w0f1d|GedWH;(1eP*sVQc{X;Lzx;@i0VC z(>9`QVjyeCB+B}+l@L?#R!_=#qJTNG@ZH-SMO-fZx)L^&b74uw-95q=i0159$l;rz z$1g%WDoDo1tDz{Matb@rXe;cMKpr*dPy_nl_q8?weBo1z*R^yCkBw7&n{r+0LLv~R z*f~#|B6BzlxLdcn9@? z%bS7A1CZQfz-85{dCgjt_srL^q>$kdEVu5)FgGVuv(uzDeqr=%--vw!2|r{2+W510 z_<|d?F+0#tXLp?}^Jvr*4{Zr!35%$L5s_f9`D(!_h2xsK@1{@Q@R&#FPi(Ea&2*On zdu1Ru3=3Ea1hA2S^*zDAsUwuYHlc4`e)6#YXyCvQ^6S8^L;8S-%~K>C6eS;`Eh^{y zjj>WXfAzj=c5d|qH*M|S+3)K>r!H#lyP@9D6mzhv3yUB4woH99k8bUo9d$=f0!N1t z7o8%N^L-xv*X{E_-!;-Htws;R-Ov3}_~50#$+Y_-naxY}BESXfTYq<>g@xCgMx^oohrbTOG#bes1@Du1 zeVKlqJA~&hlA~*{((T9!Zs!hLuK;%y4RBYsj*pSIVxNHkHf!0=&13u=#(y85=Sc^* z2l$x7Gv;uOjrZdG5%SL%^FwYgxu4q$yv^a<9Qys{D7xEd4cC6m?uEJl;1=&XZ(vnD zz#Zq;xQ&4QY5MN9p9Ah)xZeVMo^T8IB0e7`_v5+NJV^rAevV_{l2q_YW85$K7m;tm zeenu0oM3J{+5Pw;aLI9t7Jf#!Lpb*m?#Jl(4ZPosdmR>@;_k;iK8M%0Fz1)KPlNX0 z9s0{Pi8~-X1wOe=c*WwV7L!FIqp&WoqU(WBJG7k@8h_aJ={P1 zq|G!wywBm= zA}68uX$0#wmm!w813OKpLa-ZP7fZb%j!~KB!1*t*@c5?S-|EtUbH-uC<5P?q9oe?drAKns2q|+;5(H z|GBrHd-B!)`Rcb`{l=?bd-bcY{*PDx=GDLc?p5DC_T8fvl$g-}*MCi}l5TQ)7iX8! z>CKE)@e zCaE;j%g4H!v|?t=Ojnbyq>sM92Mlg%y6K-zFE3A>rGe?Gvw42HNvHSRmTo#t989kq zZ3;VXd!AB?+cYy*#xv~f^SVK%;%OY3Q_uT}AJ@z#w`1|jrRRf$jqGX)l_pRj#Y~gWFYUT)5rboA7SqigJ8)`ZDVA;y zvZKMJrSw^QG-g!7sn(}-vzJ}(Wg|bcV=)bEoLNaXwH=GgIFn}AG4dn(B0! z{rNHr>k2-aQzqEOoLXtp!^fKB2qw~$JG+`{CCz5511y0%j3Jos#PSjwvOHtWR;xU( z>fH3~RA*+LaU7M48Odqig_JRcX@I_*o;_o(Frr$>=3&2$WtY|j#;HCJZrN4Q?+ z>&+a#g)gszcHx(F#{cHj3oa+e&w@lTGqcnQCU#fOQaal_x-!$%^i%*sI^A?nZ(@+) z02FF^*r#3i}`QqsV|5lp=4onB6#Sxz^-KwDSSSJ}L0@vLxkW+~TnU1{Fd z)$~_3?_Aux+ddh~;Jn{DAE=z=eA9au&-#4RO|ml8^p=^(fwiX2y4f!eeloe zN8oAkgHhq|XX|G7EH-`SRC>0lgZ*3(?B%KS^2ftrg7PmvH#IfIFc82svT`<{l$&oZ z$FgAWAYe4gUCmJCEMf0q;G4ZiDrb529<7`e*n6yUR%Gw-%2|oMCn{%U_MWVqRoHu~ za#m&U~CT{r>M zi#DhX&I{qo0KPQll?8n4-2{B>JqP&Mdmiwy_X6N!??u4J-b;Xwy>|dU_TCBj*n4*+ zJ!%P3PbIzF3@@jl^vE*fi50vzyBW{+R+>HKW)I}C3Q?E`$6it(&6PouDfWMJ1lX~w zS!TrIIkz$xGx3Buz==2ODY)My9)gK5 zl`KJtfEXee4i@A!Gr=8Kn6+Y);PJbqNzXqE%re@>MH({@3oTrjqd?P!^-E0+$|Rkh zGw0bu85PIc*DPRcOgFhbi{0r_7<4v!YM;bS*GbWo^7yn7m)3n3n~1;4dCO*tTG0Gm z&GGirEbO<)wagrx*Mi(8@dWfoH^bswGc>)pBL90385VX75iz&`pATu^D*qC2| zgEnP@&e++6q;*5`Y=Gt}XEoRw#{CxO|9UJve<=q0&u}rrH`8OI=3p#y0l#FHTJxNT z0v;~6v9f?qBjpUE8N*8pnspRx1PTmW?BSh2!u{RNI;4El>zv<=+mV33*}#}BmF5uM zwlXZv0^R937~wXywpAEIH@5=K+bho_Ai=>791wP}v+_L3%5bm?2iBS0Y|K2)>|tZr z!Cp3o9qeOc*unnF3($tsI9kLJY&(vYDlZ`HVMhmWWS_f?jU(*bK{n1hx}1%(j;>(i ztfNEhnX?$S%$~^(R@gJy!D04Hc5sA^S-`lss5BJ^ zm1fD(=mw=(rt(;W>x#|U8mkU!jq?sF`5W*Vb1W}7s0431s0438JKo^;w#`|7?>MOB z7adgccd0q<_+4^P2`)RR1n)t6tikV!%~^i$JE-IzIH=@n)I8?+U3E|iK6FqCKI&g@ zw^z2+NcVau5PX`OCeCGZ$ph7Bwi@h7ukM`C2ZS5I?@n?#X|8dubGLlOnzST0dbtPa zQ?%I~?PN&H;fEvph!uiclQ&y5&=!z(a_z~Rl3>x?S9({Ts`Z$)6WkE2&1ycAs`YCA zMpRsZq~^M7k807^(Tie2iM9C|OUVZPZSIeDg#sOYH_Y~xYUpE5(y5JbCL*bny(;*t zzx#;zv1pWlFE2~r8zMBM%oM-CTfRpFpECnx%Y3{2JM&C+`xe_>{&7pEd|RcY5rt4I z_&Te@hWNtuI_8Q&w(2;@_eQ?2w()x!+(YPC0pN7 zxE7-=S0$>fC9t-+5?J+7S<|$yDbklXMwE89MpL%U?Ql@PG;a&4+ilJE%8jAbGOJGu z_87s+S*f=z;#rA0gG4DSydR?t&9r3|!9y%%Y^%p^k+L#g)nhZ)@a_Cob=jZ? z{!+e>&6cHte~@7gn;sf@!lh5e>*+*2O@^QG@_EnZM&hI4nT2e{i%fa>*D_vU)(@op i@U(vhf>Xt^pIa;h(+hquUG`mtiMZOI%P1nkoqqrx3emU# literal 0 HcmV?d00001 diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff b/packages/extension/media/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff new file mode 100644 index 0000000000000000000000000000000000000000..67807b0bd4f867853271f5917fb3adf377f93f53 GIT binary patch literal 19412 zcmY&`&K^keg1Ja!Xc;UkM5@@HSxAD!R_q>yYN zITp6R-GA(U;sKch0KnklYJ85s1j?~h;F4;oAdfJ5Ck zmb<~SbXJoobWRTrD?Bx(mbSojmy7J0my8-PX|<0qOpek+(y=Gnsx=#7U6pGNoMSa1!kZ||oC3tpXRyXgQ zF0`+$n&X@w?X_+}4zgCoh;OML7UO@LkP`cJq$v`Yv4PXA)^mwu)jO5zW&Ta;wrgG0 z6278;LI|JVn35@74S|So3El~ayDUMv08~>17{Hzld)q3L@iE5>3Fu0(gw%GUqXbiy z-f|zPaRK_4cPmRToR3*;%?^>65($Du&cq(lC8(K6%$SuJ%LEb=+&x>b!0-3>Z9EUg z`Br=%MdD^u(SJ=QPdBeqnqrHL{H=OVZN(IErQ%_aEV=NKn~54@3Q-77nl3%kj(uzN zzG^1>kYt*CCytHO9Z_#r)SOzVF<( z>+7(hPmU>DIMVcxjZ0$BRUK!hv`VD(7`-^hwrl2L77xXYfb+}kS=!4z65qAdZ4Jfb z)Dl@tZ_gdgNz33}f6#s^$atjI>JX*bn2gt*qTuZe#RO(%2I^?@@q;nqmQ>ak|95Q= z67uUyb8f$Y{}=y4j7@A-3@_$92hDR9SDmpXIbFQMRyRKcZ|nBCi^xeGBuqP2_!Q_s zP3ni?h~_r@%!P|Ns5RHUzyr9#@8QzrVONLI{cr~dSC1mE7_0TH?!$mmc+7}`QN;EQ z_Ov~;P;eD&E8Eiq;FxCa^OzD$dIriS(sC$1EACs2X*0+3GOLYCxk^X!QsD;(G z$q7rE6sNtXtNT$movT4p!K{A1IXS!L$vC#5^-pg3-F#*k`*ub_fiJ zEWM?!T0i;^A2bF}9Q<+=poDNkNrW8MsNK&F7glq=<+Qg5A$VVjy~<6_N(n}C!{-&9 zDyL(v7*-DV9@+O~Rg}z-Y)7MEi{ll@nKcF-6Cq`Lx{bAEuvRE&61Jk2MNN2BD`%%5 z>6_OzYsfYTg-t6eU8N_ALWV+z(3BOUS_aISGwYRSOC&fdq&`~?*GtRa*j(L1|KS*~ zNLa`km>)F>F0ppeX!<=4P3cAWpXyqh9L7`wK zjh98u7)Tg~b+MC*JVBu?Aud9Lsc!ZI{K?Qz3c2+HB}NMSz{d?lfP_g1tCPn<)ter9 zHM7~_&@7%1Hs)v4oM_+bGm>3?#?3~sNgQh3p?`n&*=36{3o$z$@+l;|mbU#?`^-!~ z@V!boeUpa-gRZp1lT0U(dfMf;AD_oeIgmb-XT9=x;sB337>=!)@&=t4Ws508zpCl_ zu5`ooowOYHQ#!%^BOggo>;v6bnzwj8D7nQ=O}J;AkC1|`At|DCt$nt0CpX9l7r4|| zTb=BQ{Kk@87VGmhaI^awaLKrfXX}_8^4-p z3XzW65n)4T;sPeAqSi@i{hz#NN`Gbr8wmMwQ3Tl_ozskA6MXstajchG(*9%;_X1>| zc5ZWc#%Ciuaqfs(vbic9_GOKf7u!~fvq;r6v@`ilIkWTe6L&I| zcasczNN(M$9PRd@)sZkc%EP_>gV{Tk4tBf-`7or_?U6B!l&I~Fa+#wP7cKE30~Tz8 zguKBHFgXH264?@Z;yUPjpZnboZ=5?0^;Y7P!4{H2&80dMgDlgOE-tT=iIH(@7=Z8W zKo`tkRI4-a2XdubvX!y>&4cuB%Mh0^Pkq!Ef6b)I>zgF$unSXREFxPVF-C27U`?KV z#841qxrwxIu&8vtwk5)p?e;VMmju8&-}TrDz(eVW{!k90AC@bSXm`o|qMUeqzEZ}L zQOKIhcranZ#l(j6ts?IEw7@VNldakI?E@j#t%7BXmPz1QlHj_a8hSK7;P-*RFO?H3 z8W+<;w(!8#C7)_cGIW))nj36C02Uq)_yQEVygzm7+Sj@VqVMW@?cZ5WtIVI_ndiFm zKq`uO<;o!bt5kLZGQQ9_@x2rKEd^8iJ*Zg#A~?(_6BUFo(ToWQG#3mPbE|RZsD&9to z>uwvU8v$pfdc@&2(szU=fN?swkePLU~!^x7j$?)g^#GCnv|GBU);_Y9djF z-SL;3)nPUyWRwpSAHBO<>z=MuV06G7_kA13@5unwo5gGAp~nG>a)j=V*$KHm_x<=m z_t8^r8piR#JZR|Rk)y3o6=u2EnEfFzFth9r96JWC=p31mi*WB9V@Sys?F<@ZJpUktaQyLFE@@g=7o zwMiZohE2TvyUzBK6(TGJt&HvIiHJus^|rD4&fea9zaTQ>&wRfaM{Uc(n=6lqnnH;->8Jh-W3>cU|2~f}zQI(4kY_PUz$~NpWsS;&b`6GJ2jFLiXW7G)*U* z!6K)hIeEMghiEtpUQ)}z@x4evh>809aBoYF4{}p8od{nbuRQZcR$*P%h@T}AiL^18 zdc$TklBQ#X)T`nT+9iU~A6}Ei0)@s_%*RB5$V*vrglewh&lho3VdgV3KU^iXfq0iQ4rJT+)V)WA#Fd;n4;ZTO)0%8r;J+D-kU+R@9pnM?mT zRj7Jt*NHYccXNf+kp1E~qasJD3AuixsMwo2F-^iCiV=rOmA*mT(R(&Ldsu8SXvpm- zDU#yGw|h8anl1-4w)CGShx3i5xr!qJFFQRY^g$`hZBV=gDFvm}$PpyA=aHI)=ItZX z@+wO+(kd93xm6^BU05xl>SWaEA?C#T+rWmt9)X=$To@ro$SgL>>_kSZH~RDEGWVd> z<71oBt=(ae0GIl1f&1hL>2br*lp4F~1g~zF9enR*nm}3w?gkbP(2$B|WDFYx7d6CV z`dH8lau-*DC@xcKnN(o=3jz&zKP#T^C)g}e9gZv4%<|Kl`Wi*7l+gM?EQJ1$uAlbS z8(V=?3x$)?*5lreC0O;lh0p;aZ2m3Y>>W{~sMdv~I#(2?2nqjKi_eP2>grN~p3qay zZh*0khsn@GhP*jqvj2u@C?vS18cOi}kYmM-v>4ro>#Y&5RrC~VHYS5yF?a~aOMeuG ztX;cwsJEeI)k4+vZ$`EPe?-Y)$Wctha4b9wSNSMUY;K@>n<-f=HIno3J7GtwD+Z3F zQ-vdt)t)GQQ2|sgTrLJqhtYZQjZ;C2JmQa+ID`W4-CjFd*azcpMgkNt;O{*~R@5wL z1TgYBa~X>zAGm?WSba)%SG$IUN->15vtpWhCot!|>-|)H&j(#}utB?NpAY`da$g7X z7W)q74h=Q46ZkBp26GIAE!76yB3hEX2Er2*xza3#7MAtb6r~^n9=}?XsEhIC`^m{~ z%M2(pM3VKk3zLSWOunw?F)*mCRav*|7dJ74RL%X{9Hry(;WtNE2}AwJbL6^hgl)D& zhMv0i6|E|tKYMdC5}>h=Q8rM#n={Ky1Ri1nm>BC?(i)x2r-3DeVCN|7r}7+mEXbjr zC55N!-%{A%Yhlc>NuH& zQK1aWPqJxp$1jTK`3@;YWT{38bI)AOEO60CVFCVi!bT}WjKT=UbW$}vD3Arz(?&7? z#4R@uyUpW192<00`a*fg-EKA~1^1wC9`p`lZuFD}>x}FL!L2L7rF`87@BTj_WxkGt zyimc?M^kox-u!t2h4{k)k+g`W)1_XB$m?UfV@uA^=5 zi{ zJoE|jco{hl{bjC@=Vs^7kPkyq}5lfbQ$)4{HQ69V`M@cbv$ zZheQ(=!@bzp0nd>E~_vhg*|H4!zIY#Hcjq5B>*h$@~3=c?brRZ3dxmPNs*M1vyj!M z^{+*gu+I|AhjUDH7Dq^I5O-<&^Dml+G-?cN!=rEL5ls;Tl~>){{A{@t**7fy!7|39 zf@~znb(6re8D?%@MXg(zSrKDw1%13Gb0$xtL`VH=IHjr%RmC11rleg0(*%oHu%a5C za_e=HoE)k+qBy8@1Zhnt0?F(7YzU>j9gqqT>zqtQoj_j0i)4E01xW+)r!DAl2xlR* z<~zovzLB|&`k(sPSRz2RHlK*f)W-$dYh_X#;$5INO`taXn?sxz{$lOv3f$B`4>rkB z#8$2w7UBnQO3r=({o4v1jI784oGFd(Tkg~nszfT0aH2#~Hp^HumMVzXEcHewa#dcY zp1?G08snVmqJO+nkW;hIaGSrc!{@zdM@!KV+C;)}Ik>PHN3&D2vy3G$A${L0di_GH)qL*mI#;a$mc zfAXNS3t7tG9zzLX6I%3oLG@eSM}T$LAIH4lIi)~0pIv(HQPqt|KKjOFJ7O{xr=+D) zTU(*8+Y29M!RMRT+xag`oSt`@(Ld?VJXDObed!BI!}MSG{8I=2KuJH<8c&6r%9{6tHj&1wx@gk2A6UTT2oGKn89;a!(lSLzcS>)6b7S z0K>hcCw}X- zU&xvo(SzHs)6|KS#Zq1Ais$Azz6{t@24X5fv<-rn; zr#amshzNYw3S|BXYKLqW@BX`4HXm7>pDHvDy_QVdit_5!t(gq_o*e`p`pArlaWO^fXtujiU#vA~M!29LoKqXKYnG|(#+06>&L)&kF& ziH>`iilK@)!P>f=QdlGg?}F=RbTQB|9URWTK}2+C&!MBsmwleG;NkZ7Ym3(?b?`zm zy~W2GTAFR~$mFxf69oKOB&^6;r-m44hY?Pl-(i0V>o~T~+260HP9-$=dbuuN;(RtZ z-!5Z{th5ljhZ+P^weq1Bj0@bHzcY=571TE;we+{VBRxKcNA2Uj?T6u|BOPv% zRP>K%Y-ri*LROlBi1{N3+{?Az-S3)2(>(L$m*xmKo=4hCoN1S4ye1978P})C6S?nwkr3IE0y z#OHG3sd}o3+;zn+&)_{s4 zC}l=l;T4J(Fea(U@s0FQ7|#>Dy_o|bur{3TY;n}By=tU~{Uh~Ah(?zRtO1vfSE46J zCDAsFC#qUMd-vtxApbna=?RmO7OfWRmho0@3B_(WenDKJfu4G+oNddDEwttNHo)a(X>TL8S*{Vp1_IkOf1&g_J-BQ0r{TXHra|3u1W`@-~D91p7g z0NoQ|qKCovx(Q?1?=F(#mw4}^dI>ro{L`k4`#c0kYK^mt#TAh6lZVh>duS;?U4;&6 z%4mc)#J7BBsv1`onQ7IyjRo#O1DKkc3 zB8Bs17tCr#i5Lmyo56er9#H(`ZkKP+3jw0wX@4~L zoTic(g@wnM30qt<_@07hm7>~kTi-Rm>~*|CyxF4Ou2+28_a8&24U@1d3VL%c!J>aZ z?iFP=YK^>~YBUGb-w$+Am>`K*^yR}Nhs=Jtajw#+OYGhblh2Z0|K=0M!oo@>lf=K+ zl0-xv4Z4h;Jh?hvNGB{zuIz{E4pt~XzuvCl(I8Wau~oY5{cJ)N3nxvGe7yK% zK;`3S^@AMlV}}a$y6!p6(WU6|vw_`?yHWJt+jEXHb2J(nNMMRAPbu1K-qm~ekbQzM zf!?KBY!2#2h_9=7@CmHELDkU>7u0}4xYX;UAhjn1^4V9>x{8)WudFrKtk%n&22r#@ z1wAYrtIY@_+LncX9uyhhGG?fping9t7C-_?e|1m~Wp^?C1Q`e}lHuDmXuNu>fm z(#^UScKG?FOksOiREx^Jymz4LP9_c`Mkzl!{COZ`g@?ijrY@OztE3{hZjeKF?^;x# z<-o*a5`dz4cJ6X=M^#F&*%2WGDa|q$VA7X0E-U>N1l0FGlL)AFjLrrLx^DQ-4%cB= zKcn_S$=d2A9Y|umJK4^p?yFNy)mb@GSc7P*5%?hkF-|}#P`PQw6rYM20;>A70_S#S z9rp2+0eWp4hvGv?pO9oATl0aLj8%9 ze7%m}bK$9&G6z1vi17@;vS#H>a8PyT=)$0O^5XOIq@J* zKkzPZvfMr}NXI1Z!w0EbNGII5Z|oOS>RqBBV~iHE(Ak)6SU#^JrUxu!e=1{Qx?#ZH z_N%o(4887qY8ZVEp>eKTfeWQg4Jrb6t?~GiPsPM{fa&O0Ty$e$9L9py{r|LYAf>oBP@n$qcaN>{WHQde}16tCpQ? zNu(;M=YTP94JnlRZx-dl6)D{uAB9@R$~cZhM~J48OH5_#g}d%w@B-yTNn`+nBAu@h zH%r!u%fy;s30mdxm@lmAu46aTK3hS?AJpV5S1i&+i0k8D zXa31;58b@l52}*aSCM39@o>a}4X25|F3&35_rmMD!JI4KqQpCyXekJ&IlBmy!iHf( zn{IE`nsaA84uE!UYYF>#-VVgLq<4AgTlcE_j_TgOm$#e08o`(QsY;|cbysO;=1vQ^ z8BirjnZ12{Z1wPHFDhCqZzzuToS7Ar-}CCBxn3n(^Ccnb!j6K<*;T%{=6zd~9)rQO zNqjWpf53HA)q=<{w)@KV5fIFHi4f&?W=&CW5lM!e3dYooUvC>S&;!BF9KI%k zacEnaBOlW;S9eA?&{h-p{#}eL9mOcL=+d)$T}W$R5o;92o*rW-iawTG5!|;@ldudM z?V%h<=`{4RU>6bmFeg)GD&u;5gx5C zd0GMg4udYwq%tPpI23E``l5#ALq0}Zxe?mz?$teRS7N=b)XdrXCp&)d!FtW2b7$(Y zJgu`hT~wGEt+Hxi{gA_2wLZ+z%jLqiK!!8HvZNqslUIl{1}{5XE1Z5{y{NUEGQQGj zOPZ$PDb?YUJ0wBR7YngsdZsdbz0|z0Bi$+!7AbtJaa23n;_yBAvPJ(Lx=VMi;@8v5 z#Xm)S&0P$Ph5i@M-l7+J3!{L$&Klaqo0vx)gyB-Poi!DxXwjAo6%FRy*Qv9yp@OJe z6XJd#>1oz@6v=_BdDUerdXT=OCIS9zBBuq4Me*vcOsr$dOiGz<=_-GT1fs&zlvF&C zk%uTDFuM5>TgNS25oVFwk9$Pib`~iRYITyc4Sk)9{&!FxE0ff`TGbT9f5%)~`a|!! zF5qw?wVB!zB1(bM9|2z*P3s{KDn#kI)Se-n%TA31Y4*#+G_40h6}hQ3iy|Z#Zr?vF7;`=zq~7l} zH9;III9>zLU^!o`@0hyM+3@xnEu$K>HlciP-Q&K={KvO4jwbHiwd*NZ>ZuEOG7HS> z*k6imR@kB}!nuCqZWl^ANE;Tzqf!HGCy?Tx^7K~MEg480)YGqYJwD7xN(nXP@U$vA z8fDY-!#&YKVgvn_Ywbo*nb!fDDTj_B>WOkSY9Q_zngO$^1t^bHSPqFK24(sARS6v3 zHoKn9tYA$>1wD4X8!m>uo&ldC-$j4R(i*Tl@3jfBr8`w}Y_XaS?w+**Dx0;PzR)@vr5eD*65o>TTpWiS` zi6SB6Vm>J&OmE4I!a%_{@!4?tN`Fp-BYklr+zsK(j3N`r6`np_VU3q)#JW56V4&<8 z7+o0F;jbtae_W-){uYxSM$cJqxBPhZHe!cPK6<$a^CQ2rmOg8W8+;mrVoDt3@e)UD zUBSYk?@VS#wMLIC>zev)kE%vk86DbzzgF$A@m0ljiHQ>+#f?(cbL>jdiVZbkQZj-P z*?^|XWrLWcJ(i+I{qHg*+3fUbx-?3}tTP2>K&?9^Cz6Q@=tfV!02Gq?@t`5Y(#i0zUNiCDc<%f9W3x_!KC*&1LS#YxOXkuI#HSadD0T2lGaUC~#)?Mq_@I|O32k(Y?~a-lf_d)js2=qWFogIASPJ8{yOWxGu14_F61H!#0H?0I-5 zj*+H8=--p=SF#voWvumxmH93j!R-gxrO7nMb{b;_{G47*qLY{v^9c}K<#gzxXrs!p?0C9#&6@uHz|ERLRPAj=d)acvft|sL>fxYUh@MWsx6o zgX1$qNmHZ7Rw^!hp`|YFyo+PJTW-Xjm?{>MamtOhnzfS ziJF?9w)CLss3>37HJ!s?v6#s8*vWj`*uM@kA?x1NxKG< zFLeh_%9nU6rf=q@|srk(MV%f6V2vy#OVofj7+mLI25BE-7NLIin2!(Xx}oD zE|GRlB}mEOrNc4LO+!MCdR|WJttE*t^+uPkownnw?G+~MU><199q&bsYPp$JkIdnJ zL8H+g&%;-Tx7=r?Ld~0=EXD*(JJ=H?WynD6e$PwxM<)j2NT>HxAJZ8+G}1E^lA+p3 zn^1}_#M$ha$K*DLi7+-^7%&72mQAhH#4DsmCsfGArWQ4rR1#-Nne5qR^*V2^++*<* zRoLdB#xlrpfdfZ5FHEFdch-OiIwuPe0GHwjr;jGPp+9rPWy(^#Y>2%|)Gn}0Ik8-z z@rGYh%7Drq`}i@F)WsnfPchy4>>0f4dUa=dbR$sM7+p389mB2YFX95oSr3U~+88hP zGwjmhA36m1_>C&$ip^NYlgcm6po*nDPrlMs7`_Tv*{DcXl;VzZZpe)4jYi^JlFd;_ zITdGSqN}Eg%pld)r7S~{>BLo`R4Bj+CJa*~h{=$W852oM>yC$lSBIb@D40YVj;5}~ zqB_XQG|HvI?kt?`ig@;A3-dg3nEI5uj-c%Pv0v#Pn6tuEAX=)mHVj6#qc^2Q3?YU@ zqBqm;RHgvYNPh<||1r8k<#KQ_X0~rCL)e@)nQRjXD-+N~Ie6b0Gs8 z4|3k;<;4!-L)*-`sssII;k40(4cy2rsUT-oIAR7GAFIX6HTvFap6DZeuo=x%jHoS( z+S0mNYb?(?fB7Fbbm(B&mem6fM;U+uJk^q6sji`Iww-OE_z~-g+4`pwPMjCbX24tV z!D+tWOFefVp3-656sItPogS`nm}s+nILleu9L*7>(UK;BWG(BcW2(bA2jlwPMegvPul(e>0pd zZivDPg)MTq!%(|K9bA$$g>QlubCXlCqoRnBHql7_ExSl6RjlF7ojon=e7|C}A!%+p zl(4TC-kcUto`Dx+^JL4@LgTO!((dE4D->41b|Q)ED`tP_*#37g{{SU^t5 z>BEKRvwp+twc9*@ezaK8*dNCc_^V+i9c0Ghd$;X~5Q8b^NJxgc*`f}Cj924)PkTqGQB9?~O z^v^=b_xvEg6E0&@K8<`bX-oaOg&~JWTa(rs(N#c)lJ|M*es;C!VKEy9=51C8Mdead!7MMJq?_R{kIo!L0lfgb#{{0E;);Ja_Gz!0H51?3^bP zf7?m3sqX6W*>7M^XN_d4&S2B=?h8=isNugeohn1gvXebcm5wChNX+;}l>c$DGS(7Ksiz)G%^#|cuc$?^- z>&<@IyjvO)mC8S#O`!Zo)TEV|cdcq{76C@)YPa1~FLtko;KrHww~5HLqixJvtSrC*MKNXXy#@?=#l+Lh|`?CR$bH zc!*8*`kFRmK!4Qu=MpZY$h_y)u-3K=12?bWo5vls0&V$NrxwBD=JZC&YUHD64)c0X zjizwRtsQuXBH(@r*&!Nrf9|AlDX#3TNteq|HO4)%3Z5)W&nE z_I}2x&EO8-3J0;t7-~0xF-wXs64l!2Q?^?N1m^}E%VANBe?s+gNU1IL4qSeZ+>Si$UOA_v_GVSA_ zu_U$q`(gZ@bOwkq{tZ5y9C}@5I%Pil2DC~e(vg3ws|4LZnGNbKM#O%rfm`jP zUcLkxiFPIX8@{%W0ftWVN;?cs`ic{VR+MjOlo0!ttJ9IHcq%Jeyuiw9Fy~sqxWdpS z!z-XAZ&Pm(>0Xzw^%OIL-<9{Ts&VCOH^!`ax|(nPLdMcrPf&ichO$<4L3u_E*qa1N zZr!gqZ3(UuTaSakJUD+VnxIH5_m}V|doD8Z;MXi>t3{`O8@0+A(7QPpkj}VR%s*6& zA|%;zt4Z1WTriL_FY(m|5iJuVAzn!8x(iuMnSJw#hCA5C-R%P}cv4$$f+MiJMt=?e zDWTNxKS)&^X~02`Ce%vHNwd3pG8HA$Je4)tZk&3oe;rpU*xSD&?SUb2r!Fg?g-a>NreO(qz99F3VxV9KZIQB-=kK@G`L$d}Ee7K&3;ti@C zk`&}y=_gM1fZKuC1r`N1d){m1PIm~`uu{2ZLQo32$vp@wFd7Bf$N7Qs5q$=@ z9r~PloRB~?2Nj!%^Tf0-xhhkc1Q|diVFpQ`9}TCxq9`q#m;h#sDby(NN8%QO^(z5; z;r6W7=%s#hOZntMs01@yJ%FP_fQ^}2ZIPi+A;yuk%F#ZW!864(Yq`WPomRQa@d+R=?&C*!H*Xb8(wq=wbMc}tE1A-t}AefaLqdTdPMWb$4 zk`|AL6h=}J^!wgTrpsUY4z__(VGYs~&&4{)xfNh|7G>Ebe2pT!-J>}po6oivuLyj~ z;>+_1t3v$dK4917Hg#W~T%F!7KV~n7`8%xE%j&wb@FG>QrG-5;kN&@<;k=St#$EnoRWZQ;2vSw3p0w84-CO=co?$Z|=^4 zBw_OgafuM9&21z%uNtQtzhG3%P(0fS{KMhH>e;m4Msi@Dk$+urKsNy>Iq$lr? z$%XSw(X`K@7MtZsl-ly^`yAxCdsw;bUC8}8Wm-mCiB&Zx-0gIILq7S| z3kXSAnLH6EjH_Y%H~4Dw`dLtUwKNM)YHQc?A9-9#`AE*a2?p=YnnK))=|8_1)^93pMimK%C5&Y<2Y3zJFk6CoR4C1iBNq$Sk!qIG zkom#DFN=#4!NtzZP*;-@;Q~?8O7sK(#O0ZzP#d0xZ@#YclDWjs>c(HIF+Y!VF)XHb z#m;_xQVi*P&ApSjAWe5sn)tlOhln$e6@<*0P4w6!2yk2yV{y9f*gw$JrWyjDgG|G> zl>UjV3K03HWk^+sxHTz&j!jg01#i4!hx1u3^C0k|8SYSJC^r(m_0&ucC0UTBI1zS% zX+M99vl9kY=&D4}FB7xQ6g&i(j6$C>2U#%AqK81_aV5X{l~jf%N~R012Msj!T1^nE zOikktWK2Ac`=x|cj0_$nqqYnsELu!J67@3kZ;c*;i?louw32nbAPuGEhF`1^s&c<2%^2LwB##S9%iFP6WYbo@1?t zK<6o1e#4@EZnrF-583tngzs%X07Jjy?^*SGxi!j~DtY?$VgNCdp?Zk+v_FV~MVmh^4oLN2-V z!oSGe*Qt%ZZdYz$5vXes@^~slVR8ISlxq8JI;4@d;yeG$#G!gVa0v+)Bz$V4<3;2C zxsf8Wl0g%G?Atpku$?u>e5B`H6b?AyBmK4=xA%^e^=O0KT7{ThZ;MmS5x$rt13##} z4z8mAa5c8-6h}>va@yu&mrP4A#VF9Qqqp7JST9i;mPUr1O4G{0mk+QSKMv6M^mICq zT!kI#?rKv1qpzP-e7bk>HFB{$(Y%NLbh|zFTtsU64VI1FZr>>aqMMluoyUyXuR}9F!1)ZR@0HCge{C z2I5%cp(9DM{uTwuh0M-}RAfxb3GUBdoa)YA;pSDsh9&aankgdn$}{ghEn!hBPlzZx zwH6&C;@i{*u0r?rq>MV>$JO~Zt6rc?9P}AL;Hz9Lx?fH2RZ#|qq?LZuF zb=I$4aId^k(cm}paITtgiJ`aRtLm!rEg~4BbwZqcjT}Pdz|4*bQN+QSY|&)Q5#E<~ zvjT5Vn14;4*$R&bf`h}4#+IJ_;WovK{P5~sW8F2u3R`o0ZagmN-OG~Sg&)6+5pcIKoZW6RdDobJF#?jCBymV84i`~SP(LcUnALY%YP)Tj zGCIy~?h!ra$uJ47@9Xqjav{oa*gXZ0ipSK){@D2x+Yjq6P~{&?R9dUo?)<*O*k|lQ z`?*KiFy2a)NekNEs@Vv+(=p{`Kr1>KII9|=V)Wob_#_gV%vc;F_eu0bWFOREQInm0k+WTGw9HtD4IH^Bp zU9Nz&OTB#CZF#VbNL7J{CEaeys@n}IJwNI`T#5=)43L>T<2_f|%!ypHtprUl63Zk~6(V``y z^J4&EgkhXw;$f;_hF}(8!DG2#^Imvq z>T4Q!8abLMni*OqT3gz8I%9eq`WyymhG0e^##1H_rWB?orbA`~W;5pYpFI56kN$(N ziBA}P1l~sg0?66_rsx07-^btJpl`shKMdsmWb$X>zCjlU5|tx_Dt0sFt!PVAVY}I4 z+X-id<9Joa9z-qIY1Z}xZk@aSk(k9hHJv!Iq|eJDJ&?*(&ElHs+s45S&ah>u%Yu_^ zaqtMbvCj1-f6d-Ld=ijij1YGL$+J&M3;8Ot&zKb=U569n#YbB*!gRoS$cu@b8IRdWdg`9F0ZyhnSiH2>?V4ZGVx@wn; zT!w|Bqr&Qn8@%4DC9+#=X6zD@ZJaUZUy3ZxwA~cv zB~vnL^3~PD^a@u3DcgabuB}s%I}ZpURcb=NGazIETWWPvb&R?X7F^*M7j}-kWbVL|aPw)2FO4 zREPNqj2+)=?goo@j>_sIP}FQ@H5S{#z!CW;&&CEO1_p1hxzR)sraRxI-!vM&Kw=6) zB!CtHi1q(@Z{$7I^d}%WAfOyZf`#!x&|(AvHZ)2GRw6GTV80tMnAytcE0|#o9Rv~- z7)aYV;0F^*S&|Fei;9W)c9<5>fxuD?pjI^asWx%6A$k3Gw!fqPPXH(j*YqV=1W^El zXWvT4-8JFviT**usq}(FqT}xFZXJ)fJH26V8Khu$qwNPE0H^@$KUVpAO$i2&jx^{n z;Dx4pNE zw+9Kp8v#g0DsoY1g_H5YSr=R4NSvv4KR5&Gu(zGJv$s3RTi)=RSG?o}Pr1rDj&p#` ztYI}vS;Pq1zJ;1SX17^y*2xQbDv#x%Jdk_xeV6}SdXV`b?Li9Ams9}&Yz<;r004N} ztX9{0+e{7}s<~H{6sCZg$m=zSiqqW-$Fw%x_4~-Jq$THm_bSi8eHl>ccl&4ykdk}( zn^iD_GQc^&&_baA#lG(a0B?SX(d{=_+Wo7K&rF;S!jBN|`-@<%7*!i1J&SvZbZf%ijjl6M=S93uCN#;!zO_Qp-1Ds|1 zEP2wYJ`fvm1UR_mhok|v4f5&*uU>>^7zBYyY~iqOq1f?JykTdH_U0SB$E$m9q95a; z#U4M3;vfjxQGkXW1YHCHv9YP!eP7rMlPO3M1eo|;}1P^iKP=0c-tln(MJS{lX~AzCMPu- zk&6>{z>sovHyPuvar#1|CV`M_`3ciUc-=S#PCGthNeb(&&CE_A^hq@VA!$1E{tExmIa^9YglhOqbN2QA+l19#j@cYf1hL{j#;kqs}P$8QU zC6#^~|7)8Mh^`u8tlAFVP>I3vCh^VkmP+z0Z>yxh(o{*21TOg zB?ByNC42m1DI}&PG|>15-xdee31jWZ`0vcyOCC=gKAuU6M%D9YgB0b{ zjGilfo+)^qR{mUxu8(&FL%N+g!>Cq>;RQuy;SF*t)ajkN zCBwqSA#ESV4GFLm)0vB>-Jp@3hb8Iuya7XgrmSuIp9@d~^K)UUcsp=i2{@=BmT83C z46&roUe^$ap6tI;L5FRLMIE)tT+oq8>yV#xXJaA>;XPxLoE~3swT)5Mh^FP9i7==3P1)q6+{Kli zEd`S?jbhJlz>>5~()5&c=us=MRHxmmlfPZECSEk{-EK)9`PCDZ=w7=*{(*BAa<9c} zNujn-EZ99({zAJ&+mc;g$Id z70#1*$1Hk8H*Cf->aq1+@j&DMd#;PL*r6bR!ndBFOJK^3umarOwQ+0QwQ={wv~7?& zRUxzg<~wm8P!2_f5IPmZ3IQWgK>`?62pFU3QjF7p2^ug-1E!*42%$|itrAlzDvD2= zQHg1mPS6~kX`arsKxbNHogIoLg@9$&304#WR%yBwYcwED1J-H42I~v$s!f%cwpgEO zTP3C)IzhX1rad~-KAq`6k8yo+0uODJYgQgPTa?EfbQ`tm=p@QZ+?+yh&a9ERIoFvR zlBHfS@;Nfl=eUHPU+Hq<;2L^x13kFawlP`W9V5^0q2~|K^GBUC4xXR~&(MPxZJUzi zy)yFr4SN0#J^#=-L9{K;{za3b98&Dp?Hv{nj z{2~+^004N}Vqjq4WGG@_W?*FD1hN+aF@(*?AOhq;*h~y!4BH@VAnC<$2Fhk(&|(yW zvRN6N7#*N&HY9OgrWD2|D4UPLg!vf*pjEO^jVOp>=)qyXV$iy~ySo=H>n^PC-#4W0 z%2*h3lXM>b6APXH}j_ zI}Q5Xvs&*d4LoW+SNcBllBB$ph`j?N3~J2@)iqM$HFTEASi>36G;3OGjGiBMp#S?l zu+BU!k3nS_r7r!P&NhQMBNpZJf4zF?n8z%w=bY!x{qk;+^}7P6=)0U}Q@gtR*wMft zQB@~D=;9y|jdQ15<9XegP)evJX4Um(;O;p!IohisoUnWFdy=l+VPEGF6?2~}?>|#; z?(hE#T7KEPzJVxBe?vigOuO!$B@(sc3Ma;OW~HU>XjFuUG-|}%wF-6NLAw_oGalkd z_4~i6xdni+v=VsQY{7$}LID5(!27ms+wa@9vbjnPUJG=m8K_kS8mXX3vlgw|wCmKR zTaR9S`VAN~WY~yNW5!LGG-cY1S##zsTDD@At@y-BP%DbTqg*w3J?YW0Eph!riD~TFcR)r?K|o+be{0we>31`C1)00bZfi3|sW4Ge)Y8+vFJaF2oire z6Q%w*9*@UcE$Y4k+e^FZm0k67gIxW+`kdS|b}&XiMSq7>q)bYx2$o>!2#tM`J3!Of z-6gqP{3N;LV!d3FCbcw|CKZjqK>q{y!)|_X0IcwQ+DtC0gcbP84|}u$I@pj*3Huz9g3@`{>+yd*6g1KS(89qAp8!=MX|4OE;Y>cP@cH1c;ddwB&%?1p!gJ1o!rlpf(V^pj0r~kCH=* zWsD*>N^(e{cTvaIu3C46yZT&|jYrl}ORRuc*a}(a0EmPob^v?@M%l{tRjY`Hq-QO; zWx}d0etO%zeU6aoHM+(NS|#i;|GU3e^N}^VyS6T#QHYFX5HiXB>zK<>wcB!b&aoR~ z1Lg>j01-&GF979#J&Om>bGj7(Hhz5YH#QLTb58)iUH9O>KTh$L%of0nUg$XVOsuMY z_ZbIlIl}<}{;GojfOcD%=iu@vX|%{qgJ(_ur-nx>OOd8py=BJjbt@gP?tZu*>%IL%@9#s4EKSk6fByx5W|k&HtwtOSyzH0jwYpX}diyi( z>w}97t)jL6FM9rS&s}%icFRZ3JK;(D?6$_FQ42ZXkM+2{W^MnL7oIUHv?m^Sy?M<* z+Eq=7R30)`Dx0=%523N!~#qE^`M%ty+hGH2Y%l%#!bup`_#s zFZO+@wiB3N7lLar`?*10Ejn&-l03!clCA9Q{H5j9OOke|?=q5UO;d0b_F@+aw+OOB z1UUvUW+1W-xX?%=d`#eK`DfP1^XEsxV*0Xj{4r5s&7@nxl$HrA(~qZC!o z4GnD-jJ7r`hJo;Lfy||St|{0&RYcq*Y(txb$sonpdjRaXoPm=7cIVvQ9iz40bnj_C z3DXR4>O`e`{sm2rP>|&T#NPxF)klYd3zeM<=KwCQjvCw7pPbUhe?KM4aJP!gJ0VR>p2ncjMq&9jfH1sRUAdUU02X^4IL=^R z+cK{L%09!BIrOy$7-JV&5VD;8x+8>hM1}$1oxn^I^O3NCCo+@^Qa)i&t|})oJ+$RYib>jAC8GoMs%gCc z8jAcL#OrvCE-H{Yy%XMlS(c1-namSrQIPI`bJB4OR6VJPeM;DU304?xfR~&39Wx?IV=^t{xy&` zFGGCucm@|Q>A0}EjMUPpCGR~0ko~ryTC!7ZUSi`~bVMk~^&EN92nrfQhbEv?lhCCp z=+-p!Xa@9ZCiH36S{us$M09!oHK`*I{4kdTe5n*E^%X(Y9?$Teb*vlyFa;uOi*-@(-nbBvYd( z=4N%|hnrla8{I&gYF1%ikad(dj0^D-Uy5yrcG}$e&gbn%eB_b<~mq<@I1N&^pI9P`Ah(#l0W#<_tW*URku`0uo?KPRM zFrS)<|Esnhwn%USW}`)uYhW(gcwukV4G5A2^pG*q3FQERiM4ltlg@NY^x40J>r z7EKLc>43Ht;XrUxb4h`x1NvGz1MCwaF&Jh5(RF}vCL)1pq@^0POoNtd5QR%z*Gd{g zr32PlL<7MsttADW4%lmv11((BMz)6OI>0#-xhPV&W&qoDfO{tA4-{e%lxLxYTCx{v z;to0+q3%2{9w6|}AoI-t{u6}as3=*En&r|I+o4-Kh#4Tw!1FmLuw(_+tiYBP*ewNP z2ADJOCFdmWti+R*c(W3prQpv1!=GoU@q&Nn#rB6sZ*;OH)`MDOWAr`D2C+L?+^r|L ziU84^0(xOe4jj11c>uEl!15LP{&E24GN>S-HJ7+IslC|r1lS(AqI#IhHx_2Yw}sCI zqc9%D@)%|)r1%Uxly*N131}dJKiiNG(@Hg(g+eDmVrvL0Oj{C8VKM?&ITp1qC~=WK zlN@&ts0`JLMETNEnGbQvqy<*0`Ow%fn&MrNJXEHj(r_0es#n$p1DQiJ&FNub8mU7O zsb)P2lcd}s4@%R;>D?*ItCjL>JWi3GkyDvo-&j>0E*9fT%PNsmiVi19B`hjS@1|I} z%%h<(g^EFOWjI0jRftj@n`MoTsmTu2qQp?URH~u0T8&1;6LHH#9G5nh#q$KvQ=lA^ zLQ{BwrsQD|1f0Jya~?j=U!c{lJWF+W!WYk)+}a5KbRwWrDX%O3rlC4wkr&wo$H(Cv zu%QK$4b6}5G51vrtEMqHKe2@z_jjX;Civ>O ztWZ!+*>)@$a#VbXF_h#Vwo?;eIx(vtS?ETzN_2QwBU$66Ezf=gw(D`J8-E? zNGtt;k<(-^%n*ZqF~*GIyJ}MO6Px=D&i*v@iBH|a+9oB!Rx_FYi-O~Jge6VCnral+ zV!2uo?J0o^4tgO74XH#+J}}@sm!N__U7aofX-J4A>m1bu#T1s8=oIwrF!!6{aq#_+ z7Jzk?dDr3`1WbqQ-}=f2o@Uag84%VaN94Ui3q~_FAk5;sBm4=Y?uE+GM@tRH_N0}T zNU1Dv%v(bOe>xcio<>Gzl%tT=8Ce4!8{WJ%kVgK0$ODoE1Is=}_-D6i zah{`b=aq8}g#&e(c~`qz(q@r(`V>S9V0XOLWKy&7pI`zRnfn=lg=Q)A5ORRME~hy2 z=QQ-7M*;i}5*2?>_V4<^lh`uk=w>o2Xp*(!m;lw-{THnD2@cICR~ znv6-rruNsuWS@a&CC5-0pA=_~hlxa6f81KLZ(lJtqGt%TtPF}b-lldnlXXjvYcz!` zl04%=jL2h6);13A%T=AiT-{qzXaPm!Zp8;D+-iH@rEC!#=P3w{JkN2FfbKx7rl{AU zZs`P*F-oH1^fb0JX5Qn|KZ9+b$|s78>#DIi`=G9_aq|9mW=#UY#hCX9jgFFaYCu+K z^$N$+#JLy|)-=bi%*mCnZxdTcTpS8*;lTQnqsnacNSktCyJe(CUR-rs(YB_Rvi~FL zpkY|hiMABD$??|LeviUdH=Tq2l-2DW#zvDA3Vdn!8e1fgMWp4B568c(MwWFPKc}u+=n(U}x zjmh4d6jaA_T?;MpHnRbt-Q*3~$1um_O*@g65Lsi@sA?#7b>$ug9Le|SPmFTG z)Hya`5+mIti-0A`8N3o(PV}Ol-;MP5V6Yj(nLDi@Fz>$ zOu?l@Ny;6?_gCTR6Xo16L@1Kw8)HX6(};)w|Cj`OSvv~dnf4C+J&)eu9mU09BAA$< z5E?0XgA3%5&%NEKF8hPniza^=5;k_jHc%nJ4cXlJ`Sm{SrqrqR0x> zDPH_<;#wTl3BzZQ9|o&#TPVQ8(DCBI0k*a+o%PD(zO8^nuvrRn(C$h>i()*VEgqSJ z0IhVuvnMXUAm@H@RP=q~Ns7su)&%vo_0CXu^8X%Crb=?9qWhGL#It;hq}Jhd>>B zcN}IO4<_kF$u4lu;7B6WC|L>qAYNI-V&(@p(XZH*Go{xTT?iJKtTfKabVx8Zn71Zp zIl8v|<_)%m5(mRtg*?^kB`TnN39Mvp zsita4HfNtyv`(Q@lgF!}buzZ_5Zr@>?Ow?>ZmA02NAu{_idf1q;u`CU6#s@UKqHGp z0eFxPE06AY`>aXG7L);kY*Z{f9}vx~y!@Kc#2o{@75>QEjPfZ4`Rn^M=AINllimBK%sda=5@)wu2v<1^xm>-+9gyO8{5s=46jh9%IRFdT$tR7fWdYFJ2&{uXKJN&%Ts2 zBTnadCM0jMk7;|`y-`J?ep+fM#JB?kgFLlZwiItMl5xQBR*{SrEv%yJ<5EX)P-M(E z(He+^C8syzu4kr-ap<=W9g5aD*;o-)%`&lLR2*MDMlz5UK3_&n1LI(a zW`N0dnt^~OZ97TS*z*sZwo~Ff?-~@X>6!!<@0G9KyM0_TO}Wc`}K*$SwD|I z>K%3zar5h@*SzJvLAnSvxmO9fe)QlP4WOGa4=Rf7Z;f4%KHj)`sVTZY0e0CDY7+^v5vH}{W@Hh+tyrOdqo-eQk zNu!Wb7RD{Zlq7(97>Vwt6weC#~rq8%5lckCVnxIl5@HZ z55J@Ah?n*4$5-2sxY+DzFr}cGY)`kY0k#NNvWv*)ImV5vb(d||5~CLrCn(g-uu^14 zp#_l|=1~@H9VP5Fx*aN~(@;qWiZavY*ODCD-}FwYjrp)a~Q+ zCYif$u&X`xsBeKng7&WRZL^@knU+D6=t<&q`tygUVhFZ=cZl$sqb=<_(+XOx5l}9z zX(}Z+uIP;F{*l$1dBb<@woC?OCuzn+G+cvJ9KSfOs%CF-g0if^d^`uy1JB~78|F#m zo}~1wING~VVrpp-M9i_uurKMzydJNG#$U2C|EXq)$%sq%6DD(>$#Zr)`9HZXo<~rz znHI5bLhLDaH%^wTCTR#~K0%rwt-%sS)qqqJ4~cSJtpb`gPmP@ra z%w;UK)}{M{BDGUGuuiPIuc{XKZpC%?URMv&h0M`(Sw02|4PBCim1&nvsrj9p^jqQc zs>9B(AiP(ldJTTK66Ze8_k0v~wrJ)l332029Bc&J-P*@wZz)bW_Ay=}A{EY6gN+}WNuKXHOD;Oj(t{=S_}v9`z^^@)AbnKyFkk>qKb3I^FQ z9wrFkwF6|Qvw_gYpO9qb9HvHSj6P9MO6BIw8qwp$V~lsssX2R~anVU88%KhHA2et`mAepNfgsKF?X(&l%e8)( zBYox|@wZ<0_edMwJIhWxl_l)1UU{m{nf+BD9hVvB0XsI;ZhV&pGRJK5MR-``6D7_2 zz`OXS$A|%MbS!i16JMu|{n&WAbB4)o%DTqt0*$L5OW94XTAUq_gYJG;Q&3QNp9~k6 z+*iRC_j5eZG4G2}($*!yZp({oZRIhzPKk1>bhwvo`Uc*|s=w)&z#HJ}WDe)d`0ZQs zmV5We^*Aze&C8>0p?jd}U(k*e6A(_Bt~{yP9J^lkZmBCnKQOmHj)+tihCyiU2Y&ox z7n;TqXP+Uz#X8mT!4j5Q1$We~W<6z@s->vM?r!vlHp|LjmHT)cLTNi%=h)WJg(=Y< zKd)EM@PN?2zfMfW5Pf++zZY=?B+>#|s%Ls^tV$JFcg@gV+qEZeQD{KAOQ(oc#VZiek)tA?*)>IOoC#YP%)&Cd0fA{$v5 znd>A{NLj^y6Sdg zg^}2uf10~~g07v_U>Z_;1w*WOC!Aral)ot>HZiL!C#%Xi=6iB`KwwLaF-`ozaVnqv zKE7O7>D9<@=pFBgRoIt1om|E4Ir;Vn734o>W$>hrZCUAKC@_M4J@+}y&U{zh%m-`E zs1GN1+04)8ht``hs?^!Ku=+D7Wg>URUQ;662)k7d~!Jz33L8x6b}B4X3w$ zbF|aSXdJWYrW$6+gmuZ?spe(c0900MCO2By?n^W_Epu#IRP{R+TlYf(5f-WBg7{e^-%R7w*940Ie^WM~n0vf>sgfGr!Dgu8_idI2`)Dg|z(Ie;iBU)wk?}ZO zX3{nb>?!4RDnM4>c8lsU=j_-|N?Ip*s#Gd)CjPQ5-I6q^?Fc;6GWGWz)nZhsDc1|1 zJ{9ub;t=bVPK?kf1j@S9GEAvNd2qXx-Xk?4-X7&zPqxNr3<6wySSzKh>6TctJK5>T zBf=Y8iDr@4Ex&Ebt_GYl4s_l7^M#5zT}i(8jgbH0OzV#hE{AtweO z+lp8j$e8aWt6xYCNJBXG2X_h}D-iBtk_m5Fg%oPajdP|EDvAoir&J|vxo58tyoZRK z%;#(erNj%g5Ie%B-sGZ8A=A}h`vo#j_5_@CvtT>&*jZ1$4o;T8P_#Dxp6j)M9k@g9 z{v|BHeh#SQU*7Ov8n5mhik*sP)^W@MEPUC}sDUYR(-cljk{Ya(&x@PlWVWmZ?KBOd zD@X(l7mvF^lQh~YJw<5I{yqp;T@;0Xpc$@lpVo;3q;x6e|seMI2@rnu!K%)@7y2rs_ z@O$>Jzw1bGRbqN(a=A6j)zpBx#k!l0tgNo#!obZPLdkbxf!y`x*YCq(T#T5^7N^k$ z4L=^9b8{9HviXs|l9}>|kWmfO*5uxYiwHl1>|6HMCs?k${F8;C-J7_8&ay2mRm|b? z;#zr^E!r|zXTG)#UtLYaO8tXsb$I_xVN1u(Kgmm+2NJiYjGW;Y|s<||X>IX>1=e#AFSQx8-$%7jm? zm&>G)U*y;{n{C6P+v`CCd&EG0zfJiF_8_@^}nfA~#cMGUxp_cCT! zN?r*kPt$wKK#ifAbi)d)Nd`lXv6jJ4UODLYh$fTO$UWgio+HI2aBigp6~o5O7oRCa z{`Y1Nu!qB2V8*v#qF7P35!yBbbSMaAVE1moyu&mTF%I`ah5c*K@_AAKPE zW$(Bn_UV@T7AQ2IEV+sam&UBHosT|&{JKMd!r4rg27uZ;(?a>AziDQsE4&fJl{jxX z9*273#KmE@SxIc)dWURR}ccnn@a$khMsWhB7BquG1_vER&^p@UP)y4$HcmE{o za$W{+9O_fVHNm8DgY|#05eTZ%WH}4|Zfrg1mPoI5gv|q3`WveIlaDQix&kRtMtW}o^XN8ntrS84Y}zN z{jiA%le{J|OPc0m3u}uPXcyw8 zV|^9qdj$OX1N)ab9^OwLrf;n;(PEM>0GGTH=Xj&|Y%KjO>eF^GJGb~$3F(!-s6h&o z^e~~w=0`Vl3S=YAkoyCrOyya&#Adi)Qg|LE+fnj3$&Y?&ZNd$CrLra!fnlsrE*81l zU86ZuBxPt4aGmW5?H~gI9XeOm?CE7rrF8dOXG@nlK9Bb>4;d((Gs_HJed=CmQRC}| zs28{zbk1?=@cpB9t{wh%@sHM=D14E;e73iFL0#e*jaDOa=LOyL(om{8gy#;ol&9SP z?IKrHax&=G9!xp}-QhHVq(6g)3<2A@DQCWLirG^j%BN#QPgGc@xc zB)^^Y!pekx_1j9lc;6dTyRu#p=}`T?B&Hh=J&gQGX+zrR&BXz5hNBJWEa$taNOfmM zzddu^y3XP)QEw+p(z9=0b2qM9Rw34_FFne~1bhvIypi7#nQdQ?izOl6y#3<~3L?Fr z{8K4gOL|6|vk=aAaK`2>=}|-jcR2eb?jMtZ5Xj}pBkGBG2AU9vRBSW4XrN5tmJ}?A z+4EVHVPiS4_^-vJ`fDb_#V`D&1E3AxP*hg_wTYX&+|=LRY#7d#yb-VUEzEFg+)w7vx4n zu(KlGa-10`ZfG>tf%*>dm@2}*VC-ncQRH+QFH`Bqpo+&2XsC(3b`99OmFyL}jxNY` zJdkkd;>O3zNL!&ytX-=v&b8@tgm>=(cb`a}J-^srV@pCo?XZ3r%FP8PgSfV8PL&eh znf~9vv-C=OB>+`a0CO>(R-xT=DSDS9;s|LnB@GQ@ZJ+XC}#&myQ9w?Ir*$52|kBZfrvq;GcoZQg%MX zZjvXCaTVnetD-A4azMnaR(X&!9&oJ@fTCjz^A=p*;qM7y>V~O9CL-CDB4MS#vi8;M z^{MHu44ib^gMsPg>h8Q5JP?@hwPCg4j97uOK^2lMxmksn*h+g{1T1Q0U zF1k;MknBpKpyPKFF&%GHDHh%~H@iP5z$UXwR0kds04T=hHzjPlq=geW9R09vSXpen ziTOP{lq3aq!_Adfh)^R6M|3GvubXD{OBYJr8R<}RG7!$+@2(6+wt<8KMXVW#B?gv- zrz3Kbdbbtk`5zlAr5WO(j>QQNglI%Vp?K2b-40W@?WMmKE2-WwEVEn}Hl-+w zD{LqXSuX!S;qtM>B%2-bJ6AfJ(W9S=&@-jRFizYXpq~$a4+GCKfi2cGg0@m>pJla! z+9lw`l$~i0Kk@_ zzmoP~G3NkHa|2oXFs5h&^NqnBA#U58O*&9@u=HxfG#5Iw>c}cyKPpQo3wp~XgsUtK z>3Ttp>N1Ip4D+-kJrJf8PL{}-nmtAY#zquD^n^KT$ zi-J?&0AM#a1DZ`CLoO~DXK$Ba0Z^|i03|^(n7Fm7=WzX{xEs%cbxXNWKd3rxDhrmC z7?3fuVfuVfs=z(gLLun^{ot+|9P+Z1&WT5kd@Ar%@P{>O#t~8Lk_|mcINA->MU#$XGfB)3gq}{reb;KQ%xDN zzci=^);v{jod!V;xWA7qK2=BD%JCQYRWBA3NhLe9LS}UxAT~?uI z`R&voORD2Se8rA0E^gIa=oNqauN#A(a=SQC+Ao0a6m8~4Q2yP#8tZlgsbOP_WEpnI zQTU2w^@$DZZ4%|hIHWB)z9f{Acnn>~pl>7u;>};08p>i*SV`4y!{8+YqLgx79}?L@ zg5VFsJQ|)DcKTB`YY=t@&BU_M&&whgn!jhatTBE@N}4yUhQNJacqRO1(4}5%KUiL# zM;j=e%bD(w=Vz*=@M~&}nDhs-vw^8;X1&bg$4o%G>vLz_nxiG=5Jms5O8L1T;aMeC zD?2OV82`^z^czS8J1u~iVNI+$HQbLrFwXQ%L95>v@gtyUB6E_jnFbx~au9wK?Oxqb zqqJ!qZ`vWPF#8I-efg4nS*#8wFvMk(8$zf0A=Tdd-kB`ESpz{GSnD1EhD?%U7VkF z$!*w&CVSVQX?vI_Ehn9$U!c7dI+@5bJtW}$`SdS}@TbbeZm2+fv^Z{+%ExqGE)Ujl zz&Q^OX*ezoEprXMWkGZXvJ1+;hD`YYZgDJ`9Gr|>>slWf6>XRo5|g14^jMp^6;#SG zex!dM;E9k12m+IK17OY%o*WKXGN;VW@qg^GBUK`LLK4-JaMls_ooc<;cizrQHpjeNfJ9^em5fVV*Z$(bnA)@`}Q zt>NKgcMeMRG zLdz&s{gZzywc)RGi6Wv9xxF;8ernfV9@|8Qt64`#!?5QMZo!*0j6RE5*l%NMkdoY*04HM#<^Dm(7tRF@I|= z7vFPAcb65FG-svBw=lLAXbNJRk~^6EO|>n_1*~1>)h-O-r$jWM|830O5?4Z;q4t1pLbt?M5iK?jg{2S6S?=S<^ z8XvGQ(HKBmV*)BAM5ItX z@$XV^*G@XV=N@IeZKQ6h!;j%ckT%RFTU$0IAWQj**W^3r3iEN}#a^;shQt|}j*qjO zasuqeX^!f?%CP%q9-nU*)t+VUbC35BHYFxr!xtf~2r1jP%Qqy4RT)_E0jB!1r;S0Lxx`I0V1uqr}Kk=-;LYuALF`l?QRIm0p^K&q<9>e)fV2Q+LWk zsMifj#unuI@LR($@d9j^Pi4pMM8i+3-1q|MO1uGe89uyljLfXLF1;ErPWC!(7np_u z#X_oBx&I8o7yH3-5KIV*egac|Oz8&QR{3=~4AE;1>p&YyDafLPstVm`H|p6AwdPZb zzh<&|kNF`;s!HZ;9V91SH8m&@@Wgf6v@SZ_I~}NqXqdvu9*vsmQC6*5(kS^}bx=KB z)(=ftwlt?8Z{r)(Xq_st$F3BFHUDOdtVgo=QELF>45ZPrSbO36T#)iz>19=gSBNlG z%6BXAg0G%l2%?9peV7dX`U2yIl4L8q9$r#ltg7yxO7Yc_4nL7L$g0HOzkKSy@;rP{ET-6IVc5=? zOpkmQ9LL`??TVjqN+pPDoIJbB8zJ0L_+oT^rT{w1iP-+MQc8Rt7QFD3I?YZ^9C(Vy z$WK8g-$P#6T+TVr!i|A#~y({eUUa=P5(ALO6BIZ&aKxU zSZO9QnQ8+j;u8cmzVhtOnrPd<5sIsHxjdK2OhI3IDDr?^9BrA=>IrzPU(3@Qy%B8e z6G`EDNuvheuH+5hBpzL7ATkXV8elTp=UY(-KBZ?U$#qy&Z-C;ex%mmFBHLp*K#5gq z*N0?cjgR70IUi2^oYa!0En(QNN50u#LsnFZV*hyy-jkdmQPa=pM%ArGB@V7WtR|C2 zqtga)m7P8NjMLLup1-q!gRKxCcdx9)LyoN~WU#z3uTk~$PwLov(-KkBYl8`s zq|TMK`O@08Zdd-!BFN6!3%j|fJJTgbd7@r$4#7OXz~&G5aR~q1xkr9|7d*i9UJ?X$CnykkjixUM=x1x$}{w)NUhaB?zCOnNUjT!CJ z{&S?&k&$|M_~JV}P_wF>)c(q(SbZzLj6T7c-BqGr+9%A53BkNqUKYWxoOBvs_`ikO!7_0qcf2xnYTT`^HV}O}Loo>-|vo#N#ts=HipuAn6n3 z@bw4;VoSDdZv4i~ft0XH^Y!V-50;?>unX+pG-h zgLf)3blOjSh{wuLR@9m{M+1SRd-vV@qu)HUBI|FZn$O0<-$6lfdRBIcVKwT{=zsG! zXS`p1$95^|ncNJdh~JvZu*1IO#=KBv9zjT(`)14Js~gNe_$2r861$tU?mAp^hRGcl z$Dy{fdTwz+iRT9R=LV+GK`o`1-NzT}T zOrcC7{(H~v$aO_?cwEHF`c_Q7w9x)iqNy$G^9D)OE_2vBjOtHP z+s*l}${*gmB}UWO^>^-SZhJh)nT+QNv+(U4e&~Y_22VH7o*oDc2XQCGdEUTsVaV`- zK(sgDId-hAgy{XkEb4;thSK!0Z&UsUgVWv@mctwcKDDeh296q_WE%N5BWCwkfFd0F z$FZgqm@4t~m&aX%gX_a~hI@Zs@>J?7DTVU$$%c{(4T@SO`!xfuV%DP4H9`)cQx#!u zz4=NqEufqA%&}{IFh!A3V0Kb6$TsY)V@RD+#SFJq+Z!7|QkqZ;iB2b-qWnvEu#<4qk?+_D?_QB8;tJUlw$TZ<2f=4(;yy!3?F76EmQCeF42MCNw8B%{nM_I1CuR`>Ajp58*z4^HrdqZ8V>Z zZf2v|X%WwHm@p4e6sT0NkTeJTfh861ulwk@R1g8KUK4E(dgas$5{`A=7!siJpM)GG z^=C$&RVvajsN~+wc-BOnQHgWn&*8+hUeC^pIL2dS_JBk{m4*C`G9m2!@Oc1o=T83z zih{yv2QtAI`cnA*ts!>jdH8k*+rQb~xI534lViH>J)K$S1%nAtZYsWm(-X>Fm%A3` z5zHfFyO)86zNNs4T>inGy1Zs@i9#$HCLm$i10yjVZeiy|JYtU*WGW97@0bS%qwZPw z;X5fKu~{dQx3lVr7QXn6nvnYgJ1o={H(}D%pn;sU*IoJE=k#a98=lPEs+@2bMUv3X z*o=S9QLUUKc-|IfV_-TM25m8eAc<=?3>oQpv2Vg{X;eGdH&cK#rM%&ms&9R?E58Og z%6s7=l$_Mdccf?>r+Yz4b&m*Wdd7*Ug(PWjaK_Z=F&}9q_xLkU_zX=#{)sDGa68T$ zRhq*?dwWeik{KUdgIRKk7I7N$DYhs&Y^kkSRq=aCa*}6Sq6_R@6Zd|?l}|J?QnMSWuaiY_q36zt`s%!Gb5a$Vyg0h4RTIVH{(CaEN~*Fm!R(7W2YTsDI(PzKzAQ{0wqI zT>e}6#hklV4oF`b0GQLuj2r=U8KB1?Qmu3?AfrLc?)YeW!KK)ACNn9{s^W9h zQkpYT*EmI?f{vDTcy^0S#9c1Qw+okRLsrdFjz0?6bS6JLB|b{R*;J|-f7uqPm8vG` zRxgw2YEb5xdZbiOHtJePw@Y*-AW4dmnM7PJc{5_9=`*zzSqXaKHtJ|}q3c;H-2~_a zpksjECeb~Bt_Som2od|UF6DrL*l=BrqSPpgJEfLZ-csaemZQQ+iC%1qGMqZszFF+2 zFXKa&97Y7P=u0Op-A||#0=CSkWKbN;Nswl7x|0#X^*BOjah(EOt+>wv=%pr^F8y^; zAme9QE=8c&s1bo!k|DITX*C0<&*b_uTsBk?)uWa8i3)SP$r2!aCd-rRpuh%2gBHu9 zJx=SB6lSN#Vesq3s2GxRBCi7jY3Ae5XHBrc2MPpq5m4643)jU-W3`k6IlYUuYD7u_ z&}mnfrdTO@zD3HJ1}JY>(~}JKHq{pD^aP;7ilr)i)=@sYK!Q`z##`@M6$2oEkNp>y z95B?&Qh!EdoG$=>X1V#%OWBd#GM|FSXZ;QUg2BSL8`Zj-@mLdpf&l@@ur;d^gEymb+8(M|4ZCpTDE}kf&F8q9?d>jkB61-E;0bF9wuPgzj>C zo8ZZy`a7!iDqHKB?(_d{^1)c^ec~SVj92O<^=VP@1oN*d3VxlYMY&F|)oit8W`3)< z>&~w_#BAy#e9FPzPv3uRKM7PTC?Txfu^0URp#u~bCdn$(ht zTpBp7_Wswl+BjEx=FgoXAe9_<^|8dM`+8F*=chCmqT@dk3@s#@)4b$&ajF1ZGYBOo zaUWHJx2-L58bAd<)fDwL{;?t%`E?S5er_3$nM{l4W$mg(zV&QcJZj2AxGZ^cDx1~; z{i+zcDe#1IEDQ_h^5$bn*4$%RD(SqZVu}G9oX>(nnUPSHL@U%WJW2OYZpK&bzCN&9ZpUow9bncCC)2jrKcFMkB4n z%=^?U3dqY?vY(O6;wsA)cuK|xHE%<{M1_lWU|1Z;ArMat@5wk30=%Z8=Y$ib8h&fp zEYhf|9Trk;DH})sCFvrh8syOH0_|#?^*iR#82!*mE20JbB0l+0Bynv)pOjXp(W2qf zP`X97GnRJ`*zsV7ZG3pgevbw)@fd5~fGfU4$`$EEE5GVL$PWU)D19$z4Y!4c#XNJ=UcH4QBtJsQKv z#4MbJRfI@UqQ$U@O|$>44so1Z;w4CwBw317Y0|lQc==_@k}XHB1@h!8P^d_;5&=P_ zLduk@P^n6_8nqe&;oY=bW^A?2UXT0GQOCl;Z+F8bMH>IyaMN|S!zYt0vdJNrJn|`^ zFqq>`IPHv+PAxCF(`g^}*t1(l;}UN0CCzxcy}a!6ixxE&euA+iC$IEc>tG|Ce|}L@ zOCwZq9V))g3tn&U`1+xH1D)NAdpO0{IyuE>{)i(zNyvMTSC9P|f$ztU(r-VXbnh7W zyRRC6w2b?{=`v-K?fG3*t*BVA`^k9N1Q6$#hv+W2xexpR4)|YGXzkI8qswcr=J2RB z!m}nYr32#QnqT$#1?SBP;NTs9D6JuV^;112HXy(Cp8kEbvFSyv=~t>{30T_$Kmo+O literal 0 HcmV?d00001 diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Main-Italic.ttf b/packages/extension/media/vendor/katex/fonts/KaTeX_Main-Italic.ttf new file mode 100644 index 0000000000000000000000000000000000000000..0e9b0f354ad460202bba554359f5adcc8da666b7 GIT binary patch literal 33580 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 literal 0 HcmV?d00001 diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Main-Italic.woff b/packages/extension/media/vendor/katex/fonts/KaTeX_Main-Italic.woff new file mode 100644 index 0000000000000000000000000000000000000000..6f43b594b6c1d863a0e3f93b001f8dd503316464 GIT binary patch literal 19676 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 literal 0 HcmV?d00001 diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Main-Italic.woff2 b/packages/extension/media/vendor/katex/fonts/KaTeX_Main-Italic.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..b50920e138807f385d0b0359f4f0f09891f18406 GIT binary patch literal 16988 zcmV(>K-j-`Pew8T0RR91076^<4gdfE0E4su073x(0RR9100000000000000000000 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 literal 0 HcmV?d00001 diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Main-Regular.ttf b/packages/extension/media/vendor/katex/fonts/KaTeX_Main-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..dd45e1ed2e18b32c516d9b481ebed3cb8bffa711 GIT binary patch literal 53580 zcmd442bd&Rc`n@NRIci-?&_|t>YUT0p3ptrljF|J##wFDMrl{lYJ<`$AR#0nAqkX0 zB*_AcWPEL|Ot6Fyw%{5tV8CQ!urb%h27>@|eYu1m8*T6Zo>M&=KoUHD{`)-t&g@Kg zRdv<(edo*JjF0$yKGS!HPw*Yux?^x;=`S{p`+QG+5JwMPdez~Rm4EvFoX_`^?ehsc zFTL@G9K9;u_W3?l!})!epFDciCG}Ic`g|WchMV}09=`r0j)#1{udKsw_2`v1U;ang zqqx`bO~kGc*cp6`!wRHLPmGDvmsI z*~kcup8C-U`BPl8>Lb3tqM!E#eQBTiv=h=P9qEfSTa6JXYEwln7OE8*aqz}_J=$!o z7UQvUjZiC0No_WsO9mo67A?{@M+#7xRgd8%o*f;Adap z7`erk{R?~jMllgDR&oQ=t)6u71r7nWvC}hx2H1A^4g$7)yG9gFg?0n5F_>%`Rl2Vk zS@6aqNKsMjF33eiW)uPv;nDW_Z*UBE0+PjwgdglPjrN%N)7~CY^oQv-@=?5!bvbYW zc`~TW7hP;eZ@5m@gX{wD@HrQlp^w#qxue_+?)KiMBp9)GK>h2_pSM9N-iiqZLd zfhqyNBy8S71ljPe(3JowQ+`SJr4k5AxI&T%er^Wa``V{JLq0-j#9Xz|4Rs5=j&G^x88+PKDM3+Xqqt5z`Sj zn{mbT9x>RH=o#8Lp45f9d{HGU5Gmc8nWti;Sa0+SGNqH6K>DW}&pJ7wIwBGhC6p3- zXK&xk0oPFLbrP8D8@Hs0lXaaeq3aCtpMnui9U5IZ5!DCl;feZOSc

viU|OFj=e4 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

Yqh7OdB% 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�q{iqW@P3>Beq0%EJjvF((%W5V$Nl;YoRM#4D5D7f{7MJE)%@L?;5S z6PC1FaW5(Zu6INvK30(rr!FvkQ^|w*&iOB7veje0>LOFfevf}sMWRpDM;gYmoGD5_ z%^E1-6#ZvOyiEe?)pil^>@|_p=$y!Xm}@%G+Uk8SfBMN2^%xoFDk$T?1&`4$z}=&> z;czh}pHI19t~J43jM?lZs0OQ?KWUGtV-BFbPI$d^ZNyK!AKNb4e;n~n)gfbgg z1h-}Tvnfcp70lIxF)=A!UuofhSZ#=r%fmdvLin%;Y_|rz%bYpQEjwSObjelZ;cTn@ zZiGYi%x!Bks7}D0#7?<nHc_Vha&q&>eHcmu52C2zzw&2SV3WaWC3Ov$8G4i_zf0xss)GiJ0G{ z7-kNa8dBrOlkNOr;>L?~TJn@9ffw^2+e*Wg%KWG3bdjo=OZS&dMmhKR#z9;YqTp;Z zqyQyaZ0PvMiHE(JIqE=krRT;qq$_3TgXJADXSIt}Hu|w>u8jG8deQUnmiU1CHTp=n zkW(rSRbx;QW)M#kg=m3QyeL4RVgQ-OJ!qe~e-V7ciAvID=ih1|{{+f6HOND)Js&x}6uys-%T>c;Lhs0BlqQd0}^buKWM07kCbAWn4zi zg{Br9k24l$ejcPaOt7F)L_UIWehbKf@2!f26(T31#+q*iJlSwn1cA5=-0L>A@&uXd z2(D}y-rZzM?8eQ=8H6~v+is38UmE!=2n5rNmOwd@{OF zno%~`vzyo=G`8vFLb=CAAoCrkr^$!#Ytomf=G6Z5ZEPY4`nK-jP%R}>+L=5EnOEvB zem+Lx=5RYT=eWaT24l6Yj*j(E5;h4(e!WdJghi8h|I=?8-Y`XU=P#+kR};#HFt z>KqEhE}L<}?LHgz`x}kt=tvD05$@ge{eb~H7e2Pv&##*%U-an&HUCfqsy6d=x}r*% zt(>uz-EV}vmVXV?z z9IaHfW_lRpsGREtHg-@0U!0U3iHDfa!mbp&?6fEcY=aRB_l~||%g!!9sN39UIvUKH znJ$J_E&9hM69vyH62n7mJZJRfTXuE=gsj|&iy{HvZDO(v@ngmvg4z!WYG;JbYjKvu zXGmRZwmvXcQ}>#!KfiQ83;Y1p4q5slMCjVNvKgnCeu_j1%fES{W%U zo1_*{L$LpTQOmu@;_(nb=guuJABC3^#ZW39yI(R?)v|1p3{v1aTCxdR_0gD++8~qj zk5Sjskx`Ns*(3=Y7T^!gtGlycMJ<5$*j9Xc!}*o?hUvIvRbKkOfHpb^r=>QRzO)Mu zuF5J8R4Ea67D;^;zA>?UWg`3-2|iYR_bb+77bQQ53)7>7zUQ= zyC&N*s`LvDe3Q^~ctt#lLGkD3pQ23rk!sVlu*?M^QYFhWRd$KulOSh?0k!U)0!mFi zjD!szC&Uefe#8ue@oldJ;((li-?o()X`?A8o|ckw;%9UDlb$o^tvWDrc_{SEzvy|s zv6B%DJ4|1Chebg%n0Slc&Np8gp^SR3+RB-<5&}Atf@sAn^_kBgmMx5b78mcBx+x9@ z;m9L{IjqsIQQtTSGc;U?$YZk8v1p`Hw(H3&q*1aoeAfnc<%OdF$Z7uhjnROdp3rt z8vaC3=1NPxYoWEN3Ll#Y5DP&nNwDa>I8;%ZVvb1j^C7EN*5fv(>mTSHv>3CFJ~YU5 zH>#Iw7G56m8jm#|vqn}@Rv)wQLEwbE&gIs`D%lgvSFBDrM7MO3x$S_BU=nB5gr(NY zH>x>;>23pbqM`tKfoNSJY_X-9mhz|st3`s*Z+xE4u^9{&SWGt82qqDE5D|wePDFkH zlu(@*HaHVYU51?6dv0q7M5HJdRWigzTYGU_1PQJ=eeslIs2HD0+2fn1><6{ej)R-9;636Fri}NzWNqt3IPiP)R?mVC%JeuFCr)mCeJ1RrKdApI$SJF_Z}r2J@*@8IBm zV+J3|j;2K{?K6Lp7xa@zvu&Y-1a)HY+#TB;XS3RaD-dnmqX2t^&b)(J+E<{1;*n64`!dk)s<$;F*cnH2(TEV_6Oub&Q^~uf<2M> zg#V&U6IDt~Qw-2Rq?N>?j$!GfzaNJCtdy&J(Z(}GkL#o{2M;xVbN4BHi{WzeoRc@U zFDZbDQ98Gqa8B`EjRrvDAz0?`7=sh|r06*tm6hHanQd{*Jv(Wr-b`qhR$D4K7N0~j z!u`cq8x5_iwQ;A&c3hqu+qdnq@zw1~jx4odi67hfJB@h$Dy%51Es>&b1pJ-jX+ zPeu&?S=HQl`}5osg?GQi5tsyXOv zUgH*gb%q;!?znD(1mWb>AB||$(;tQ}5vtXMyzW)<8W`Dp;l^xDcU*vmXjRpU0J-h{p+RpawOVoUGWk(-!Hj#Bvj>I!g}KZAO{AYGmV;T&8z)wy$9H zI?>Utv2H%L zzE5SjtI^<$Wa={EDO!sT>iGA1G zEhrgQbqh37oHwJ9^&m|_Wr8X>3V;p*=euLRG(GnE!~E!_il#y#ky@2xjg_7UPq63} z>m^xeh!VXQT$Em&I`|Sp5N4a88=f7W*e*yD5+_Job-5r7OvxSXNB9VM8dsK_V#Q7m zR|$Z?jVj9cQo=O7%lG4Fj{8A58l< zA$MmuW&(tr$O{=2IT<9Kv2Yrb{iYX6Anv>~?1n$dBr7L8;w z!&|lvi@#g9XwdTRXT7=gO=q4KPpmz?i0mQ-Nec+;K0%0ljBv$2Uw6eueQE6Rsx5bUU*i8D2w1d9%DKe98F=6zQ!G0M~kN39(*nI(h$ zZS4spndqA3<$eIr`R*5#iX%(ARxU{xyQjoj+0&k9)4?TwxA&gIv5K>9tzJd+F7Gz= zJ+ny{YiumD0ssx?<)QQqYt~(j;Y;PcT@HeEsYd+ykf=vhn^5vNpXS$^<;Mf_y=D78 zeYCkLrK}^AS^yuuO|!La&-Cssl}?YZ+TK?!<`!?Wr<-n@3Wz+egn00wEY^QG8xcNB=v-N!P+eenIzoJmt+udUw zf^~%i>o?qxPp7jh44Jo)Vxw&H>8M(^^W8;`#WPw36lj!NNVkJaY`?yrY?t zx+lYdf|;+}ymtHT#dfO3O|*--%{zAxI1@Tow z#Zp!5FQz~F`IfS~)x0bbEVa#9)28&NX`(5cn-fmp7TZ*1cRhdg<~vIn9l*52CG_sv zrOM{GJJc+_Z)AG3GRVL51=s!RSF9DA{G#Xcr~VGVByGTyA8ie`L6qeLpVoDmjMI)^o6|6;u59vwz%=;rsc?ZDG^12NPDC32phYj+~S1pLGpL!zOv9W5SC4tPl<_T7QloWeoWv z$(3O(EV%L$VOuYi+PX=;QY;?fEli4?31^g=3{WaYP8XLu;al!@+EU(Kw>V)N2y)za z@!FZCYcJksqxqFpi`TBccwfj4JGb2BrP`c(pWFNue2;mTM-U^ITaH0=iX7JFQ>pw~ zQ;#%0pG@Z0x~Ab+@+Z;D&iq7W^Gp4LZ&7=Is-Ao{0fYrPg6G%^baOLF@T}W{!s}ae z1ZBxVZ#K1v)*h6sN{a_G6yCg|N(|548J6F&ux96cLW-)(^^dE0$p_~9B8r*XP521j zs%2$d9fC;!qdb|SetvjYXH{*;ueB(L*D+$+{=TW#-34w8NSPkRyYE5Wk+T#&C$zp#L<nfTDxu3JZA2J2c=f(!h+ z22z9>Eswrt-TME^qsnvhl#u^+saf=Rpan}x-jt&xh`%rubWtCCBp%kouRZm>Dzz+H zU9ssmyQ&77PShCM@Uep|Re4^4N7Cs#rba08BuC)sV?)y+ha|QvI#5j`k4#;y@FqKg zlBJ>R+a~k)eB}#nqLW&XIinN!R>QGr_0@;|J*kJ|evz5d^NL24G7F9BuHSB0X(hq4 zEt?~WkG(IQ_KP(`ORgz}l0=V94;@Q$fWT|@NYiW8x+;&PqnRqE%Kk&IRV-f}O}`=J zzWLn|;M=Zo0j!PnVn@95rEhjauX~6ed0nj4ZAAd$S8p}iGsem7&7Z6k^)Hhg&)`ogHt4KmamiEGftu8A9V0hWf zX2Q`}cqX6msW~6wgfLRYWbHm_sANImnn|sm=;YI%I z_sd1e=Kj_qGDr!(o117_3ZPAkA`Xhyhzf1NxblFg64Y_=2)7*zI7ey`dDs{Zc@TyU z$eL7?^m%cDxrJ}3Rpz}Y#mK~{e38D%p~{^CUz0v$yK$O2@)d4q?z@;5Ecv`{f~M)h zf_T5M3D;;zEia^FL|ZI5`t0SZl@`|{e$s2A%Lm&=iV!LKI8GQZM%1+CwZ~p^-;qP; zyx`*D(QRwjUTOn`phxfN-*~x!>+Ah zukHwScHOm?FXkt2hcHHScWg6^D;{vM@telyA*>F;Fhsb}eLy2E)R(p4b5kzA0}yI4 zF&+?oE_(4&L0dSNO&z$+#&vU#+OIguGwuB7E6zsbt&$);cJC2m*f6eqzy%O=wQ{wj z^$X3n^qjPH1&%;5OjY!%S_M$X&Bv-Th%3^N^-+m+W;}UT*8XC{RoT?8kxv}Dlvs_g z94b}T_K#lzE7tap9Xce=8h?MyR{E0J*%z%j8|^zL^~lI4E%R(}tIk1f{0-OcU7tI6!&lIp``vp^UAs*jLVMo=3Wv4X3sBe`Zd$y^WURR#iu(Oo z$yGvee8p^A_PW*CnRanx#wJ$&QzZiqE%x-4MicyOgufK^#Evb;moA#!=jrOOvvd9A zy2dYZ{S;_#QLKONY0WJ3N3_tqR#3^eO1E7i^?>Er96k~XHC9M{fUgOI*Z4fo@YZL; zK~$vZKdfG}CLD$*f+(K<5I2EgQx!r->8Lxr&XEFhgFE^Z{)*xQa)^? zjkz~S3Jz;6&x$H^25CS6;rLf#)+WmCRfP8y(uUCDcvzPF2F>>ZlRn|wEw*}rg9&MA ztehAfs|9n?rf+zDE(!q9oQ=*qj2jQCc__RQf@x^uK@Bxa6LAXonHJO9{26;PSf@-0 zg-`*hs*ul#e>OS&77~KrqTJN~kx+{N_~b`Ea`4b0_z|Vzs$q!x@+vb7{U3k&k&hfY zcu>}2v-VN#b1*4mEcJ7s>pt2#8J5owEd;-XpKF7@MKo5EsSPsNE*4cdeIp0drn!&nU@oP)Y0$j~EMDwdy}GN&E!f{ZvJb%o$lxcDa_7@faP&ONstkOX zaV%C>udWu6Gx~u|C@yc;gU9>YO~Z~sCEJn?k{0fFf{0|Yd_{-%8XGw&2P9ivej`Oxt& z0K!G1@Zjh0v(Ozvxa4F6u6Z6H+VCwK5uSG-7QNvRVCXm5qtj;NI{2)@Jf8lZ_BnD_ zfO*tX3;0dWdDhw%>9ZW=TH9x#>jEBJ*mgv=H(dpj^EA}|*U*+9y&&8&px_SeFuj%3 z=P8ISC>QJd|4jq!EU-ci86-Oc1faJ!ZXTHasnBH5C*Tr6<5(QvK4wRgN@_|(Z0vzBX^ z&Yg|lHU8yJ=Vq6g?(+KOOrM=TRH_@2MLKFYqTx-r(S(fQo z)87E=>(h=M1v;5JdVg(p+2{jDQekXo{sPCu&j>V@o5`X+}(jA z_!F&Hlp|j4C)e{9?|ofvq$1ChuOy{zvtI*L1~-{D5+M5`YNn#^oaJ$1?1>?~;6xUk zYfr9SzOjO~g8(x(cn?;mb6}mkP2Y5~lMvITQz!i~PL=;tnYmt9QTr?=Su&nb0VrOc zUlLhRFol%~fp$>m@>3fXpPKreAO`@#2MrS6Gms__oN@|C(cC;xnFAL$cc=R@Nynu4 z;-%Cz$-v&>QFf$y0~)ucd;J?Pnk5E7K&!H0&B~J+N~*{depyIXa$(F`9fOjV zAXDC4N0<;eff(iVFCH6jJPeIT;x0FB%H%TbjOy5UZ{vL~;NiJ9f`(GZ!Z(GEO=~5= z_ogECTeqoUyXN(2W?pN4NzMU2H?K?KB~8r;bqOU)xb*P*zzauO(dR=XK2vX%&A4s-uSbyVN zvW_0Qe8Z*Bw=0|1T=DwGv3LC{26pbTTgyF@BbU9tlEdg7hi;22d47Sge*R8MPFK9S zX-FdO(;R3ubtQ-lH4#b+7K*{YTDEd*HAgY1+H^U!_0(uv*K#WyEk-LucXw~tKFSQs zwnw+`sSI%V(y?25N(QCIhK*6(j~?soy1qA(i4Y8>BE4ICSHEzguiK;{ z+a%_)LDptcieH);A$s>B2H8dTvlw1~DzmzCaxg|oW^l4|b>`Id7-O5S5|78;zH!y; zHPv*flj`|gy1HrN;Qo*FbWf&jZ)BqdplW`xsQFVuyAEj@Nd1~4&6L_8TodR&2+~I9 zvqs?tm+72S18#i3=RJH2&Cr|P;kc&RQM%(&7dl>ZlHfJ3jYflS6w*#D79!tyiyL*{ z^E=c06)R5!CDxn2BWEw5>f=6Sw*9S&X#Ew?#*wHA-Z+lvO(M{ zjpfokBgw?3+VBd!MIM({2P-{0!;#fTLtMNAz;=>Fr?xC(&Rk?dr2D8nY_A{ElPLi4 z5-)yecC5dDQGXAAmXHSCDA*`Q;gr@XB~c;_gSC`{9fgQU?TF29LLiqp*=QSP_oCV= z8zA}RuFWGv@0buo>I}r;s;xUXqL$y|=w>!~Q8z z>ax>CSc%^ey1gK6M;~(Mmz|2J^!pN%m}?=~f{OKoA=+U>tf38N*wc0VO7_D5aPdgz zAx8R;P-ZrM*xSSAx?KL`j;q(eyWUxid8&3r*o1$j9n=m>v}GzGE2ZM6D*2r81P_j7 zL9FTnQR)lNnr54*y>!i`)ZA1?O@kMz6D}>7$b_gCCm0S$c686+*|4xq#^{mvR3^?|LUs!Dib{H9jmD585@Snc&Z_WX@=P9+HM* zQCaIz1-AQ>efw$DWaQCD3E_l56!-tfuYSp+7Kd+H@GY)Wi1J@5?aLsEzfyiQA@6>w zP8^j68>|1~%xjnxq4V!(l&nUHTmBo~AJ{`OX0gEhSZDz84)hZp;7HTvXfc75G4k7a z6XV8b8$J)*-u=pLx->(43rPD7{#3^6Vhhb0xhtUG1(ZcLZ<0OB7FyJ_asYuAg;{1$ zH1l-UT4TnzOn7HS*XW2giVoQJ8$7;ul$qS>k-=QxH zh5qAX=f{wNXIv1gts*B+v&er55xvvXm!A(9_8%X;Fd|yjncJe=HL}hZF7?7J`SQjjebsq;%j5q&Tx?d;@ayF`}lPup6D|H%)Ue#kzwQgjSq+~OpB|C!PRir zC2f1+E>*upD9h_{gE`?{%XftxhOc6dZp(Ct9}$}oFG>Mu)FaGhkb)$W*CGtT@Z|59 z+-?#IkYl#)FvHXV^w!8~w~Tyr1wwVfpgRrRyCV2*8#2uOn@uau6Mvuk(*RzuHD!hU zc=uUj@kUt0;(yi}PgS(_YNKaj@>EI0d4uv=KwS`=Z~W-gp%5DOIm7dFe;$wr*Qz)@ z+pzd}*h-xvP~Y(8F%mC3-GeVsGkuA=ODWt+Agdbg!zec!!<6zEb>VUR6cdM*{>&*P z5rb#Q#oGTL(c@^i009610UiLV00jU5000020000O0F3|u03Hqu00000c-maS0}vDd z006MJZQHhOE8Dhh+qP}nwr$(CPHg}Hfb?%1Fb>E9E(euBufSYz65JVl6#{`6Abv;{ zNH@qaC>UBEdJKkzHHV#o^Wc*ZV1yOX9x(@T5(z}sMuAZ^P?yjy^eFUqOjXQPYyjIE zdlOe5w-V3BH^XluFbT_vA>u|7h186+j;tfEq@XBeDGezDDN`x?Dd(sO>O$%}T81`} z_J}@`{*4i2jAUY%3FbpqIo5dA4|XN?CXS3#owJ_vjoY02l9%C4=6&bW_$~Q!_;UmS z!BN3yAzfGy_7(0BJ`uGMZ5KPm>m`*X6Qx+GS9)7kOEy)uU-m%uLC%y1K^$a>S}whgjfv`g)IdnfxW`#uNB(a>?gNp&`NzIJtX zt#%9DN%t}L2am_o-!sv3#Piq-_qOm(^RD+k@@0KneUE(K{Z4;N{~Z6V06)+!usiT2 zm=10U#X|E!x5LD6+3@i2qsZAPF`Ic7ZIh#u ztCA;@PgDFUx!l~mFyA8o zs30j+EIce?i@IWi;;Q0{lDage^mFfmor8~uh=%lp+zQnS^#%Yp@V|Bd0096100961 z-ca-bUk^O>01pG`00000000000000000000{wehmO?6&vIx6M3RIvVkWhd? zfFJ=20t6ITkdRd0Ki=aReExI)`_8%doVlal>Pu2`ULsb~(uL%wy6KW~G+NVf^@jeY zu0X^e;N|wnn<#T~I0yywp$Azlj&KBqwG1^1$gRm&MaN-V^Wl@SutoewbK$Mp;i=l` zl)TqYu4z{YtjFWl-`<4CN2G{!n z*WohXT@2gFq|b!=o*z9Nb)E1*^PJoByr_K)FQR#=3!YEY>AT>b~iB1^fnAz<$L5qT#!<-g9l=yZqfY$cuI8$M4ts);f!jmlUSDYk6)l zs;*bY1LBj$`J;Glh8SQ~zT0^D8>H!mW*vL}JAyX(E#5<`(rSLmE9DT=dpb_uEPBG~ zdye#y`Tq|~rQQJNzk|5*9IR<&Y*~(T;(Y9stdVz>xhEBR@*gSA)K>ric-muNWME)! z|M!6*irx92=Kp={+&~c&z$gO%ojwLKc-muNVqC*Gfq|8QfvJmW4+8^34}@lXz+lM8 z#DD}E7#Q9QFuZvS;|HXzPP)V+&oHBgBH10w(r z$`E=0c-m~wQ-EDD5C-7cWNX`PGUuYUZJRH(ZQHhO+qP}DXR=0Zp4&7v2mk;40RS`4 z%_lbL_qF|bant8vFhqsQ&JL7RKUO3d>-5tb*0C3AVs?*bj%{D4d9Ma0RZ$lXx1R;&UsO zWm#L?+uVEH2i(WpPd&{%oxG9Wr```#OFi(bP1W zlk5z;&F=GQziC$R)q;r_B9TZgQVa1P&19olBlpY0MswL{n9)QtnuM4H(|p%>|IswZ zw%8Yk;z*;Jf=i9&xY0Z@n&=iQiB zCeb(=Lw%?hb)y#4h#F8mszX(%5@n}s6q91uFKyfYyujN5x4mzB+;+Y#acjk``8U;X zD&Lg5DSPeqwH$@Nz;ri^006Pe3~B%Xc-pL1*LvGb4jrnwSCtf|fSJhaHHV7R-3!OG zHr@66$gQL$=YIDp&%k{dQ0aI3XbX^%d-Ag>Y!;%&{YGYyRQzw_p(J^Qp-mzHQ8Lb-b9iXLjJ7uFx zHi=@(KMXoGgFPKAD9qbf)jsFLn$}$h6WW0P+rq-sUpv#ri1u0@mOS6Wd_CCtn@`SW z>;rCXk!p>+agJAWK>$hSO%+X(s=EW6W&137(y2ZW8*v0UxaEhW0k#eD>IJV}gk~57 zfk|xPux_E)@lm{CXN&gc8@$J>dvBs2u(g?x(GEC7_UQgt>!{Xtbyh?3;0L zKmk`fO0Wvz0Qdx43j=luH_~bcRcBySXwRLojs|B)ogSp&>=eV6q$lVhc(IJ2-6dO? z+zukZk!0(?@vS zI0jBTK4VD=>#fbqP9gM3H31=MQvFTRo^IA9Elh+cOX5qTSm_vsk#?)9L?UwDo{y8# z1rJB1izAXo&V}&%&6|dp5M|-IE;CRen-L|IejER5n-7St8ey#34&G3S!SW{Y&GME? z@+@zwq`=ZtNs;9pm6TZCRY@Pqdn)N?d0!<1ENvxw{9`F3rX@7c_y^w>2h|B_+;dou(rX{))VB(cFWJFD=KjgRO)K2`utxTKphnv?us zztY2G^iO&%PDV=}PaHm;Ns30*^Jjw;<KY7k)4Mn>Gr$< zLw=^LZTp`KPz3XHVXAmLa9s&Fs3DeVgxn0Vq|aX05Qv`azfwVmZHYx4waHx2kxA>2 zpLAzqA_?R@B{!+Zk}_-(P7-OB5H3n0Ig2DqND_z==xRLc00)^8QglX%B0dPFyD#xm-$^7EZ&+nn<576^Roih%epa;*;gBNX^lI6WJ^85{Y{ti9=&^hDa6MFCkJ@}3amG)(u zE2%2{`}4O$f130$m};%bm8ElktA{hcFYDSLV@v@@c-ms{-obDJP@^;)I1q->H`W@L z#c7!|5&Z?kIL{Q24q~I0F?$O}AD^0igQAWDoeD&VP=^MDs`U>V#TYs7;yp{tDgNPK z=>$vFNC1m#NVzhl8limcm<3<}VtiBUMqe+l`!Uyu@gH+vL@Iy`-i^Ol3dJ!fw!Bu` zxe=H1DL%6FUD2n`3!Oa}G>FA%JP5e}p~5SWc-mvY4J06tX$1oVlPC}${Qn9>Gb90_ z8iN|sYM|IQ#`O#g|Lwr?+y1`-@(=v~`rjDD2LOd$548XQc-mrMVBlmZVqj)qWZ?v| z7XdMZ&B!1E415gRAZ#G%!f*u2W?@iavWz zd*E~MwQh6(&cZn*Pphc{c}<%tQ)@WZs3$#P(C3}yc~4>BrN*~7mt-~Z^_~}S?m?b8 zP=hN4r%5a;<8FViT4qi2jH%~`ZufuRZ|lsf?Q0Oi4sn;Do0KK2Or!JK856;SSsv%KI-A@cn~-VerMtUd$zrs>PG_-xT9b?U^G{Ph8Q7 zaYM6cu^p}oc!;Zx8e1c8fCqWddh~cmNA&OirsozCp|EKHc-m~i)1eRm06@`upKaT= zt)8vyQL=5@cHa$=IYCVjzdMHj{`s|q2L8hwKmrIPh+skpC5&()h$M<=Vu&Syl*dsZeQHX6V-wzHqB zY~cty`OHCnaEM)8NTo9&#m!%n;GwukTb+GoE5E^&}+4sqCFM;vv`aVMPQtW!=q<19CvbKV7xa?Hiw z1i^!m0{{R3u*;DDwQbwBJ8%>V7PoLyYq+&w(KynTFsJ<-*c+d32m;B(rB;aMEBE{$cz zTgBd!XpIaN47`04>z@hu+aO$C*j{*E=1uxR;w%`onIcgz?{^ggmc=<&OHz8wJeb4h@07DqT7&4g6JJDN1E-jPEgI6!# z-Y9-+ta61zu(>BeN*l$sUVcM!#wCsC6<2DwmvmzmQ)I{Wq!OpIam{wP?_G2p3?|sI z?cM4uh6zj|b7nDQFmvVwES-f153ty}-H-Bmy7g0H$K6vno$-0p^_b7e^`vf{GFn}{ zBc-|A(1Xy1dLy`8Qa74MO=fvgycL(?f+Tepm)bUGbLM7Rz&`{qg7KDXLrUn|j+--z zDP-GG4dbAU7Xxp|=n zgH<#1CuONOsFcpg+t6Pt?zp7B!LD%zb7zvld^#v8ZI7DF{wcXW$ZQqeNP0jI33_gR zS~=MP`Y?dux)G|8G)k*ciubP6S!u*5A5l&rb7npaV7MDejnucIv63rPF{=m{?O?7i z0(lJ4uPRtO>%(N=rI5i4=1$*-fiw4@n;+!?WUzD=s8{t&kG!QRi{k_IUp3Mmk(984 v^<+||kiiO~Uq>*82}~h_87wBVpmt`^U8(YX?;rBk=34*&00962|Nj6FrM%4V literal 0 HcmV?d00001 diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Main-Regular.woff2 b/packages/extension/media/vendor/katex/fonts/KaTeX_Main-Regular.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..eb24a7ba282b03d830fa6c63ee897d92a5188736 GIT binary patch literal 26272 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�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 bkH82T%!~T~dOL3iZSfI!+IWoKhyte*`46Vs literal 0 HcmV?d00001 diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Math-BoldItalic.ttf b/packages/extension/media/vendor/katex/fonts/KaTeX_Math-BoldItalic.ttf new file mode 100644 index 0000000000000000000000000000000000000000..728ce7a1e2cb689df32c3a6c26e1bd072dcf2acb GIT binary patch literal 31196 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 T)YS6}E-Q5R&CfXgJ?`xQe`ny2 literal 0 HcmV?d00001 diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff b/packages/extension/media/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff new file mode 100644 index 0000000000000000000000000000000000000000..0ae390d74c9f665cf8b1e5ea5483395da7513444 GIT binary patch literal 18668 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�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 J00IC101u*7EU^Fp literal 0 HcmV?d00001 diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 b/packages/extension/media/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..29657023adc09956249f6295746c8ce4469b50d3 GIT binary patch literal 16400 zcmV+rK<~eIPew8T0RR9106-7`4gdfE0D43K06(+<0RR9100000000000000000000 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 literal 0 HcmV?d00001 diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Math-Italic.ttf b/packages/extension/media/vendor/katex/fonts/KaTeX_Math-Italic.ttf new file mode 100644 index 0000000000000000000000000000000000000000..70d559b4e937ca1b805eb39f544cbebe3c58ca6f GIT binary patch literal 31308 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 G#ECDig^RoZ literal 0 HcmV?d00001 diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Math-Italic.woff b/packages/extension/media/vendor/katex/fonts/KaTeX_Math-Italic.woff new file mode 100644 index 0000000000000000000000000000000000000000..eb5159d4c1ca83fb92b3190223698427df0e010c GIT binary patch literal 18748 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 literal 0 HcmV?d00001 diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff b/packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff new file mode 100644 index 0000000000000000000000000000000000000000..8d47c02d9408d34b2a9d566c0fe0d42bf82fb735 GIT binary patch literal 14408 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 literal 0 HcmV?d00001 diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 b/packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..cfaa3bda59246b49e94298478d6de3b3208066c8 GIT binary patch literal 12216 zcmV;pFGtXKPew8T0RR91057-z4gdfE0AMHp054wv0RR9100000000000000000000 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 G>;nKVNojrn literal 0 HcmV?d00001 diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Italic.ttf b/packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Italic.ttf new file mode 100644 index 0000000000000000000000000000000000000000..d5850df98ec19de2eee9ff922ef59586efe471d0 GIT binary patch literal 22364 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 literal 0 HcmV?d00001 diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff b/packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff new file mode 100644 index 0000000000000000000000000000000000000000..7e02df963621a5e26d53d510f0b4992eebde1c60 GIT binary patch literal 14112 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 ZM6wWJ659DkbG}7@mjJD1@jO-Eqc8pH1xbNXy>V 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 literal 0 HcmV?d00001 diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff b/packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff new file mode 100644 index 0000000000000000000000000000000000000000..31b84829b42edae20d0148eeec0d922dad2108c4 GIT binary patch literal 12316 zcmY*ZYjcwbulTEV8$@`t}$2ry4J=0ZF)l+l# zpT26!T|rzN00Q_HCOiPbe|MS7|EvEu|NlinnUMtmARGL)Du2U3tT8;o*w)Yi03gTu z)*Ar;5HJBcJxF6W7a{x9Ft`2on*;zLx&QzKI3tFCKIVqb-?2<+ z-#$?P3nX(JPqXiJL;(Pzx$kF}-5}`Z7N&+K001f5w-3jEFq#owv-mcC=Th{o6MlmX z;syA_!q&y(+t21Zp6eUe@7TDhHulEfek9*I;O#%q3=_K98hU)cm*l^kiT(o!BSM^= zp{?n6&N|<5#Q*?Up7GLe9S3`77XW|}?A!Lu3`l^!gY1Zdlj(OXUD$VA+BX_Z`$0oI z)|jfCMS4MbqPtJrX>HU7Kz77vT9+W%9Q%sHF^?#yVi6AVJVXl z-;%?+3k~e0qQknqT*k=JMcfHe^lphUw?@FYwL#43yHlh!H!V2hU)iIt7C3VG{ZM`n zuC>lH-?X>^TlRrjyH>v;5$Q1%xJd3XRT#)|k8bM=`S1y!-rnpESowWYY^{_7Q1O< zh29LgOFxY10>PiqG;&Xh^CLVB)$~hna!3=JSRZTf)LRoVHa1a*D-x!qi>%b+%T1GfWKU`8 zk-aOT=Z5BoG~{a~JU)CG5IsLWY93UTSt+x}IH-pTx$|{@d_`nEq%42E*x3H^efB#Z zgciSwlHjF|7<&7pqc?z7p@}lZ%2%U4@n_**&bEv>XNWPXrxKkIb*Grql;3kkXoPC5gq)=Ar!V*t(IKMtUraZmN^l3_p;+~W`5YMvl}oPcyFIjCs2c-zhF&sq za6P!$V7b0PWgq=z?u8esctZT=yPj1rEY*=Mxu#FSAdW=B{$TCG2RC&vLnTwjd z5RMA`eIyoE+0VP2H0jmZ_#|_q(+9m$9G)6Dxvlgjm7uhmM7eDDx)~m`{iqoZiL^u{wTK z?_#^-OM~IuT5gn%FgLj^{Vo>Si!4>`6vO@6PnzgN1c#<;CkP~Wf6Qi@^f9x?3_8+{ zSil^})Ki4{X&dDz+;)i<-??p|OYcF#+RxEFxcY`jKB(Xh%h)gU8793|iCiIliGw7> zi9AP*5S?KdbLf)}jNy3BDo12xSogA7xP;T){IN5-4_N&n%+J3ssvo>NXT9#iLR24- z6~d@xtv+z*ystY*FhVjKg_9!5ex=SeWf09x!m2*oLg2F9IRKM4`R4R0(n^46?Taw1Ua4 zonFj2OX)(mtVAomg6xy9*Ap>{N;Kog0>-0DDrZ~sp3b9)vzYWqUY4c<-YgEJihHjN z9f8`En)3NM^laCIujiQu{ENyP+_0{8=qJ~$_u(FfS|XM1=ML}A1dZQfaJ{H$9gs4; z3qn-J)=Tp}4jkE2qP8#71YOOMEA|XNr)mI+f{B2ZcD&YyPoK}=lK@2!C}}y5Y=uGz zYGAE$B@&q=TW_Kq>VqCe$)jofQ6w`cLbQAHla;J$oNc>a_86XMOxLQ(;le!-5ludZ zqG4-E>eXh(XvUuw&vCOL1k%pALZr~B%CAW`BzY}9MTKKNIy`X|B6lF+(GYK{-MUpS zVKDxhViAkWmtu6J^;Ptw0JfbzFEi!+OZu3v?iw;Q91sa*aeFxAm_|Xy7hjmUT*|*M zYGfxFLA1-oDMDS{E-i6?6;wFwpMAkQH6;2V#;%H|5r}~`al`|4z=-Lq!*WUfV zcSH;r&h0#4*b572LF%k;S>3Pmz0lI0x8{E0wvZ#WoRe%iAXxhCwf)|L$3M3ySy|H2 zqB_PjF`_ytu|h2@<@=KGg=8WsY&`^RU<`)fC6>@Y7mkAjpN%mo9i~tOd1G~;o?kv? zPMgIfqMnq=M`L?>-v&_9g&l^i7r*hHvrWkN!b)W(7q0C615Q+jfg`1eN@aezP=%E} z%JkZ%x-@K(I@`e*7hyRxxuHrYm@=o)vwvGipoo;?3q6*KT+d?66l8tgw-P}JfOmMG zb*_|INKMO^ajDQ;5>p-Q3O*L4Y&E&;3ExLdJN1JT!7|ospZR9abdbwGI0;H}RE}VW zz&3(29npU0Q+81CmHN}B+?W(w87V=jKK#yNyrm0s&lyW!fg8rd;bWIOeQJ6? zbJy_fpW!DJDI4G9_$k}DR=TTC%WbYMeM64@`+;&6Fg~La``}*FW=OFj`Ft3A-O4`d z@6Y<<3M`u3=Z(~(-Ds&aEbLzu7CT@`^Mp}w1P)6^UyiZ89x0xZ@DZmeL&4f{Txr5| z+2>vpZt4;gTTimOG`92+IkbDhALIwwsvY~eVaz!`m4_Q`#~JXsiy1Ef&>a_jhV-+` zNwOXF)SC`biCX;C!YMFz6Kmy~!8(3LxXMPXj!}0vh5P`)y1z%5V2OPEZPK`kk#4p9}*#oyTrkPn6ix5kP1`6hg9ea7rS)b;RZ8C?#5Y5N7 zA!J0d%9Be~=W` zV&R`_t_y}R0L4;(4{I!ZU#CQL-qUISdUc7L>2uqr#fRQ*^jTZ|#2L{>Xt2ir7}qZ)L~ieMGlDx!dee z598RDMXT=5nrnaYz7s+%m|qyzZSy|7u*H|)gPH-GM_&kpqaZx)4$ zBBg4EiwlbUf(;&li6wqy7R^<28{mww74_tJ??T!4wBcr}S8fWnR8x$0tFlm7Tmpz0 zcWABaKlAmV@Q<~I+APhtJ2gFGiL*A`$Pn6e-BrJ-mgnK|_GP4oYD~3mpT%yvVhggy z>sRGeD2G^0N>+4x`k4ON79hzB!_5X6<}}0!==D(HNbRn%C~8+Q1DQn!2!aw>T>d zYrcBR`Cw~MqsG4uhh>6R;BE|)y|EqRn->$6V?{{UqHzyxHnu`Yqfd}5E_X2=?5lTp z_aAE*R~R=ffrQCFGWuFrKaRI2Hl^lfSBr1uTOpa7$um67gmiyu%^g^xeYm<4wx8us zoR~mKv(?YCe_Wsq|12cYu=hj!Sr(;_Ep&gx?2sT^Ixb*@V}0WhdN8;pACxs=42gf! zC6$w#uDR=cL2TT$)0;*#bFkw1ly^~+j7-_Dk(X@`vau!5<+%IwB>ILf+X~Gu#yzc? zOa__R!}KYw-GlyG8Y=)j5FDG!9hj35XfOW{K~1_*g-^!oh|U%~$M3_D33i79)sI8< z)d>1P_3{_IM_1}4;yyJk?oM{WxU=O?4kG3dwJ7%U>Rq?vw+H&9(JPS*Y4k! zxilX}F?R|o18{_)cLyV|GkL7R_JQrJKL&@K)f^xHk>!ZoWH)-@Wv`{@^-jmu_9^(j zQX`6zd@K$*4`oEV=wBKE&KW?jcTN0;~`K}p$O*=EFfOMD$~ zH1(7M;doG=bQE9b`7Nx?NnqT2;k)Oj1IZS4d-@MfVYy&hj2W#EcxnIsLGcM2+N(9X z4W0BnHtSqg2o?Xm-B&ruF#Oa)t4mOOaqGdBro<0>J01AYTrgOL$J<2q>f8T;@#Y_1 zv=$)384|eSt06MVO(0j5JL(#xulzbRRJzF~8LQ)U@5m{6zYH zCT>)y79MT=(!Eyi^jozyD~Usm@Ceh)9P@Re8z~Iw#Z8CvY&n!eOyv~_?Gn5L(#Fa4 zOWPOL^x)14HcrA{YOFY5u4lfGiofY0sEw`_dYQuC>5z^c(yZ+WKLx{QTU$-cx95< zX^A=zL#~%YT+p*EdyMK3otynU5?affK3RxmwVltEn4#ccU>|uE1L81-sQr?Y_e(zD z2H)a>H*E5tmFq3FGvp0Shd~@P_XxTdc!%!2f(AnE~V>yCK9aINf zZqhdWAb|(v`dWmYJ>r-pftx+)dSziC;cI=%GBo{Q#wd_$|Xt9XN?>|4CQYP27 zG-_id)m+%LpO+2*N>!F+-in3*jsOtX@OU)`hyh5ApI- zoVdtN%1rmH{sx^<2F>vufh?<#Q>YwkqWp!OEQ-i^-%w_(2pJQ$WiX4R=vnQg+^EqH=eTOqe$mTnc5DRK?Nut=q%4fiya0g(7~Y^rT_vND6Hb z(*!6T18c!!mEb?<%tlxopCL{93*H?|2+Hm~c2S2B6vh5fB}8vSAOiQ<9qRQnWH!EV zqb&l3vh`o^NCTaN(FJ@Rw{w?+hgu5eF0+1T6_HTeI1fDP?HTol;ohuR9ms|EVJ&4R z4=>O9zgabt1fp_GSS5xla$A1Zz$-m`JUpDP@|Icxy9`b6vjNJ09-ak6d!K`7Ou~s~ zJN(sOyS?61LliDY(W7@L1v|X;5QMxB%dP#FquS6Ea3wDcvb7Kk3%0U!!lTxPd{9SC zqBIE~WMeYH=5d2I${|cV!%XNPoqUB%h9F@%^ z4bPGDE*HFxe8tDo6~4%Iv_P4$h4gbp#vIkZ`o#uNFxZ0kX}? zW;6dBX>P)D#Ia?ho16onLZnWC&IVC5dlT~gdC!*S-y68^e^6I2j6pKJ>;b#^&A2Zh ziWy_RruOtP8Qdyq z!0gl_tf+Habx9)g2VF>QI=(^=Q%bTYWa~=0tF&z=+QKh1HSgYGqS{cO+?SfaKz4`A z4{^_)BF4CpK+GOPT-lYawAn~>=qfHaB5%hhd~nLTiz=g5%)+q&7_4s?CskDg_`FAc z2knFY;QW2(4Rx?0Ug6P=44`s&$wMJ36@vP^HCjKLnC%!IvisoK4TXgUF>=(XquN|2gal*U zlhX&~dBukgjpl8IQ{UnQ%3#a!q=rUs9&AK7_FDuuQ)wqk0WW&xk*rdLbs*~;!Fxy} zb;394p$)t-BhX#sYFhNSy-3bljk`Xk1Dkwh1*slxa=#8AoIc4G-efRx z<3+)%-rdAMdi_@&(usbWBKQq(X!YCc@L(&yeG*9Fakm_Ix|UX^;M$2N<){X>QO80n zZ&><*7@YPVXgqb<&MtzLNmY_ZH~beSRrUu2i~JD{ggkP1r`A-HT&t?Ke;y~Qp{~dI zd8_UNDL<0L7LQ1KaLN5N_mSF$gYasQGk_#UbHyVZA)x`eH%4=%N8sXfrfTd5E06mZk`+fm{-C5=$HYEO|DQqnk| zoa9^Be>0b}eT}D?j{e+tcNv#|GAl+u)xY)TW@uyIUK`|r46RSxpPZZIvOtV{0ULl$ z6w|rtDeg7OCTzFMPVXEF_OU2!pR=%H!8uy2kg;~ZX#|s#xUW)VMMW2vPVnmQ*WBD_^6Z%!pbBM2d0lX=Zu)n6Gt3jd_XZ-?>uz`0eX_gn zyCPj@DJ_Q19ehO#ptn5i9Y}D@_TC-v=KgLBMuxfi9I(rHOXBZakue(A^ zFTk$B-&qCh;{BtCze2_=I9u0{ZdC6=Ylr=MK1k{$F60g(#y~=iiqjAh{@{#67ct!l z6roV3gDxa<&qzzKw|Y9AM2CIA`$t8OcjYebBdMZg(uJ7C*V5EP!7{@-4)Uua#*OM~ zeCs(KC*=`{c@0g;A?+<3MfXP2(nRs0!m!?`-}8mA#uwH!hZyz+FGXc3r;E+hXyy=q?+Fy#8 z`iw1Y7*G5n5lPRNX9ZiHL3$cPxE{}qd@IA(vhhDwy5$ELi+epLUHO!Yd7aC750@A5 z#?ECOcK__47wuyh#c+>XGWl$LnL8i*6zb^&Xsliunxy5c@Zd#d(u-B>F(1Zz3I&*1 z>-Z);pIu@6ouz$Chg;yIj^;z4>=hPjR%U88kAf(!)lWI>_a?C8QoY^~27`jWjJp=8Fc-)lWm2!D+(%b?c*xBB@g~Y`t19^7U2JG*w5&@cV{6X%TXON2cI|~@=4xH zdAb+8%ap`#Wja4(_AZH;RchMceT*hQ*#!cB=J?!8<<6J0ZGPnRhmAFb<@n_{$@nYm zy0R7jJ`AyJU z8AqUzTus=}db>v6T#Zd@tnVz3*6fajh2K!iy!7ue0dSerak)K0ij<{$-Ms$lz#~^% z2e_jMwzI|!X;j)nq%C0U*qHxHl@Es?Z@IbYY_We6kVG1n>AEXiZJ%M&#M!^ z&#AF7$pbP6LN-Q(V-iWt2Qu<@;`V1$+}5qAXl>RKGy@yy5Y5f5v$g%@=o2J;Y81zr ze4n6{_sak|3u||s=>P{=3rneDM#BnYKT;}3GASxoMaUBuC)eA5Hy}ao<=j#_;M=h7 zTdE#Euxge87JxJm@%-R87KIOBn-L!i!4dxwt&8*9;4~L0&WoV`E^-tz0MY zXX|?e?(c%Wn{2aKX z^ZZmuyOChpLHN)C)Xl7TGMX>+A^|sA!#6{m7sFtMu~@(V4HZlQ1JYKBaH;hXn zZ5fmn=?bR=Bs7rrhszcm4thp@^Ab-m)i%FMx&)=}iI`9dH_3F(WjDODCv%S8Mt@bx zVDKli<7QTa=bA@|H>OZGq?2#$EX^C#6ELMkrMW+N$LCbN+$3QV>to7oUviVQ;5`OTlyFcj$enQPPX z|K^z1B`#g$$pURYr`Xc&z9cf1F2U(@c8tx|jK#X=|5I?7~ zITd>8gIQJ_xwfBMnZsl@yHbI;`K2V)IIQjC-7x=5@8(O(Yt&XpX-DX2qZc?QAbB?{ZM6Laqn6me%Mo8QFYjDh!c-1C~k-QT|KghW5xu%u|$&Sl)ap$_* zi@L&<3(4tgi5?}Y(BN@9kdkmVuJZY_Fm?Kp(Z|WU6039$Yj{B4&whNkKN2UW1j6jp^xoz2eoC+)VhXOp%GpG>sAOh@ z0-=36-N&C)|C;i1K!N7&Zp^UO*4DwfRW%r&j<(S>xx|LH_ufsKe1gI}-27fv<~aBp zo(koSt`$uK`&aQr(oAxltreL6l1VK`_WeZlo#}brLIuMzQlRy^>hpYFI#C`MPIJ7? zPlTS|-mL9=9<<<7WGYek6Sl;D^4w(2V>VxKIR!FKFywFe7NC{C&o!6jtGzr*PC8C^ zYu!|oaIOId7+lGY)j`DUj3E*0GpTepNP@1TKCd5gzh(w;u~P*ZB!QKq%yVqeHLM@! z{-SdyCY8hZgs_FH>+>3@aZC|+`>@Pv5kbhCA&l6nNw+CeXxQ{>`2@iC-u4Cfx|r^h zPg42Sf zg;Aca9or+ZIg*lS3(pG#2NzdEOu3BxJh`7=ateV!Sn`OwH8qscZCClh=d?(Sa4MUE zFa`slb!!oT{L(aFH*fpn_?%M*qfGSZik`!{dp{9>kunfteN^Nxc!(Qym7fu#S?ZhP z&+UhH;Tg7gmyD$jm)+7KbxdY+P*4nZ06qf!iX1;Vo+R@=mjN03=c*TqDPB}qDLzWe z=Yf%xIdzkQR=t{0m)QR|qb~FDk*7YaQ<;*HhMA(n+rEhL1wxOhuNeoHDTvx)-;>6! zMnSpf_30Z{DF-Kc47kxn;5iHc7k+x1N#ly0s&n`QpAQi~87{urJNr*&1`U7lFV8*Y zx76ZN+*`Tx0}W#sKbi%xzgHBksiR%QA;Dyx0YmMkW>?*w!c~|uMS`TFdSgTJ&X*rwulm3*^iIACjPJ$2N2S*6!2v-ib1rG_23(p9z3U31c2LTM>Cqg2^HX<@&I$}TK8WIc= zCzAMgeX0fN8kri|3^^Ei2?YX$1w|Dl9c3C79hDu`_}fG42W-!`cKVefn5u`c%$Xz`?o$N%yvxWQ4%w9#Vg zz2;#!Jv@=sT|7iRX=xdzop$A%Teh+d5YT!#@jemsYyc1WxL=t;P*FNo+9iBNR?~suFN(vb_wr#!+$f2gV9#z`@ zLLmcB4^$a~nV>dp6IEUkM(90szU(@={_tgGA4 z$fEWVCN3r_s!)Bd?KLnSg+N!5Hi^Z^Q+TJQQ#UaR96a(&zV@Ix-@{As|S; zU$T^=b}87IQQn6!$O>_`8^=5w&)_`0K60x;cYNO+L~y+i&K*6ixZ40SVF5<{A|iOr zQ4!?v*=R#q<27^%=q9O77m*j8nW@d;?9C}(zERSBYCjYc2%)$TxWl~NT<5@+vxw`q z>r`j|&>;~Y^4UqftD&5$F@me;FrE(XVN9ma-mDndqL>K*+9D$S% zqP-t@vsWgd0RIa4`0t#n)K_7YEprsY1z315xbo7SWpGs76x*Y(_3<je3ZIP+Z z*(uFfPo8xv_zxst@K_leUMN$hPRub|;BVJ!Y+zU8T;t~zyYQM8%5sDoO$fsAum|$v z(@{-sXe@aFHoFAc*~VK%cR9wW zJO2Pk9m}E1C029-vrOOVJm<>=kZ>KrxY~|Fi$Gf7@6W+&^@7Z>zyhRf_Ui2NSML@@M zv;durr!g}W#%?9NOJ%OStC&$!+w0P57xKI~yP)>KSc|4Iq{~c42O$u3UgEGMk`i?-FewrHJyB*rPrwZ;MaCzK< zREF6}ic6q~>W3mGcytBY>`A;~{0mhib+tiAh=(kBdsgU`#Xz5&DH3Gw0b_c#eh~JS-5ybQl_67!On)$reGrg45ei$-*8C(ed!7pHp4cw#~b}8*8y; zd{5RnEe&#_*Ny@OB|A=hB&u6)oRcdj_Cc-Vn{XjTK%C8A=miOnEiMRoUIAk%n2YME zNqOQ&DRvNIOQnBu^Er0Q={!-6HbH%#hPZ{)@PCU(0z%%YRIDsr2L}rp)-df zoCx_{>|#JjE$z(;MsHc!UQAZs$I4rI8y_8eDj=W+kZUW-WXVYUF!(b+VDhNoMK3Qd zL!f{skRuf#zVu)oh<3CPl|$>BL+z2H_NfzI6YDNOiNmqvRIj(#1NFI~5$`<4S~#wu zk3=}zRpf`pli_?@Y_+N3IAX%g;8G8svf$fqy?^XyYmhJtCa!?VtJGX80?z`Df)P)Y5qb0^}| zu#%&kaHi5{xwXTh&7tHRdhkT%XrmeX-h?LE`Nt%eQ$^Z3mC~)s#6P+X6nbn|TXs?e zf0s~`PxU&5KLeaGoN9-xrG*1EHwSUocUas(2~&QHvskN~Zr9{CT&HQ5R$T|ztBY9# z!#=A*bD}Y%81WKEx_4EqEP%unS#_)Ba24#~R6`BR?IC%jc0 zx5F|&C1@3att@()LdIVqKN*Sca$!>;O1H|d_9@&cwvnK#cerT@qlXN>bh+*hwsY$Q z)SspQ(Cu1-k zRR_Ac)$C}!+3ff-)MOjSqe|TPbNgc00x9q@dqfDDil`XnNrk(KQs=L z6tJYg2r6HWy&6TCh9*KGQ!(5!MSd19+cIamNyuEwBpW}1JlNlK8CneuRL!yj-I|R^ z4!CUD3vmH^RUwz0)}oPXsZCyj&-0_xrG%enK_L4~DAv)}2^7&b$y$woTRUk4&jq*n z{FH25W;09Je!42Yje8{>H=$UM>7JWq_M|N+NB_)ee;3Eg0m$FB)WWQ}jZZprlYfFZ z`yi3cijb2BIWrrGX`7jF)Xj4L0q^yR!2dPNeXxt_bqmh_8UvI3Y??`1PV@2p&?@pP zVds(LKB*!fpwDC-}_i*-U{Sr7@2b)(zuJ_pdBg=~0SNFj5a%Q&e+mg#KB|G)>i zX3*jgj0#hjyiea6~%=9REJLT7zZ8h^O$=sy}TL(EhyU1}Tc$f*z10u7J4 zbEVU;+5A{+VMCUDQ0;*He%iS2vqdjF8Bv@iD-f7Sg_IjG3+E_wI><7S4l#z_GUn%G%b-FY?~WR7|ze8U7(nX!FAe=+k^8#L^g!VK%C zJtMl4)6>)2TjjBSK~dW8*yGV1&}ld}HAs9m7YgNPs7%pRN6|RpsOX3cTFNz~u$fsz z95huHjSoNlbp^7@3{z6DOCOKkwvp&l7jkZ>>XEsFQd(7L*A+j43*6>$T8Kvh!e)&vCQIGOZ)^Qt2mG(pDhwvUm66lhx9aS?nILUN+jH)Vn z7L%BHHu=?4ynHw&4ERg%{TVDc#ciKD^JhV0s%v&Zg=53B?oivx}%G%#2(Lm>v&^$-t3kiHwU&N=S)MPE`M?qNJv%s_Z^)X`bae?TM-5GG?l- z<2nW3y6HBe`?C4cD-z51Jg7Xw_tGx|U)%LOtbNn<(kGGY^E{-!^Y3=S{@2IT%rAPa zX3Ld)1|dJ2KEH`H$<4@BVRA|i>>-L?PD6x6Nd{%j z2gNy22_>C`B^{JSGmv#507e)#g4Ox{Aq~jFk`9buCC0&{2hqI~sQo4c1OW2&1wi~i DqysK0 literal 0 HcmV?d00001 diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 b/packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..a90eea85f6f7bded69ff5d40114447a6d8b48cfe GIT binary patch literal 10344 zcmV-uD3{lFPew8T0RR9104Qhx4gdfE08C&204NXu0RR9100000000000000000000 z00006U;u&y2wDl83=s$l;$X`j0X7081A}f0fqDQ0AO(pa2ZI3&fifGaK4piDbvuxV z6#5b(Dmas7|Nm{dAu`ZEP~ECnb|eyPwiTFUR5y*Mx2Ne~jX@c8uTXpvCb3wegGR;m z%=c2Wc%sfsG@ovJqNOc3b(O8mWQ$5aQt&6euR0L)w!<=-Mo2Q-4$p7%|J(~)E-8qF zjfJA9fY?*gpf8b%N?0{@F4INkf2*e}_oBAFibc9tdw-pWYuQY8lBF!cG4Br*%mc*Lwiq#xeHSOGr0 zb3DaLRH-%IWLxZU$ni)jzs}Tmb-AwfieA&}dsA=e?Y*n_u7vzZe(VRLZ(9jDAAg~< z{-(~=#k$(dkGQkKjyzQ`)$g9TN+Uh6(FO&9*7@)=wBO=IbUWS1Wr7ZL3;5In&{ouKr^jC~kC6N*wp;O?) z3D7S;P+b2CTv%oIF)ooGAILnNYNE-vh3pz@2_Ax4+7TexkKPf%YFRomh!yLo0K>jA zcX`b>42T$gVRMZytzMGx+X|FM#wHD#E(Iml{*pw z7WQ zIZj`BRFq{Z4eVMa#dW*I~SqUlfrfocRB3HkDq$pXxlk!f8y-9NoqE^A3>wv;wqSRw&jw+~g zG6HO4qBYgc3kdN@iLA9GwoR(d5Z3`k3v4b$-t;j$bRS7t=AB< z*o!nNAci4QlH@U)ksB2TLq{Om8nakk<&LpTD&E!F@)yP8HQ2lW(B_8N*qG|~tuaw{ z`(TY&UAK-73hUYBcTLLCMQ{Lg_@3LpTIQ8*3aqN@D&ny%V357wgydUpRP-2;zl>Rv z$XhnFqF zS^@8V3-rySqivp+krj`4oGzUaPcDl0UV9Q&O{_{d6nu}>yXqLD06QELrbtrCNRBEb zPl*&LCq*hqiK?VbHBzDac>@Uct`z0Nw;-s9uPuf7d50NYW6#HNM_t>V&pWJ&HO_{P zQ;5~!9WIM>gBtLm4hjuWXo5lu6xyKB0fjCo^gy8xni&JNw~~oHGA(H2tTy&!%vu<( zdWKs#!UNNEoC27^k!eXA(y6zfDU9Z1F1Sw@dtf)%I^wihc$)9R1JzKSH_dFYGYh~| zrO#c&+HxVg@)E>QqfJ^GIREfk(7u$7vXJKWyhE0N8Z*^Rf|{7mE~C0yFN?L3k1-Sd zqZL+Bn8c0>GeS~J$-c|8efwAmVb}DyoiIPHq%?nN-Ej>B&UfMs@^2uIS)`mxnw;$A zu>o~nffHD7KqX42C_+GX5^w!U#huHrPkKSkKIIE5>U-H~29XKv?$XFGzrwfiq zC5Ukla;Aa&CnFgYv6pKV9!mmclPuG;VS>%zl+2fagq22YgeJD~@0f6>71j?oL3Z zddf6}XY;+Aw*)QNW}qvM66WudwqFqW&?Ac*td`|AWM6X!qCo^%+Izy#o4E$mT9qu# zO*+TlJ^kGD3*Rf&ZtxA>2iKyqrU&49U61x{#c8Fe#J-h$1> zr?8AqMI{@elSe3qj(ao5{rL+q3t-d-`><=)vSDnfG+I@W9G?e8fe1gz*uBxp7Bwkq zKAdXon2(Imy2BTxZcELRa+WOwAe2b^6&g=ub7NJXyT%?2-b+cic~ z0(Am|A~9tEi$>UN(5Qn;;>rLXjorsS0Z9%52}#%kAd(_t2n9`OG^NpuCUSY8&;r+_ zlmRLlQL{m(4K7J519WKAu1X+FJ-E~dSsK8lAzT{4r7>KZz-LWSXv2EyEM<`Qo;e6& zVer5T#PG5T0A5?IK~UFhF9>2YnHYhv^)RsWFxq|vA~^tn_Z&g+oIoV!$;66+i-(1) zhlQJmg}ahT9$t{A7yJcXIe2?G_;@(@dN}wgOY--E44ru~^NwG0R@;PPb)=r{&_S%R zs(wPPOJ%EH4b(0!4nUy6ha9i+MEs>82bNW7?i3l2P2O1Y~_ zBG-??&bBS5!!&G~)+nYy#xHr3)&tKiIDX&vY_lgRjQWqrWZTivv}l^DVHJ@lYF$f_ z)%3M}Yg*F!enh3~9P=^Hz$i8C6@mb331l>akR}K-8m$`UHpTPdQ#MCIR=I~Ft5{3S zN;(IYuW1%y)?yB&@mzViE)*fhXa<)2eayJTmZoW6a=Mn_SkzrrMGf~eZjeQsJk_d& zAfVVd+K>g!Qr{h-5Cl~u!62dTDHBt9t1Co(7FKrYguMU{bu|OP#~%|G(gL66nL9LN zED-zrech#*cn+*fDEN3)H?a$cIut&aIsd6intT6lha3Kud{e@8eNUbF*%PYz?3C)< zU_Q0>TG-m9vb^ov3q!C#ekSfktG=WM;y#zA$30S}a9a+2Y}ic{+lBRGdx2(}b1=A8 z;rE-Si@aoLWF{uq1XvGivM5aCv%zo8CcKvYjjqtfqcetz4Z&};ddk!GGzvvGyk*3s zqM2SHSj;(cWVGg`(aFR#)kt&>zT~D@uR;OzpKsQ3{S0>GFYd%k|y|gtOUd_7KlCW+eEzfhz zLnt6fZ0fKp2N?N*9a2B6VXduPnkY^tPG`pr?F}>Yy{+c`^NVeZ=4^mTLbz!YB{q6> z*Xyo7CfuW$EfdX+Q^dW`-M&-ZDZsQ1*Hx~*((HgmX*32DEabPFW7m7Z@{e2zu2aOD=UkZ$ej<+M>G&4S_?pEW zE;wH_Smf$n?e#mpGfv%e3{uxInR&(772kEA-I(Op*Uvjr`WQ(Jn4cT~phT8Q)AP8N zvSrOL7xy)WFN2b8^&x@x%j2G^z6t}eNccqk0Q9K^eAg@rVyEw;*gDxD8#fM@h_<%3 zRXrkE<#ltyK2X(bq0vQb**0CsDt9cUH>~*h0IS(c!xTYCCWREWZSmEJO@F7rg%f+@ zi|be1v>mGU_Scvaf8i6(aDcSohPX}>`yKVfw+X^$wU4fsZY?pI2y`p%`v)9rsbOeK z%u+R3(lr>V_W3JVfu2QqoFkj4_b)i)oq7Wjy?0U6y(bhdVA?}$UsfzijRI!*tfMas z!%`InG$+THB`_a@nn0gLP!}6F()mo9XZ!;rSG2TiP(WEH*LM@!7;C@vjIJNA!gX2Z z5qsCv#akhj`I-;*2Kr4Dayw6S_F7wB1T-<7VjP7&3KF79P%=Ud&4EHn^HA{TvMoSA z(6L*X9|LND1qFa6qzWS)!X%Vnq^D@u6qd;)<{hD$k2Th^Dz>OVjhhaM0#Z2 zk%xcPKyNNrThv`tWGfFbQ>+E_AD}kl*VoUsC#Yel{tke$yVy)BDcR21#BzlqQ{D63 zoQq0cum=2hp|*w^E0t;{A~@I5sW0n)Flnn@abKtAr6pDq1bYLpmZTlVxYrsIs-*m$ z9U5b`#E@pCVvbPW2#uJUM6kDGkZip7i)_PE=p%zgQmB;qPD`k$P1HMv9g=C8MecQT z3^6Jv`^{BgbmK$f>DHHh{!Uqpdt%E347CJVBeEGE=^>I+INp;PV|{I6?XNJcIz$ny2vAhJ~?n@BIQPY zbFzvD{0$>LP)Dw+0?kKgpS;;Bn0IV)X=VolMV@XQzFD{N)~n9Z3^tBpp~(}Si3D?; z$RMgPhG0Yf;2)qU!iw~QEssZFR(Hp)QHZ~Z&vbxjlmQ=3{$w~?8w(ix-{ zsiNMggF!-dh-T_1${jEj4)d9BMKMB1ey99_c+UswwrjTJc2=20(T250Bu(@+B^xLT zHM$;6sj72_#r*aEK)h|?Vv8>vQG~_R;&9n!zNu0CyJbky#U||Hg+59ZKt^C9no&@=bZVQz7R0)yC1!C6vcY4pAd{tGEaLdw<=v+QEe2EUAtV-ziQe7k||V{b@1^rTpI;~ z&t&xVXw%vOsz&Lfw=}<)(M^VFpsvrinRw9An)S(tvvy#Zo!O&N*{Ly9ZN!p5SBOj% zp#aTaV*Zv1nCXtGu|!DDC<^WsdBGqttJTkS*rfu^9G2MDo3lP%hGHPV%v-gtjTZy; z3DnG)?tYKGO$@{z?c5vcyF!=Px=k}+3Ee~i%$bR68#07@^BBd5Hi_bPkr$16(@IHM z7w|TwT`my!K2+vSyb6w{Q6o%~82rRUW=-6QYjhL$?x$7MJMSvW25NNOoBEqrEF(Bg zh8wZgIdWQ!-n4>?oNi#+>z8F+=(;|`Q(yp1F&KX7Sg%bOvjqs>whjPSc824XCW9#Y-@7pG2ol98}`e$3*(Mx zi)2}Ulm=#9{&B0bB+!97|0;63w9AP6%7ny#kgr3!TNYvY0J9#8ev1^}TqF}PFPl8w)~>s>4ldrR{qk%r@e~h0-$@hcMBr_reB15)_(}0L>D{{k4m)~LE1K`4ogY6Q zvgRfgP>ClHyjcXGn%cW(?iD>FtRt2jPa(iy^R#<(t?uJ|c_JAJiN(%KBPjQ~& zmjP>7m9?Fxg*`px9{>Bly*=RfLpv8vW}Bs_OL86xE*DrUEMI6v~bM z4OXcUbQp!%(D7H{vkJ|9w#vempPw<)G^Mz&C3T~CKg+{TAz5isHm%r@uf`{SQf5!+$FcDM(nmlL%!adf zb+qsML0owlwmP#?KZ{9^o0Tj=3$IM)<&VeH4q^6e4-}lixFSgu9G@N`SH+P%RxF8V z<-I%i0K>ZVJ7<5Jtup}RYURP)xpO@Dt5qPSjjT0HWOFex*@2pb*C>^NwE#9Yl{ z?33w>+kVu`_A#>WHzhh9$LeD;k}8n=yHV#eR)LipNVJah^jo}JKeyf<)t;V#c7>wgCXkX3(aXY__R3sZ4=?ZSB_!sRd65kz6k%rOhs)}g-OM8e8?u5W_Ysh#xnN#M)VOFq*gHD^YZ zTZ^*43zILIW)MvnL!+C-KKbOZSgNv8Gk1Ayr6zmdda%K{*sM_xD|c)qBY6v-`^AMh z#T7-l67}AZY=Hn8fx5Z01H!b|=~C^l2h24v6L(IlA;Lf7aq@ryXXO;Bh>vDSE5u|y zLU&H?cXyi2^Fj!HA=I|B%22hrW;1LU`&0kVoGrb00_s@sIB#-95@biO=N8C~kYb98 z>!I_irFfIl_c3`PQF*@Uy-6;}XQz%bE(j-gdk>@3wLQ@)!yAr5eN({UOAGUOk z%vRtX$*Jn5Q4a5&#?nO&_Q8x<;Bxoaj2G5B~<_>q01EI;7#WAJP4 z+L?!6m-i4Atk^zwqr>B}^`~X>vdOU$Zz`v?Hwc2C7 zsgrI|DHlpW>C+QoPbY#hrh%5WIwR1HXsuwEp7H0$5mIIR zkAh+bPn=Ql*69VISL&SZNTQI*Bxe=vuZWT{>Ktg1vDnycrwdGF{29^$4g1y};dK}xc8~mMWNR=UT)M91W z{4s{#2s>&rLYa3P;s#Dl>MgAiR~pll{4%eKhv36}K&sZ31j6cEq`viC!Rn=z+)Ida zs42A~wQ0_(E7XX~ysbk>+|=B9ZZtyB_>6k3kHQm$a zK2&NTsQ+H*kB;WeJqI_LZS!sxeRniAgLMxrNcGTMBYc3?vu5palxbM8sE2j{HqIOJ zNq~st4NQIJ@IxQCX*qjTFMysAS5q{)vS_A=3NLcxAd%xZ1Ancn7@+9Vh5>V zb4z#4ZX2_k!|uiy{@tj1Xwf3@xr5r#rw=cuDch@c=u)pMd`DZI1(+ku7Ess9WO)dj z>?tuQHxY=-3QY6H@iWv%NrJ8_R}~AIrpnh&dWQl_{r~D2JlH)AYI*ZEyJJLFVxH33 zwA(?!XcBwgYMHsOGq@28Tgv7rU@?TchvqK=Q=57`qwL~hYmI_Cxc#WqF7<5^%K+qB z>s+%U_i*dyR$#qvtpc-bET)PrV25kb!_3-!HQ`^yQkl=HsA+QRrQ@Ret*I*SDE>OO zqSt7483ct8qYflW&1KQKGF9d-b~qjXDe~gS54EW3OFUC1hhk>9C}wd8Nvg%_u*s8v zzsWxdAkNR9Ha!EM=;oXas$y&9F)9Rf?){ zTh5nQUqR!I?ar~#hJDYvp~UVjIoeVe1kD|qJ2X~R+|*OaODFGX-4A1V=7Zh34Z zMMZ)N<>B*o){4C zUPVGhBIeZ_=Ai4=cvE*>a&Wo_Bo#Rf+*xf!LLZ(L8G~2skJZ0S2r(ECGZmke7|lpb zuH9>hjiB5tE;xejTw#(_MHUVg^cxF~+>~nE#Z3Cz5ovctE z*tNsA5p2X?(kJEI_aZZ=`G&lRO5XH#*2#yx!>H^2Q?qAfxEBQ@kmbx@nQ0GW&@g2L zl#p~WSqhQ`H8NFNNoNEY*?;~b=L?1>&905^R#5}hG-XS?XY_!ZM2*KRG}`$J zm912w>c>JSj-+v)y5iBD%PXWo_H?;?w%KW)rlMo4%6Wazf4<4y2w3u@kg2#Ww~Z<- ztIEr<%|ZEBeAP2FC?ytKw|sS>cb@Og%F9MLnjqIqE7|b(oYcq(stiN6veF|fRJzc8 zGnGmk(Ms;IsaNnof4}z&hZZ^gowYI!YHZLatEK0vsIfn;AiZpDOX}lloE0WRWdavR zH?P#BRmlHILt{6cds$RSC_WogsMdU=K#@X!cscxTMKP5=)J#<84vaNwu_^W`v$eCw zfH6@Mnv}F{NG0Wv?+`d>zmsU*qbE*S>l^l_2GybtKF?Z1M2>7b4&bb8n8~Vz7J({K zoF4YV+fN|0Q&mD6ljtCk@EZO5tB$yeM@^A9K<%Md6n+`$jtwS{Q(fif2p!S*N)jSS zo+n&9l%74Jx{93q`{VQV#kykM)|Z7k2}qg0=eeW4@{iA<_4NwZui|k7XZWSA(8-&~ z8Ble#`U-%u#hQ-P7=*}>rPc1 zh6uZL4U+an^|J~;9S>^ow~CJAlC1a^2Gop2uaipPa z21f#)0H}4$y6q{cNA`26G|q-EQqq>M=g_FzslriWVOksdQFD?-Ab@p6p6l@|fyjK-J*x5x*^RHN@JN^-H#rjIVETTy@H_uh#gC!Op6N;!F z(O?3_`0*6Pew67e_0K7Xt`NY}9I1{#elpn`1SA7NCbKon%E-4A8d3!W14)25<89TE z5lvRZDn#VNgy_O|Y}K9YEJ&bU&GBCB4RsyefUR2#LddsOn>=mbUp+T_0CX1u-DPYu zF7nn_J9mwMo49Km9B964^^u>ZP`a4f5iGS~EhWGfv*_JQ+pm}=-$gwf8+W*ux$zKv z0;#q95ifhspV|dA-CgV5jPA&c+VWW2;$Vx|Sm@1B1R4Y61yx<1#!gR{2hPU|@tpGc zAE8(jo)_g8u5DIwGet0x<#La5zln7XyFj74+)Z{Kh7I*i%d2YCWgZ$bD#4v$%rLF_mB66DpRp~@w{)B$$^B$^R>S@i8CYk)V{da4 z%Lw$06Z^9oc0WmS;}rC_P7C`p_%p(76UpYGp z3j~l~{New#hQ!-uUif)kZvt?3{?M}^@aq1TMkV86X~rOvMu5n$U~K`~*<%H{S((vz zoHRp0HI^64GLpCq1Q4nd_+6&*xTj(2HxI_s=q(R)*%Lv=GHBUdkNLM05NDaHg5|P| zthT8GoEbIJ^j5yraTNjuTKr$mdd-L_G}WwSnhzn6p8BvavNYyvH3Q*0+|ZzZC1C~s zvtgx#(4uLse;i=3a@|9{_^PLxw!boe2Q^2Ho>Ac2U5*K*K*2IIvWQfaLa8C^0|vNJZ13RGwel`n*PheE~c zg!XeLDMTOUTLLfne{R|-g%p#&@i8`$k?mqy4iJKdLkOTS}(zoh908lUhW;qjdUZuZ7F5p%1t2M!E zkuJMKC**ZmXirC;;CI_x#MnGZi1%&cc1Gf6~4~UsJ zAq^QKeT~He#qAg6*LnpBV)o^&DWJH1y+51ZI~L5!GJFb%^VlPHzS}ejFKJL6DyWH6u8A%3K~me+Y^I^cj}OkYL3`Dq3xS zUS8_~btoUc?*9yjrRykKn!-}`@UYVunQ|r348rO5AJA(*Ity@)<|qcL4O_;%QD<2) zY(Nx>Rn*|71Z8jrYzb{R>et^$tMxj^l^`9nXa%tn>A3iT=a=*56Cu(I!y|;VKTmvw z@A^>_wIECg1Au2?KmH7rfHHt&G#qG%1h6f59s`N9Z48X=voSa(KaRo6O3WAnRdn+r zv@x{mfEGMeI$6J_)~U-lqcv;Pq!(YBAR)Ju5)&(wnQ)2C=hLXy1LGYTw?$^5o(E?x zDpc)i^RkeI4v~;S0oV6czd%sN{6ds#H;(=Q`!u&&HYV?3wSFCIVBPGE`n2&Ev2vX~ zwU_YGl3FiE%~E=EitxoOATybhK-Eb_T%^vJL{{R(8}E(0q0jp`)~PAhcOapT0q}yf zC36Vfu%tu@ib#yo|CYzYI8{S3uv2{kBjP;mQb>sS(zw8b`c}q zWqI}|(Icoo%XzQmS%6|fNZ<9dnUyoZqp;UA{4gV_NfZAmLFm5|eCL89A)}z8p$o&n z6pkeV8wVFpq$ts1#EQe0C`qyu0;$pnrOP0aDN8mn38@@1x$@*IP^d_;QA(6jP*Tw- zQ?5cKEgd}rBNH=>g_Vt+gOiJ!hgX$qKK{U9kr}SL5FEJZX7CUJLckSQU31Be5Fr#I zL1c&mQ6U;chr%F6n2tDV$T1%q9PR697K@b`87R%k@18Yi?|oa&+}Hw{>>lu%_n3_F(PJ`6#Nt`0$e zVdM667!VJSrU5Md*nBWr3&X?YWO#~TS1HorDI&lFFbZ`;84eT_6+glOnwMmpd*ME$ znCnhRh^EDlqhO1f>8t3&+ewp?=v2^<=Io&TCcf@{Fjiv@!SwVG`7mp=@P$dv*MtxP GG{*pT{>~Tx literal 0 HcmV?d00001 diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Script-Regular.ttf b/packages/extension/media/vendor/katex/fonts/KaTeX_Script-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..fd679bf374af72f2a183b97b40c9c7e9e51fbe5e GIT binary patch literal 16648 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 literal 0 HcmV?d00001 diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Script-Regular.woff b/packages/extension/media/vendor/katex/fonts/KaTeX_Script-Regular.woff new file mode 100644 index 0000000000000000000000000000000000000000..0e7da821eee0dd05a0a6f0b16c2c1345dc573a84 GIT binary patch literal 10588 zcmY+q1yCJL6E1uXdI;|B?(Xhx!7aeS-Q5Wm+}&M*yIXJz?i$=ZSm5%0_g4M&_trBz zPft&GPi@uCPW5^yOGyEMfKQ>-2O#}-7X|%a{$Kz9FUo4nYybeU>!()r6Gl=nR(&RR z#*Ux5(NE6t2?;=l>`8wmigqQpJ4f729P&*w6OcMdjkMqIspJA1TfR9kA<rRxv8rv&YZSD_>s2I^bx-<*Hf@NhBW^1m%w;1|%>F5}B~X9ZF5G={X29M;BxMFhTbd z5`k!!-|pWIGK3?5+d%Q;xdw}5py&CWUuMe=#Uy{rhAtwA2&MJ=W;J;sF75W zoBj*ZxN!!FwLHC^H#fQt6ZQ9Cmim!j`aBYC73x`KAXxlPEF{JjubWk^yUIuu7T=pI zrgwcA&=OP~g}-hqi!u;pL_Ot;D49K9rb)U^3Nmg#O^^Uy&$|>#mh|z=+hhQ?nP?p~ zpEC`5t1FP)9CqfX^%d{es2ZhY22_3w6{dbYrnCzAfY5DNVN6e(9rPdvs9&}ICu+pL zBS2j4Dw=iJwZYm&8*lvw+(u4E{ry*M?~fCgy{~)qO94cI+teNNL@KFgGhXz5dv<9Z zg`Jb|puA%D`uGWN_E< zs(!mgbkvdOH5!s*&dG!7NyTKuLir2*y#8Q%t%-G)PPd?=g(9=&PL@t?xu*J3bc$(R z|93=og_)7VumP+2im*M;8nW^vo96bUPNlqL_Ui8k=S%X{Pax!|KSfI2LqX!6@n76` zQTU7(4pa>05a)Drs0qd#(Nb_Ai7H?e(InzBemaqQ#KF;sdr8m#7?lq#y*XTimgdE$k$E)CQP*@Z2QccijMdOo7zv#T$ISv@ePU_^W(cL|N4_(vm7Vrc0G^? zYUr=X>fZ06aJaWFNU6^L(sveCtrTOH*!Y)yH50x>O%}gZiRc#y69objn27qN4KME7 z3;ss%Hv9&rCtY8_bApf3NPi5}1SNmgooBrOxMK?FFl9f{;%A*sEtsbsN1ldj7#;oX zu(y*?mZ1ct_aSv<|%VUkoUpC3Eo%pE5TWV`x?zg)c&a2?95c z%Obts)SD_y=J7K{7KFFcLXme_NC=RtOGNrc;@utpFyaNf4#cL0)nutd+nD%P^QtGG zFUh4`i{eiVxr?RYLh3AKo3`*U?siz$t$Ercg*Okm+WxDHrAkbhZqAVjV~W6x4zYm`peZWA(M3ZZzJ0_BQ z(|`RXh(($rL@|iDg2G&R`+a9l{R`3Xi}#AIVZjSUA^PeN<^Zb*h`r9EH(6b1hC#zx z$i3934hX?MBILF{#0*%CeMykFp9;=H=;FJU;yyL>enjmden=Bv3_q<@I1;>qYH^|T zV?Fis^@1MHdyu#uTBJ!@3&0Mk8Cw1`MF2^gw#s3O)?rqdi-QgfR>O)} z_C%nOOE)eFYnVL0+(T^l5^Q10Bn-z(G$j6>cA94`HNpR3?+wG>xiyp>S z$SN-k1j-itq~*)xAmHHCiy}2{^RC0#cZ|no7&#nxjCnAXP`60xH-IZ0*N502iVPOn zzYwF$!OTx5Ph_gy+W4t68*(>{OGp|52&#(PD-j+DfC#`#NA#t+rqv=Qe!bSSKSlg& zlROel{LfFpPp+jg4)!r$|C!}=wM;l(Ig&bULqu8VCg)Owm5A1#3-6x30QiwyQTK1^ z@{wM34jC=Zf8UKcde2acA&G=kQ%TV-d8pvz(az;$^~CRnL<(g%B#x}ve}4;I1}F3B z(b$|qY;mZ<^#%5dvc$Jl--;RIEE=nE0tt~$JxXYGQPHOh=Op$T!=y-^4(U5avzcy1 zmGhS$1-X%RK?NKk>Gno7mK^>!VG{0_o3N<@_1S8{@?++`I2p?4D2D!I{OE(Jo+LmQB85(ZXNk zpZg7NyrJ!pdsBwrVXZnVN4b2mSMN}5vFYS`#T|Jo!}uN^5R3Qad;sAP2x(|DxeLpF zNg)2KMME-nW!Lhty=3e=olEk?u-F_b2}2DRgAkrPl9t`hFXwNVr%5>L88&#hK9&!o z!2?lUL^CU*a2d6!Y_`y$p~t6#PXXyb++WuD8E7npaig> zqF@V$8ga>mo73@gl!w&kt!ciU0g_RF=o;t8vNx-eAl%TxG?OXgWk@-Bdf5h1CszJa zn&DC?2}RPY_GJ42LdyCGrJf#5{VEYA>qYK8x4zY5k~e&xG%m>F79@I0JP1DG@Tmr} zLSmX3C^QYMeigIs?)uP$Zv|qMChDp|!E#6XgAbF0R(U~^f zDRotzeeS28CMge8j*itH&OPBVhvMFjD?D-9JygCp7(CO)Yd`$l&{45-kXwecCf21P z2J^+}goNmDJXlDpo2D$Sv%@PEM_1<2>o*WdC&xJBtaOfaXKApExBZgNtRLTSjEz># z`?wV)A_*KpkwfAQB{nT4a>*7gD}=0=LaM(hpNvE~G>PrLll)r2x6nK0PbAlcEdy8f z&3p)n-B#tP8SAF;C;i0%A?8dHSTD5Cr=tN-N}QXhfgUatngoqnDo}!A|1!%&^Q;W3 zg=Rx92rghTZ^s@>{TgPsi6BpoZLT~E8BE5FBR1Q0XjbWZ*CHVLd4hSPc{zPYLI#eA zwfQ{^h>bB4!d<-6^wbNiOCK?jLpPge+kGHC_F}wt0@UX zP)hsZb{w`e<_(dOXRGn&e6ZdUrdvEl zvovwJG~-(<=Zho3HsJ@GR zq@jAwB*22`h6186C7x zM@=knfezpTzv0xN(jG$r_hr_aCTGt$eqK+gAxtKU;}(cErtj803~>JW!l1{{iB?&{ z8Qv%)38Y0&F?HXT=)s-fBu%WQtf>kKmXz=R^OsoQ>3eMq37`KVmFcps_d3P5+*k2i zv$VV!sGhYM7ek(dhwxSZ&fVqcoQyFC+OGY>@OzC68jFK2!Jas$gSNAaHi6Y+&Q`bi z_4l7Af-6lc0UmkyanJOA$4D>#go?9@zH_&BII_bVr*C#RZeC6^wIycBIIOT$O|9Kt za$>R8rOyn3JAT57ckQWTurTaX9NA5lMN$zHU$KRoSFBY72BO`zA#ox!f1@&I^JMjy zU`P-w!BLWp@_3N-Q)>U|mql$!xRd_tGDLnzclLd+bX(7iL(4Va>iA z%g?8J*+h*GmO)SkCI6|i35#wk?i->Mp`ib5obpvdMl)$pO0KeDT!D^R+sDu}o8ATL zz|~oc4O2D}l%_W@c4@n^c6E#)&HPRse%D!M-j)^ssY6D6+d%-z7rC9Qjn4}-^q85t zk1FHX&x+QP>h1FM4b(dM7v`W>H~Hr`KIH85j(OFAvyaB<`9l}9qlU}eokzva{270u z&tlwLXCyswmmjO5ctnIFY*?==Up>fi^->Q@>AYF;Jt-aePlZ+UT6S*Mfc7XO z#a=o|>@6Ro9=yT2?s?P5og7#~@820J)&7XEfH(>rp7hzSU{r~zF(2jXJeQ3*LYeI{ zqKe1CBOxQBu%{6j6GYig8PM>}*1S0@aze`XoUtPdV?Zg8sizpbvJ>I$_cIOa={ z0uB+!r6ke)>2+C`i-mNIkYU+1^Eem1~|R53BhQ`1%?$eW!M&hj?=)>diYoan@& ztl=P@H!Sj_zIGcv&nf4s>x{G*!lRS3Ftr}yAD&aY5WD*-!PLW9Ewk-*!Rkrq<8J$T zqECCi&c<#m+iBTf!r>t7RY%=!7BomcorLP+hi(^YD4RP_BGTsHisx-#y+RZ&F890@ zVXn%tq0?XY1$88qCz*i6NR4^8n?R8)&5+3iIR^!*zy=%|_$i_;&NQs11S?eZ&H?hL zv4jgtG)3x%IQJI%zD3v#zb<<{WW4)6WPuIln5m4xD|0{POXn@PbGbKK^|>wJvT#l zHtVsb(}W5KU0c`IjW%VFC$WU@H;ZQVN9_Qmzj7w0E}T3$`WIT^Er@6DKb&6ezCTti zD^Ds_oprveL|D$1+}rO_fGQv!V(mi$g*XYQQrrLx#-#4%~6A7t8(5X7w~EQXXRZl(#aMe8d8n+k?7KH|DGU-Vh9 z3=C~&LUYP1M~*IymAi=ws!!bO1A?zQ%7T10#=Sa^D7IaU9kzt=UpA}Kh~F-k!oADj zht(~^1lYOyJ#&er+a>#EE3fz`FS>CCbcW`VXbG?kOs+xoQ^ zaiD^m<@5Cse0&S>$mF-?WhVmB7&l4A%OC8Jb(4!1B`5I}KMC2_56AVd`fe>7^?$}v z4pCnUp#Rcy$vF0d9g%n{MN=4_ujopSDxo?Y$d1g#mtiyCUSH@m z@A}$q(>z}EXxR`?xAjJ?hhu^P>=C30++gG5!Utp3-)878p_a5sac{q@7;m1sYVS=y zqaSD9fd#6B&r{Pieutuu#E~Xlc7q{f4 ze;MyncU{?ZkdY6fhwvGvPO9Ly$Ou2D7%gyn_g`VB($=4%ZGOI1%j~dd8j)DG`~nR7 zUsM6fkicU(wzj4ybQ`OO2HX+B6NG&`*rH#BbhP;zgu1#*`8rno= zi$>BQ>HS!?Qu5&#BffFO6;bz71W=uhX#zuJs{;uI&y(kg|8jG%q7PcD>}cB7wSbsP zD^!~QXqk-JYHeN7fh(_IWwj@u+EiIUOxT};RTf%PJ& zq$a|-`8Dt-3lQJoAoo)!r-gHXf6t9pz#qlPT88W`IE1& ztqSG%N*C(xg37i&Q)SdOm9gn?5A_Ou?Yr=Nnfo)W}f6xdomO3zQhU{|Rkgs{{s za6`7fk3bQ>oB*nB>?7e3DCT&8EAbS1B!USVsOPqSE59!Cay=yPoYURH%p3Mf>yl$foaOdem7pBJwi5 z7B#=4)f2Fd{QPb3eg{zZ2k+Uw9>ueCShr(ste-yLT2X56kXThOH~%W1 z(b|L8)M?9bLzW|bmfB!a!E16RtTDCQ+bn91=9Zuv52Desj2fri`1SAyg%FI~=Bw=V zh5Vs2nBI@O=beq>pG?&aQ6E#asT%oeW)T7VF0kwoq#`VD^TfOuHuMpBbBshhbYTsR zx7pKrVh@g0V}efhtlWRd1P{r&wBMDc#oQEtsBhz;NFH|_L#M|h!yMDPNq8gqFEXv(wUVt1asKR--d;R@)*8O44d&o;ncU&^D<2sH* zmXzx{hcoPJZ?@fsU_e?W7p0fI#uDQ%i;30QS+&>UxC?N;jXEx2wT3hjtH|kCR@JIK z<<>XZTM^z6^5SN;>^ilS1fMHQYo_znwx&3Cy$)d9+eQYDSV!o}q~cH;N20Jb?-FLC zcj**FcR_j}xNPL}potjX$t~M<$ zh7496LOpp_wL&+W^XYZD6t9&l*}a+5aUiT;ABiM%Ks;Bf( zIV0T0+ELW-TzD*e*`_dQ)+%gka4Bc#gt~p{-qmnS%=i05Ob2mWK-j=XU=XK2ium{z zm72i*7h;xUfoWlLb6K(l)>1r>MSx*E>b|^$@d^`k0D_33M_9LUQ@T=;2S98!T7W~s zmK(g;ELWjftjU-|M-W_2b{v_}xD_D9x#Vrlx|S_-=;P$dD{eJ6aMb{!1aJ^bm->6N zC(c|68T@H-`ZmlZm|f3>fhd-d8V#IuXcN{yH&;YuhDk-_u3tEvgh$Y@O@k?%itUwd zK*|qcc2ELa2Fmg@HX%ht4cXYTcz2l?=0EV)I$a>#0XI6YVXFzl3LZWEW8{5gCxUnB zKp4Bx-%Tm-U)mVrI(bi}H|KX6nI@9RI!>7>TH;)oQhVZki~kW{naFu8t@R6DJnAqX zc?{W`>ifYSpPge$Pq?2|PDH(XT2w>!YfTAp7j3F=seem;g4ZUoo;&9r8wiiNmT?O* zfg{c?e3~e{9kv4Pbjd*(|9+7=rilbluN&2hoN|!!S#Ep7x_wxxhhita zNZe^*wR4nB{joj(7D@kwd%!31^+%sW$JR0P+X8owtHN;4?c2Tk>P|}zVT!Rx=*N+F zHHBsnBE=}dI=gJaqRq37$2;844rs5rY)EXoIVV0%8Cwgb1gBaj*Sg>4*8s~Fkj`SV=bL_hG1f(Fc^WrNUYGR8Bep6 zoRU33K1BISNeeDh9g5yqi&YMw3Wr%yc(Q3mw8fE(FAq~RDzg-(3-kBZ+!?GX88wAB z03m+tTK~JZ`3j>2DtSfsh~*n7Qy_m&n*co;MHGhzX#yk|@O3|U z&}j#BiQSWc2^Tmc<6B^uEUpn6alxMjax(92(w)~4XDy5+Vw&J{do0l+3qeH3Q&i-{ z2vLa9Vqm8X7xR{ePLA3$Wl|MaP!WedILJ##1exNKMgsl?Fk=vue3nZ;tDwYy1pw6N z9RPs%1P&nPvn4|MuWtIEp#8t=40sDs4QU4@2+aip2a5t*4f2EY{vr?m1wIae9}ydI z9_b7@4uv1(8ubU7GP)QB2$Kzy?SKA1V{AW@tQsNaR|F8Xce;T&sz0odW!$+10cx(iD?A5yyxc`Uv=#Zrp&1%!lv-3<-ds{x=TBGRyAk z8}I8|7-|X+3MzmVV;3@FF*OB?Kp-L@TtVY(b%owQ&grY+5a3|oV6o7@wHif$J7z3P z`uctok02zP@xf7G;NSq~bdjw-m-t^yBO?f~ISCdpG1@3Iv>zbj0w9%93L=d(?*I5O zN+pkY__+BP{5T7=E&u?|?%?Y^*M4d)d#@`X5mvTqrC^i>W{jS3hDXkC!jCj@Z9cq? zRu@wq`TwPx>GQ)?Iq`V4gpW`3dCt;c`OenI+xJ_n05H^Z1r2cUkC?sS)?WKp6*r@h zjWiI4a`l>CB`y3a*=yOnA7rIN1A(Dy?;1ktddz1@6LMYjotKi)iZ@;B_j`7&dT3Ss zPf~YQ|Q`nA?7$xL9({H zb@l}-H>zF67EzCf_+2AaJ`RP%e+q|)gd_JzKx?XjVT4cyP{1a*I9Ev6s4wNgVuEA} ze%=!!LMPx}*9u4sG(K&)6Dq3woO{ByKB+Jp^MgC?XD>#EX>HR56mf<2#8q$(&f4Q3 zBl{YhFRnIlXRSt=E6MXDWzQ&Y0BGkS!DQniY*#`L40R=+B=nUil7y>s814r`>tE3H z(?vk;pgS@mtWa*qR1vD@1gRDXdJOa7Ws|yj$A>klA?PLM=7;YLu?4%Q*%~{GqbTU} zlSS*&7sW6PjZ*GD#TPLxXP1$!QYAHg59l>me*h%-F zg8d!L7xJfTy@f0ixiMiuy#ApSho-{9SN z(Yrd8Tq^nETv=N#p>XOaoRM9OCB>w)0u&!#+%%2u9X^4N$%GPXIG|P_-gWzf!*lTz zO-cK+m5ZG}^f&b)R7kPx1GlVChfZ1(3u+<559Szfi3yI>T6HDbk5MllAtgC%0fH2! zSQ+qm==JMVR#-ZU*=`3Dy9#L*DrJg4{)bH#azqWD@y|7+EtMbrw^TP%x2bZs3=UPr zeRJxv2?vBJ$=X{QvcwZUbSD;GhLn&Dc9cGECbSf#lpMdGV7IYg7vW4UMxU-GkF!~n z2ys7>a7Ez=8kvV=^LxxyaF1ktv34OV&w#rov~a`|;URHmIoI{sWWEd^5>MJn=t8Lh zRK5%rAmWLz;1X9OVp=&LcBm;zOM6;b+v~|`I$zU2GxH-%v048ob~AJE2bbo) z{_SKr+Yw#6J?bxtgP3QRbsW(^C zWgxti#}rTNixILyk|Z)IL!uI8rpd<85`TdO3uknw`4XbaT~NJVE`?;{T%eJd9@+qN zSY;jhqm+eK?G|K{6@F@5ytE!pB^L7irV2$XcldP_j7c1Vl!V?3UlGPX2ei%jO-o z2Ag(yC&sOwRL8TlBCAOxXfv@`dhv%>eXxCwVoC&b=g7FBWviuL!$edzzhXaqVSc8; zYwQmGH~Ss$kb1&5cUT9b8l5xjjQ`%C3EnDsV^bN3(8T%%D~#BMig{1fTAxz6Apt& zCp;EWNGhv7b#(;NrixMxu$-jijTJi`>(m@vW)b_;d8;8M|H8uO@M7%e3hxX^XWocEV%U8fB%Of*9wiadL~O z+w|}DMVf>;5?(*D)Q+bzIMKL|_*}#r7T~3Xq+wnN(th938z>RzizD0T!?vz*QB8E3 z>^}?MzX849%Uy>1(eHO~y{`-H)Ec_9q;h*jXSNHDi1{oCe8rG^@?p1C3+8%kT_`m| z0|MUNkCl^zVAQHP+Z$Xj9UdGH|C$-RLP{ik{vP(Z-(7F@JCf0W$;nL&_hE)}2oMMm z#-QV$#+S<@;U*G87?*syfBtXy<1Vgm1ELl4HXSNS^Gd%C`3RIc@d0&0VtKjH2-gp z1{)den9_ia%#BQUp&GD(tRYjQB*1;q6$J!egiaD$l9PT%h;eh{-$odL4LZ2lIE?wW zTog{IG}9)|K*_l2-U}+N{Tg}LA#{c>{lhduCVR%HCSWJrA^CO(V_Wr0HXQnG zksCa0#!6(?*=!?Jr*Rt&!@8&bnGgTMw6C{t@Zpgtiqq_9V7c}~9__uk#K~3_dX1XN zQ`?x=Fi9pwio|yLD5h4`G8H;D|qR%B5FScRexJ|K zwjUZ8A_4%^b+>wYKq9reLWM)vruOlG4hib&nw;HS{$AhTKHfasVgH~ER;SXU(Xfuu eEHh;An3ua_Lqs4z1Q@J82nT2kfhQYDH$_Kij|0iTRZ#qY zNZ@1)o(`sckdP20P$0OrPQ{=ic2J5&*+!ChSkp2Rs1rz~I>ZN2PfZP|%j9GmD|WTN@oMZAt6{_tM4>FlNS+!xZI%6m@k(BVdqZ9U7OrP@-QZ zDBh>VZ61-poc=-&g!PsJ<)aAAxd%3xm6)*>1gS0Utr4p)ZAlI?JXYBXhb0M2Hmv4w z`qBcVMq}{1F}fMHSKVYN=uS;BpHyJ$R^uB+H$eF=QH}<*T-c2$aJ@P^7yu2 z-Mtiyoie=cd}N5*+qb!V5<%xkrWzK*;WFon#7YEP0wS@>?8G$DaA^vQhs4lIcYeY# zOaSMYc~2@i9Fed&Z5E%+$CDe(5OhuY1SC}40@d3`7Kb8(>z*gq9R_5(Bg+YzLpT%d zbc8If70x*rfWJQkUFOdur@Q-)w4?wTitCmXB7+f#7!2_Yfdqy^BEukw;gHNIkiw{t z%4j!bLxQj<@wU3>1r@=2&hUIs<(xwW#_yGL4pkU`ZXqbkE3N%bd!wfXcM8hn!k_xEf7SyRgQA1A=+4C%=qEsPwNCU*q>FpVo)B+eG zq>;oqDev=VlLi9N^_`>4o~pQOMeQ(Sx;gN#)mBIEr1>+Ja)A%}-YcKQXCG@`mymo&W)5^&tLay~LFf+whwCM3(5 z@^YFQ`4va_BSXC_yK7CVo7Z3Z`T`IVP`DS+xS6xtXQtT5VD~tw9H^7YTutFHDxph= zyW`Pd6S1spx%M;EuA1R-xw@y0ZmV=6$@n}O2D(ostqhdc*P0eU85$wR*vvNi5Jr%J z?q=omqhKUaWEkhnr0E>CtsQ8ei5EiJ6HKNTI25v?W(=G~NPtqOz+a1Gx^n=<>9T?vmCQ*=yO8M< z;a#H$?prRMCCIg`MNFW%^sH|gV9ahhj&0&BwFqMsxalo3evKTs9 zGgb+0VMGsWMGtF34{Jw{>d+1ynNDkXbZN7-pPnnAN)XT(p7?^o<>qT-5@WU2mOVpln?dBqxix!{90&jvh+{Y+)nUa}VFIzwAo2+s4r4m& z9t4{}A>hjZJV64jNks1nz7Ad>AhcF_>kA!43M@jz`UR;=W%_G3XS z>1n4OV5C$2U0)*N5h)AsqYygj2i+$91GmQ0P`V^ySFToDK^Y2B1jQqm^5q}#Q4ooE zcTOrk#BoK6l70p{mWOMMQxA!D`xA#6iMb{9*7|rU@*EeyD3>vo0XQhIEl;LvI#9aG zuu#a1i9Yh3t2R%~vx_{&NWT->!y#SLtc;P>&KJpho=5W(t0ifvA_GBG6C7m6d35?X zMoTaf*wZ?TU1=)vL9STkWAdXQN#qRaFUDurr!F7)X-qU+dN4ijZcn4NxJ0bBhq(s>o4Xihjly3+c!zuuaj&87ZD9$goQs^~YQsr^m@rGJWG?qzezS^Q0-+@tXZ;ejd z)tF(TponK$x@pp0#1n{C+vh=!L?j-O=e;pCE*+(s8-ZyXOS30xOG$CDm3+uh+i&z{ z2>C7G2SJ|2s%02|y^xWRM?5Kavd}F$;D!Ol=g^VZvN=KfYfXVKGUZ*)!S zq5#|%8Wq+u!&GSD@)*iK5e=uG37#&Z5ij<{MH)vFbtg1Zm^t9EIy-U()4)GaKsTvixfM3|dWjNyLC+>nh80JPP972#z5W{Iwr|?`K|AQN@@rygHVwGw zGjiHaB1?Nkgvrd451uHAB2kArBu4%e#xY8ir3%5n><2ONxZhi9%5#zhh={bb?r#X1 z?Pc(e+LM@prZkqR)0ngpK?GjmQk){*LD3eFNgjdk{5C_x*;JNFrUm7H6qYMwNj%c; z=RZuL@V7DQyCWkm9{EHW^&DC4^4QgM_p6I4AL!B3{Q@!z(18y}Z6k(wGpU#NLH8F~ zCemotWn#oWHuj6)x$N=}z5p)*fgo=)24d6G$LaW&e~K;BU%z zvlMP`aG?&=J(u~?p4{hI%Ec|Ccv^$=#+P-X?AJFjX|pi~4qq+`^$vrxdQEb8LQ!5k zN+Hlx1W)jmiV>bTfrN0=VcWVk39e8UqmUa^&@~=z9G@Ir3<4oOFp9x6BG#z?q!$^4 zG%!Qj5ew~!?4%~pA)K_0!vgBLEP>w}@I)EyJD>iIL|KzsYJDi?dDNg?Sd6#mS4@HE zkZzYZ=_k}u^HPudxOLFO1uWj5y9Tz4pywwXhRq<0Wc>^l*k!DppXx(A|G zfc=leU3WUo)VBwWEb*BK$i+OnR#J!42`qmqFr!!EM)=m`gJq=N!7f#47&3p-zH&&U zt*3<+LTU__&gY7&+=FR21Tm3QY72?@OSms&@N7|$rOMp(X}EB0K(Tt&94!F->jd$f z+$f@4PEx@U<=oYmNvNy+AI?)|<{3v|MbT)P784gF(7^h3Q5m3YTbFsYYp%L$B{(!) zVCKv)s(#4oe}dXO@!E!>tJ|e|Q8A;D^f(cS30RWYz$GQLN)>_ib_wOY&8j-TDF4Mgkk_bf zblNF1*Cf8;Rv)+2+;;4QRlWc9`x}c|Hxp6ZC&UprfRjt>jLX!{-Eq>c5F8xV0pRkv zDerr9z0P8-z8+O76IsP4rf;}Z{nAIMoty<*^3XB|Zfhe!bG2Yf)pA5r)lCpdjYk#s z+oh6ylND?pt8;gsCW+>!sS|12c;rqHhk06UBQ1kZlcTJXuDJuR9N|eH54OZol^s&p z?ua?^l&k@Hh!nKXRN9C6tuuG$O0}&~@QF4IC9j}VmXzp9Glz2P$xYs_Rq5vdW#9t9 z$GWFm*KLbfI)lot$dN3;nLcQ#Pim=iM8bCzAmpsN zuTQYta*L{!p>gwMNHj~y<7R_8(K`(5&IWEBac^`i+kcB=x)jAeHHJo&645-AJVujC+Cd|1`ua-u|)WswBqFie%u;LaR1v|YKR5T?s{6m$K z%eh=~%B_$(N7HW8!=aZ3Sh4C%>XIlC!n#BiF(~F!jU)C_iw`zW$qF|RoiouNdHzxrTctQyH*djI0mA)w__Wv3&6vKc~oI6da(fH)qf z7Y_Pvoap%otehAq*O5bHgOWzV)mr+zm|L$!_;uXR2zl6;mhP$YT=3Fr#ckD|VYPi9 z?5Jm2$rD9%)p*8bp4S3hpv0Q_xb#F2sF;%$9w4;!f036uH$x@Y-V^oy-A)tfhfa7( zoIw-#JK1J6RE=V3Id@4&#Y3x0bOG+g0_*51tQJIcxy)tA(x})S^59Wr1vKG##Vau} zIlRYO|7+(Hgw)}>J5vW)+HEVp%p6Kd&R-0ng8HcDm&1qs07=-hA+R(jefmi_(1%^} zMrs0#hYs(h0@97KCzE$EN~yJ}U`sl12Xpl*VyL-|ut~ZPG7I|+tB~w!?Iep@-huJX zQiTdTv|In~$SK1m!5Y<`JU!_Lwr-i$agxEcEdi&_B9hiWN;F5-+A*L-tDDt9rG@>u zMz8*{2()GAjN4|cRN9)_K3RQ!@6?;CuB_h=5d;h~trX;x@Hyj4HOpRIqh*B)Cf@aM z&T*^LNI+x=2@oFx0)lBac0Rpf}X(eM5@Z+|s&t;4ijacmFz&N1Sv>9Q5~F9Ssa}pKf7rE{@BCR6ig>|*IB}d2Gd{`2F_@r zkc%KT2)+X}bmLKkA_?NCbnkt=rvauSwI}fzDu7QHheN(cw-2$whuBBzWWnyw?*wA6 z6y#9RJGs6$9KRVd0u1W4B)NU{a#jHv}r-EfxIb_q_ghN)Kp#bwcV#_Zhxo= z&f`-5E`mDf^T0iy7md! zOun*+UvW`so2MkeZj?e5VENx`MKP|yr5HvSM0T9}RC~zXto^$sA-O$g%M<2391uK& zen>3c1Vbd%%$;UYu)=sfL`z)r`FUUJ%FS}Kwl}S$@n4Cu#2n21Z+aq}29rZ#&DiD) zHunCPRqpY+GB!3%+yrof%2CBL&lU6 zOU!^m#eSnAmNrP;c>Rf%_*bNs+Ke2HW5wa@w79t<;sioJ%Y)H16#8rC)LA%Vapi|y z3+{H;+ZeNSZy{UQy`g$+Ds0WTD;_4qcn(_H6-$xiR@!<&l$Z#AcH}GZMD>ib(I=*KHt&6 zjmStql4R}F7w1>emy!c$M|}6H2QTa0B9QQ5{(Np>*xfRuNbLf$5Jd{?~Dp4&;10vzcI4O|d$fxh3tbpo;{J(A5nTTHSE zPNXy8bS0G{z$tt3e0N1GYH~Co?$0Af7N#las5^1dVZDW%oIKLBMOYkEQ$PE#Cb^oG z`b71jHJ*W#N!jF+2p-7h9UZJJZ3(5Hl61_d7Sr3;)aE(ML;j#YJuW+~5erHgpwq5EHes4%5h z$rqd^Uvo5;^?Is0r%~C~Qd#2hhnJX)2ibIH9Q8`muIFJu>JY5=|CYQ;F*UU}UX-v9 zXC>uVv~*N)tKN_7CLn~;OhxkC`)?xeOpK;k8auh+`dpHhG{PY0}_m zBzeuYuN`!)BKc4iBBiC({nKVJMw*U>0lfLU8yz?Mr>?u+N|;)7AdRLc0%tdblU=z7 zYV} zXb{h7InS@PDpr>;=>gTvbV2O0!^O1(UDX{<$B}t`AzS`mxEJK^;|?sBa6b+<<(3}a zz{Nz-?K9TWXnnvF+Bg6BE`&NyffRa*{CBeK+E~$8$(+J!6L6fDog6^ zF8{9N&;o`}Th8Sh|J=Z@T%%^Q%b|IsPtkH@?G7g;7NK zp_#ReURAoy;57CzN^=R2jKC3?-p6k*t`E=e@hE;@%28e4k%hq8=+1cv_53pk9VRJK z0a+t6@F^(!_<3yJ;ez?i$J=+-)X00X-Jw%i-X1G6At{A1>ss{TPNPfIf^!M-I7~|* zMe$3&Q#m*Hz4IeAN12__mfAB`J>7GNB`|*2PruUg#J32=oP~#9BY}QFkyYbnP1qg` ziFnUB12q+QV)dP64*V~BQou~Ma^lv;OXR$S{Ir6NUbn5~f5P!Db4ib@M9z3Hs(_o8 zb!>v@hk}0Qa$H39E;D)RETPep#hk>O?R=#AGtDb+Kb?{|rWo6%{XQqOa%obQ*EGD^ z9n1<+2FcP6z2!AU>Z8f+|9fw(-)7SR@Vk$7tD{_hu9Jijrj_||(4PCUi_7xX$OL+x zlV>r8 zF_y_Dn6u>4x{TVLB#nerFpWeLYn-vS#dfQUW})X4W%GsXii(OzWP!RtUODEJzj7T9 z!~^V$D|7iuLH0>{sZ)N;e2Vf~8WsODU{9J!Yw1rB62v~HE z^SN=(;$@XtD=&P;V+Ki5!1rIAkdUoskINp){vPtxsr`4wR4D>BhZ6N=kbl{8Bq?!D zy;A8&jH4qGNV1^Jza*vw5Fl8#f~3s24$yq#GO;(+>)DP8pyX1GUIHPZw)STnE~Izx?>qNu9SWz>a|hh*Q(J=3tO{yY8GIIDrTTbT`Z8gK zp*89!FkbZjxrOW?nZl*GQg>c4rL4q$`<&-je1f2;ulkPdcxE(ct9ojFfbp>~KeR$Q z*vMV;Q&Y-`3TfM_BzLc^`6}zyS8%AAD0ZX>H>G6W^{|#Sa(?8-_q?2x?64DA&Qs}d z5(Sqv%74ya21Ar51`VMV2L%L&eXzun#`>v(@3MG-dj)f6hGcLT<=BqF5`CCs2D9F4(?ni>g+qBA! z;E5YvyV++5RV-Xf1XrS1xDdxi?wmQ`XjM6n?Q(dmO;sO!u=<2J0;BKOSoa7AShlbE z!nkkKo3n&_FXNv-V5VjZj?I)bxIGsMJ%Y{^W&|V-%{r)`zgKCnSPTBM_|+nq|@3gXH|CT3&HPpzc*Gt z5Fx%J1UNRIIDahoq?e}){YHToZocwqW6Na#E&OYAm>q5ZDjJ_X`c7I+Cd<&pCHdO} zW^+V4L`wDv6HcDM8yXaAq{%mzw0BxkUd@>lH?=tiilnyE!y9S_hpO1PO_C{U!)d7K>jFqLzB!bA$}N#T}rhO%WzB$tNZ z5<)69R=jL#DNzk*^quCF8p|1!snW5B3{MXj%b6BL0K?=nfVQ0EsMyZIemipr-y_WN zXY+*I`k~hQ)3$q@)-}-kiMXL{N9XtNPupO4N06MtH8giNtvmKJzWB`()(nhdMiIW$ zcD*j%Gi@GUVe}nY;EyL%wy+`yeJ1>r>AYS&kJ^k-XdYn>(=vxKzWyenfp1ZLJa0BL z{;Dz0?`Yg|TU=C6{1{{&?8z-ZlbJ9_!rl0i#-Vjx63|2dJPTuA1~LU~lx{P5d|#H8 z;QEHldx}q>pWF&(hrg9daL}9;()gl74D!^9`9HUWhOkb*@`l_tt$USC?IrT}S5102iBo!l%tW&a7FX==nDe`5uJQ z+|^eBo#*Io&RNJif2U^93KBQ1nB_W2DT*eD@0=WZ?$yb8LPB_zNyw7N8U$s*hgnV& zLQxj7mgik-IH6`i;CUE*-&oJ*9;kci{zG!GhPFx*bh1UamHPl7?_D*^G5@*zw@Y$C z{yzlw?7EjB@ePPU^cDm`kgWP0`8{4=is|doj^U0$?YO2&T*m^CWKhog=!Bc1FaQ2v5 zv0z*Yg|j&vzz^56;*%W7^@2Ovy0P0kI(=*)n6}V2`la7<$B*n;>qcv*cQut7^em76 zy4$Pyene%)5k6Wbba){>b$0#h_gW*O0)XxdKhfVe(8wwJr*e=loJ$tY_dhq9;@^Mw zYj4E||8_t}laGsB3q@-t1TJWL<`Ad)Q*@id!4CfX5RoZau9F&jBqR=5Lr0ZMp!8^l zn0ZZdW-6>Dsn0FK#k(PP%_JpPZ9{ylDSs8s5y+6ChyNn2oA?^uUNK|zL#9ll${8K; ziu}wImRN*<9w+=CLQTzmk@fuelmU~5W}0CLP@_3GVoh`aB1bx4Y!^BZ9#=b18HMP; z*ox_%_|pznbb|T&%9fiSvl}pIo?%@&bQ&d=p+#ol>u9bZU(Q%)sZq?K%?O9+PZ;J7 z+e8Z&N?CcgPfdj`{#318G>KAB#YCgkk7*^p&peeUQ7Hs98l{p@F_=V1>DggSubA&L z@BuYC62q!$lciLeKe+;8QTLH^x@(w4m86E@$PD;eDkcg`F}jL&P>eZ$KSerf@W zY!uKBNAlrj>iPom9DqSUI})<2_Zvb$j%PVob5S#6SyM9!tt>-7O@$6LFFGa8rk@fQ isFOeq9&M@oI}Pp55h!41eSwD&UH=U4=~t{3ha6jZwt}$$ literal 0 HcmV?d00001 diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Size1-Regular.ttf b/packages/extension/media/vendor/katex/fonts/KaTeX_Size1-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..871fd7d19d8658f64d8696ed9cdfc82c821ed76d GIT binary patch literal 12228 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 literal 0 HcmV?d00001 diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 b/packages/extension/media/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..c5a8462fbfe2c39a7c1857b9e296e62500a8a8a5 GIT binary patch literal 5468 zcmV-i6{G5RPew8T0RR9102N#S4gdfE059|a02KrP0RR9100000000000000000000 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 a^|k5z`r34UeQg~YeP3G~_EX$#+W!Ytlnjvo literal 0 HcmV?d00001 diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Size2-Regular.woff b/packages/extension/media/vendor/katex/fonts/KaTeX_Size2-Regular.woff new file mode 100644 index 0000000000000000000000000000000000000000..d241d9be2d317f7b39b401d96c8b18836acea0fa GIT binary patch literal 6188 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 literal 0 HcmV?d00001 diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 b/packages/extension/media/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..e1bccfe2403a4ed770c1697ae7c15b9e1cd9bc4e GIT binary patch literal 5208 zcmV-e6sPNVPew8T0RR9102EjN4gdfE04)>%02BcL0RR9100000000000000000000 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 CPy?p` literal 0 HcmV?d00001 diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Size3-Regular.woff b/packages/extension/media/vendor/katex/fonts/KaTeX_Size3-Regular.woff new file mode 100644 index 0000000000000000000000000000000000000000..e6e9b658dcf1cd031ac82b6b8f312444c55d4fc0 GIT binary patch literal 4420 zcmY*cXIN9wvOP(t(whRI8c;-f7my-FLhm9)0ucxxO+X-0rAjE$r70y;QAFt=Ql$!n zPUu~Vh!kl;UcC4Id2_z?otd>~_MZLctT}!LnwkIzAR2=@K>P2_iT!W<&;S2sU?M63 z0OSHhR-XujMng&!(#_73$oUgvcOp^+O${uO&wO|QfQ|^@3K9R=)zQ<*jhOcX0LYq& z6t%s`DcZ@-n^?=BNX()5ALyN)1ULc!NF4x3Hi)?s<6b@%C4eV{QxoN-`#Sai%M#EK6N10(qf4* z`X3iut5EBYe{Tv&<%2T~#tigTJbU14c06D|c6RqXt3%o;{qsaft=r7{=ya{y_R^CN z@5}L+t;~atTi;LGsUL=k{{iFUB)cqd0_>*+Ng~$G$o$aSCM@75f$)(3a#H$?$rV8@ zls34rAGgt0R8E=ZQuDf6m>(B&bHJ35J1xE-f9`piS($lhwQP(g8~O~FglVC;^SPec zcTNo2RLmWS;C|M=vn$WrK=E}|X`OTR?w7QHYa&#V?XJAd0!uWGLeOaAA`4x96QLyt zuU65BaKqE0zQwD*5O>*Oilrz1^EwgNFl7_^D17_&l8+62p5N#5CktWZ-#y>{cE6#L zU#B023`#8@?N{bw&aP6&i0Vh0-R`<)3>Sg3X%A9#g&uq{`qts~YC{TbAabb0+_x!x z4-5$tqRZwTRroN};E?uNKO!&-8sn`h#e$Q{`dMvMEvvG6G?_c!D$^lL&AI#488%Dz zL$7bLShsG~`xP=kC?%)YlrdSzVV`cf?KR_kG}V`zP|>((n6V0)HxAX@Gku4dj*`o= z%ju{s6D=1DTB@*Gn;qHsdB0jsUv!LSF(W&E{V9$@wbSaLv36tc=mG7da*1p=Q)1I- zwsXWUsO0^4q+79NxoLT@&TFYxz9v|oPnEX(_d`sLOa;?)jdd(!^u}BZ|5g4A5wknaMc)wzcwAZ>hqPRn-LRkwg?k}TNVp5H# zLufx&M3aveUoH!{+?0Lw@%|yU@k`n~6E1v%nv#-@Qm`$$R^O82i0jz=SqVDfB$`>3 zAI4GjOBX^XRvgccA2#KtaQE}&ppzQhNqNy`POgJLvi>N5R5Tfv(kV;V#qdC>ni0%9 zl+P*h8|GB&beO=`9&u$$Lm6MD80hl_&hfZc+Pv(aQbwtH4Ob@HhuOE6N7g5=mIv$A z31o__X=ATU)lJQ_y?HwDB7tCD`N-p-HB2^_=@I>#r?W5q5RSNa5N}@CZsj?VZ@M(L z=UZd@?SM4sRKMoW1(Nyzu5{L48=S$t3N{y=ff>8cE~5gb{)Ws3zdI)nSlI7d=v0{L z-dxfT2_DEQGY(;gKa9(>>{}Hm%DTO_e=7K6D6&uG*^Ha^ zWu5dJf1z4b8KO@@PpbCl3QdysC@h+31iinYe{baMK<9H6*SCOdmuZKKZpd%tpv5=K z%6d3ucYonOkncN_1dO`fg{R@=@ewzj)7QUrmqBa;OYs~RCHc%M-1|{?r~~a0nYBl ze$;v@v(&~NjjIIi34Ur_6&eN=j_$uNc;EKsEv7y zys^H$l`#YliwuZ{VVE3HenxiwpLP4QPPwqKw6L%;j58esqDB|t7}$#F>FEq5VP|EX zrxI%ppjsivHWZ7=i3yZ`eQbW>(MI3Z7#olchNy*bNLpjS5Evk`L{C()f9D|(V0%ZN ztDxjKL{?-zNUL9r0?_bhWoDIPJ7lwGD`#_IyDG+sZ0s1Q;42zJnZu*_0s9q&MKg~81 z3zulU(>i=FALwfNMGANX$KZa-THr6=R2<-3}5C{nnu2>s^F!b<9mQ}Hy&~w*!*G;+L3%!-UVs5 zZE`cCA199DoUgK&VSCTM%b8c?qu@}eEA!I6Uzu87I{YrDkifu<#kkT&N7jT5ITJpV zFw@>XW~(szE#{9USGwXtd*I!v(`yVxx!p5y`iuRoSAfZ9L7y@^bt;+3&zWga zO|YjAv(KGx+fWK%p?15AFm_=*1jdRNa)1(OvOUzMJ-!;RI?l$*g7^3$cCK)-qX`M={AtCIT;8Dm#WDPHgV$POD?hutF_^v32QtCOk_Ffi zkChO&0}##(7H2lo1{D;ynCEMGIByJpv*wn@Y>_2+>r-=KGGl*};3hJ_fd4}1*Sp7| zMeiRch6qJ=R!5Mr6BcKd^W^O+IN1ofY`IL|%3v!Y5-@=<AF-t z8GlD77Nt+W8RojXujEl?_?T#VCv)#SK);T=gjaU;qIy?ec63X^ai!?XC$9d+3_ zY{W*nC!H(SXpL%i=-);Wmg|x>F+lRW@(> z;q*2;7?*PKK=8*$;i}R?nM^8q31`mzl-(z~d|}Nct2d4;jHC40;n%-2wrjxooRS_> z!tJZLa2pj&xjhroHPzn!Z*2+;_iDXQC5R{AEVC7xoohz&Pjnphwwr@ZN5XzaAVV zhROCvOXR^Lm7Bny1=Q0jG(ZB9dgw_<;)w z;&iJW*|Qm_?=Wy*PH2u=o~|^4AJx;adzvlCCNySq(c#*}(@cW0L`+GE^vU{X+`;!?z7KXhvi{eC`FU*$| zU-}Ic-1q5l6e{Gh`o%&gb@_FpQ47O)1uPJ_P1#6NJV0@~b>Qea>YfMb%TNqPVIOZZWcoAvER#lpi5h+A2*^`eg!yoXbFP$+*nYCv2 z_Je+C$dKn>Y7{H5(k)l0It$45-!_y-Uz9Xzu)e>u`O5{bjayZUgVb zEmGK97$hh`=f`Q9$W7W0`Q)<0;Z|Eul4rhL)3oCpflWlZHNooE%~ZRPY$13+1&?*X~0gFpS#$rxdzi5*dj-=bwnb z3yon#elVu+e#Z%B8M15FfK(1a^8e_x3UNfC{Wqr&|MQYbU7n~is}I%FU3&TV_z?}0 zZ#O&Vbmwzy1rMgj#n@VY_ufo?gUxv58A!a5WRDcN%qI88D=ZVK>})PJ@%N=!x2ni# za)Mg(!JIe8eC^x)Ye7NX&RirD{stHsrUr8XqvFJFmZ#R^=tKnTT|xvoiEr$!7WSgbgQ8Tn$CcO z;~h;>g^M(9%aGAp{Len+X95DJ7X&hzrsZGtWnfq?R+kX>Ba~_g+pH}mRq?}l%IG>n=_$;Enw`ZWI{bOoUT2g#cZc=zsN)qOal;~({D-O_wLRr1i wAFpGvH#RnIgsI2H(Fo;L^QS(G7|}=IXUD2o4FH3=s$ljcAHC0X70816~U<00bZfi2w(I91MXR8`~Nq5q5L5B1BP= zCIf=MO0b<-%=R`R#gQy8VO~)Y_9Wg6A;jG~PCYawUBwUZ z^xD#3Q2{A1%A~TNHb90A%~8TOOF_xEzM^(fZ&!V-?SKLE>MQB$_yXG?`2Vf>+IMF+ zMf6O*?0YI?jhRfcIhmdP44afbCn*tG07?l^l|8T#J$14|*7;Tf!RQ#O@AV?Z$o5!j zog>ReN(nARZ>{%T1}Oc5>;wnUFntuj*8YacXUHNHjn;#}uX_CSGwx>6wBhY=!It_x zV~gh3aTl5UZNQEu28~1;USGtRREQ$miY$VE_CV;tK!y$J7=}i4Vik_l=jlfblh8j= zO8q_>4X_~%!%z@ zdF}#VWi}2l}?SUCU+9bog+auC`YA(y*wIdM+dVJ-@fIc91Ys(vwOD$O0~hLlcQ`3 zF5_Vu%-S(Au|Z74#2C1i%!cKSI_ZQbFJX&sLz)hAGM~Wb=wUo1 zeA;=Sm|Im%6Dtw6<-!oXWKdNbZqqN_IHkA!T-R9b-40u9#=POmR*IT@5?nVim`)zU zrNaeOK+WX=9r-39P;I6HMso$)TtHfbpxO+mAzlxn<@_HjO(F8(s*-J79xsk1Vo;9= zC${7Zh@_DV%96>>Oriq9dX`C_SWB1mSS)6y2-_mA#3jQxXpN_u63t^`NKyl%U6ED< zcK*kjA?eH;(L42N$p>_(v?J4w+W|dlhzL4=jBl)qG={>u_2DpmzxqwDklJK97*XfbbqY-AI74rp;wZ8Lig-qHQ zLQwuCs>g?B!kLPWyc3BrlL=ZgGzKb@{MR~nR>tL$n3)iyoHwMdN?)WaF5XK4Gb*NI zz(N@zE2GqpG1Q;2G=On5knuE#sVJ5S6vxz=nNS@L3SWKPi7E}`?OC&6V6atjv;NiQ zkm4!&_ZG9^47wO^H%NWD7xP%0;sptUL_v}uS&$+~6{HE$1sQ_>{-KbzaA{a@##+fp z=W3K&PGcEbyU}()-dOj{W*`e9Gf~y2Wkp}$#~f%n5y;`*`Kq=jSKgt>+N_*TPvXNA zt>sM9m_z;9kY^EObKPhJZLqsIK(v_O6(=l(6Tu3XkIFbSLR}{!Y zbFB^J-(y2K-#bYGDwP?RMrOdCHIMLpA3m^|7KsPWCy3dQuR48sDNqP7^J_7Kby&AQ zewAepiOYxmP!nnUeAAAiIBB+p0&j*&6Vn2j+~;nxRA_L5Gj2kGFhiN zFN)A8#H*hB-6;&q+$kJOmz~p?;)0o9@kWVFDJrT{7dkB~P7yhUIwIL-n`LF{Tq+2CHcQ!{`^@eJum40N|)un=Q;$xAvYO(g@I@bl2Moj)Z zzJ+naZKWt}YN}nQmZ7%GJKu5}lXp{$F>;M7Kw+FXuo4u--X@4zo5Mc;9*)^;uq$bJ z9A@g&Dip{s$Yv=Jh1$1DD~+!31dl)!yWDoid1?O@vuYNxiPy6gTU~L!ZW4U*mqun{ zD~cmWvAidEUC%;SQi0Ld^wU3fz%$)@NiLDQ*&$jFlp=!3Ole9*$N{`e$ybU9s+a~>;}{~0sL_;aOA|qz zrc_@EqG-^R8cP#Flcr`fH!^EMX06Dq4cYWVamd=mlBSj-f@w_EbpMdF8A#epByARw zHXBKsgQU$x(&ix*%}2#fCe;FC46dRtM7g;r`K|@r-4~-0C@paiZK;FMvLx)1W4XSI z75c6f7=oKRK;Y6lQLe9qn^q;Eq{V8z#2URsP%jbEORUvPtkX-ZFW$fgy@8E-1Do^) zHXD|-#X%_SApS1=l|JDHz%LGL2XpqQ-uaYl8KI5lGD(wGylEd~2na|tnhuRpR-DET zzyy$A0r~+NrC5}qm=sxe5g=h%Hg2FC#P19B*GOi&f$zwn}2eKu{6Q7bkzy z)JsCupH6=#(;`I>RNnoFuJyg|i}*K93+{l-T%D*DSHE*8i)Z2f#6;-Z0_#py;1c63GI_2rbwXYf8YC^L=%vS z)EQ7jb8m0e!IO0#^rO4Yp2K1GS^D~__tk%RYQQ_dBAF0WT(}3*-u3Q3Ui02@>$`_{ zZ#$l%B=_|A4xFeBqNiU3N9cuu2qL)YFOO~;Z!Hb>J(L`YAgGIeu~;9W(70jCANq8_>tL6P9w|yq>8^&hrS^5;J4uJ%|No#+Dlal(3jU|;6~m#=@MT?zppA^tw6(W;r()=m$Avwkr zk@(?yuyf~n9j0!RKg)5K1DWq!W_)qZzO6alp+)?}WlMO^&_eEZxAr;Xd<=W(>6acY z?fo(CPfbFNNdc)_^nKCw(TdBrW&* zZHOirvt{1rfS?@owKAqk`_hjv98f9#Bs-TBXs?=7tFQ1Sef1h8!R`90JU8x&c zDM4!=i&yQG8XKEN>7ENU=pp26j2$j>+OHc^S9BOgSIN+!Y>w=(SF zgbUf*rR#Oq$MM1B+J2jQ_aDKx#VQ*!P`9?8mX|o;+4*v)aDTmisH%Tu|Nd)C+>0}m zTA6={7ZP47bf%ePYS5g9f%$WmlrzFR{nfDn==@qI4=+^_6`w&2m!(qyFit_LQWz4K zCslgSd12M>h95?MKiYUzuYp$hw&L4z{yxCBZnai#{lzGs07L($gOp7gN+OE>>IdKA zb-*z{jKykWY^)mR&GU)~TpcVwJiMY=SNyl2W4;4`mB~k*?4RXE&8;!qIqo6=0Tj)7 zE@q>SI}MeH{v<|5Zsb^S0}fjeWIWxl>1TNB8aRPfkVp0Smm)t$qQ79RHP=D2xzo{G zwmwkcMosfg%y*biN8%q#TDov)tI@<;!-`3uMvrYv(8`{iNsUEdKv*?^lYprrvwa8{ZKn3Pw0RmWrnV60lSOOc;;72-a zaKH{b3NV#D$%gA4YcpO>~>s$<@ZpL6q=vX;GS~C$Yi8wqCzH! zG{6WidX<5<%|6#6rJq*JR?wx5^HvV$iY~>lXhy+F^p8wQl}5!JVS^_UHzRu>namZ+ z^iM%x70W6!lBGb=`f(NAF;Y>~8qex2_rx)Qd@;~uJ`hC!C>^R~`4B@vsuVvIJX0#k zpocSV0cK=|iO)n}#-J)J&co63=RnM?GV7|MdzwaB|oq zZ87}%ab7O*a;O!Q9A9cXmmBYE(ap5f95`NNRSbOQk21kCbSTW_wYVp z2#Yg>BRo+NfvAb7S~_p0-Cl0*@7!s3sF%!~(?0cIq=^x7MC8vM1&(sfT{Ulb{^<%Z z_CdTjx#liHw%1-KKR67z;4Y}#cL5pmw#5}60w8VqI0*w(-jzf)dupz`HrJS`ou#Ee uC}16e%Gv>UrEUWr7J?uwp6K^lh(`Fkpv`!YL^q7xb{CaG@8Q~cR8|6Fhs$~Z literal 0 HcmV?d00001 diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Size4-Regular.ttf b/packages/extension/media/vendor/katex/fonts/KaTeX_Size4-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..74f08921f00f71f413ca42c9d1c90202e672ef38 GIT binary patch literal 10364 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 literal 0 HcmV?d00001 diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Size4-Regular.woff b/packages/extension/media/vendor/katex/fonts/KaTeX_Size4-Regular.woff new file mode 100644 index 0000000000000000000000000000000000000000..e1ec5457664f438ce5a1cc6dd8409bf60ca7804b GIT binary patch literal 5980 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 literal 0 HcmV?d00001 diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Typewriter-Regular.ttf b/packages/extension/media/vendor/katex/fonts/KaTeX_Typewriter-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..c83252c5714c71a3e0ec62195884167339a0129b GIT binary patch literal 27556 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 MzaRX}iWL5T03x1QQ2+n{ literal 0 HcmV?d00001 diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff b/packages/extension/media/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff new file mode 100644 index 0000000000000000000000000000000000000000..2432419f28936aff53ddfa2a732d027e6a6648fd GIT binary patch literal 16028 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 G0ssIW .newline { + display: block; +} +.katex .base { + position: relative; + white-space: nowrap; + width: -webkit-min-content; + width: -moz-min-content; + width: min-content; +} +.katex .base, +.katex .strut { + display: inline-block; +} +.katex .textbf { + font-weight: 700; +} +.katex .textit { + font-style: italic; +} +.katex .textrm { + font-family: KaTeX_Main; +} +.katex .textsf { + font-family: KaTeX_SansSerif; +} +.katex .texttt { + font-family: KaTeX_Typewriter; +} +.katex .mathnormal { + font-family: KaTeX_Math; + font-style: italic; +} +.katex .mathit { + font-family: KaTeX_Main; + font-style: italic; +} +.katex .mathrm { + font-style: normal; +} +.katex .mathbf { + font-family: KaTeX_Main; + font-weight: 700; +} +.katex .boldsymbol { + font-family: KaTeX_Math; + font-style: italic; + font-weight: 700; +} +.katex .amsrm, +.katex .mathbb, +.katex .textbb { + font-family: KaTeX_AMS; +} +.katex .mathcal { + font-family: KaTeX_Caligraphic; +} +.katex .mathfrak, +.katex .textfrak { + font-family: KaTeX_Fraktur; +} +.katex .mathboldfrak, +.katex .textboldfrak { + font-family: KaTeX_Fraktur; + font-weight: 700; +} +.katex .mathtt { + font-family: KaTeX_Typewriter; +} +.katex .mathscr, +.katex .textscr { + font-family: KaTeX_Script; +} +.katex .mathsf, +.katex .textsf { + font-family: KaTeX_SansSerif; +} +.katex .mathboldsf, +.katex .textboldsf { + font-family: KaTeX_SansSerif; + font-weight: 700; +} +.katex .mathitsf, +.katex .mathsfit, +.katex .textitsf { + font-family: KaTeX_SansSerif; + font-style: italic; +} +.katex .mainrm { + font-family: KaTeX_Main; + font-style: normal; +} +.katex .vlist-t { + border-collapse: collapse; + display: inline-table; + table-layout: fixed; +} +.katex .vlist-r { + display: table-row; +} +.katex .vlist { + display: table-cell; + position: relative; + vertical-align: bottom; +} +.katex .vlist > span { + display: block; + height: 0; + position: relative; +} +.katex .vlist > span > span { + display: inline-block; +} +.katex .vlist > span > .pstrut { + overflow: hidden; + width: 0; +} +.katex .vlist-t2 { + margin-right: -2px; +} +.katex .vlist-s { + display: table-cell; + font-size: 1px; + min-width: 2px; + vertical-align: bottom; + width: 2px; +} +.katex .vbox { + align-items: baseline; + display: inline-flex; + flex-direction: column; +} +.katex .hbox { + width: 100%; +} +.katex .hbox, +.katex .thinbox { + display: inline-flex; + flex-direction: row; +} +.katex .thinbox { + max-width: 0; + width: 0; +} +.katex .msupsub { + text-align: left; +} +.katex .mfrac > span > span { + text-align: center; +} +.katex .mfrac .frac-line { + border-bottom-style: solid; + display: inline-block; + width: 100%; +} +.katex .hdashline, +.katex .hline, +.katex .mfrac .frac-line, +.katex .overline .overline-line, +.katex .rule, +.katex .underline .underline-line { + min-height: 1px; +} +.katex .mspace { + display: inline-block; +} +.katex .smash { + display: inline; + line-height: 0; +} +.katex .clap, +.katex .llap, +.katex .rlap { + position: relative; + width: 0; +} +.katex .clap > .inner, +.katex .llap > .inner, +.katex .rlap > .inner { + position: absolute; +} +.katex .clap > .fix, +.katex .llap > .fix, +.katex .rlap > .fix { + display: inline-block; +} +.katex .llap > .inner { + right: 0; +} +.katex .clap > .inner, +.katex .rlap > .inner { + left: 0; +} +.katex .clap > .inner > span { + margin-left: -50%; + margin-right: 50%; +} +.katex .rule { + border: 0 solid; + display: inline-block; + position: relative; +} +.katex .hline, +.katex .overline .overline-line, +.katex .underline .underline-line { + border-bottom-style: solid; + display: inline-block; + width: 100%; +} +.katex .hdashline { + border-bottom-style: dashed; + display: inline-block; + width: 100%; +} +.katex .sqrt > .root { + margin-left: 0.2777777778em; + margin-right: -0.5555555556em; +} +.katex .fontsize-ensurer.reset-size1.size1, +.katex .sizing.reset-size1.size1 { + font-size: 1em; +} +.katex .fontsize-ensurer.reset-size1.size2, +.katex .sizing.reset-size1.size2 { + font-size: 1.2em; +} +.katex .fontsize-ensurer.reset-size1.size3, +.katex .sizing.reset-size1.size3 { + font-size: 1.4em; +} +.katex .fontsize-ensurer.reset-size1.size4, +.katex .sizing.reset-size1.size4 { + font-size: 1.6em; +} +.katex .fontsize-ensurer.reset-size1.size5, +.katex .sizing.reset-size1.size5 { + font-size: 1.8em; +} +.katex .fontsize-ensurer.reset-size1.size6, +.katex .sizing.reset-size1.size6 { + font-size: 2em; +} +.katex .fontsize-ensurer.reset-size1.size7, +.katex .sizing.reset-size1.size7 { + font-size: 2.4em; +} +.katex .fontsize-ensurer.reset-size1.size8, +.katex .sizing.reset-size1.size8 { + font-size: 2.88em; +} +.katex .fontsize-ensurer.reset-size1.size9, +.katex .sizing.reset-size1.size9 { + font-size: 3.456em; +} +.katex .fontsize-ensurer.reset-size1.size10, +.katex .sizing.reset-size1.size10 { + font-size: 4.148em; +} +.katex .fontsize-ensurer.reset-size1.size11, +.katex .sizing.reset-size1.size11 { + font-size: 4.976em; +} +.katex .fontsize-ensurer.reset-size2.size1, +.katex .sizing.reset-size2.size1 { + font-size: 0.8333333333em; +} +.katex .fontsize-ensurer.reset-size2.size2, +.katex .sizing.reset-size2.size2 { + font-size: 1em; +} +.katex .fontsize-ensurer.reset-size2.size3, +.katex .sizing.reset-size2.size3 { + font-size: 1.1666666667em; +} +.katex .fontsize-ensurer.reset-size2.size4, +.katex .sizing.reset-size2.size4 { + font-size: 1.3333333333em; +} +.katex .fontsize-ensurer.reset-size2.size5, +.katex .sizing.reset-size2.size5 { + font-size: 1.5em; +} +.katex .fontsize-ensurer.reset-size2.size6, +.katex .sizing.reset-size2.size6 { + font-size: 1.6666666667em; +} +.katex .fontsize-ensurer.reset-size2.size7, +.katex .sizing.reset-size2.size7 { + font-size: 2em; +} +.katex .fontsize-ensurer.reset-size2.size8, +.katex .sizing.reset-size2.size8 { + font-size: 2.4em; +} +.katex .fontsize-ensurer.reset-size2.size9, +.katex .sizing.reset-size2.size9 { + font-size: 2.88em; +} +.katex .fontsize-ensurer.reset-size2.size10, +.katex .sizing.reset-size2.size10 { + font-size: 3.4566666667em; +} +.katex .fontsize-ensurer.reset-size2.size11, +.katex .sizing.reset-size2.size11 { + font-size: 4.1466666667em; +} +.katex .fontsize-ensurer.reset-size3.size1, +.katex .sizing.reset-size3.size1 { + font-size: 0.7142857143em; +} +.katex .fontsize-ensurer.reset-size3.size2, +.katex .sizing.reset-size3.size2 { + font-size: 0.8571428571em; +} +.katex .fontsize-ensurer.reset-size3.size3, +.katex .sizing.reset-size3.size3 { + font-size: 1em; +} +.katex .fontsize-ensurer.reset-size3.size4, +.katex .sizing.reset-size3.size4 { + font-size: 1.1428571429em; +} +.katex .fontsize-ensurer.reset-size3.size5, +.katex .sizing.reset-size3.size5 { + font-size: 1.2857142857em; +} +.katex .fontsize-ensurer.reset-size3.size6, +.katex .sizing.reset-size3.size6 { + font-size: 1.4285714286em; +} +.katex .fontsize-ensurer.reset-size3.size7, +.katex .sizing.reset-size3.size7 { + font-size: 1.7142857143em; +} +.katex .fontsize-ensurer.reset-size3.size8, +.katex .sizing.reset-size3.size8 { + font-size: 2.0571428571em; +} +.katex .fontsize-ensurer.reset-size3.size9, +.katex .sizing.reset-size3.size9 { + font-size: 2.4685714286em; +} +.katex .fontsize-ensurer.reset-size3.size10, +.katex .sizing.reset-size3.size10 { + font-size: 2.9628571429em; +} +.katex .fontsize-ensurer.reset-size3.size11, +.katex .sizing.reset-size3.size11 { + font-size: 3.5542857143em; +} +.katex .fontsize-ensurer.reset-size4.size1, +.katex .sizing.reset-size4.size1 { + font-size: 0.625em; +} +.katex .fontsize-ensurer.reset-size4.size2, +.katex .sizing.reset-size4.size2 { + font-size: 0.75em; +} +.katex .fontsize-ensurer.reset-size4.size3, +.katex .sizing.reset-size4.size3 { + font-size: 0.875em; +} +.katex .fontsize-ensurer.reset-size4.size4, +.katex .sizing.reset-size4.size4 { + font-size: 1em; +} +.katex .fontsize-ensurer.reset-size4.size5, +.katex .sizing.reset-size4.size5 { + font-size: 1.125em; +} +.katex .fontsize-ensurer.reset-size4.size6, +.katex .sizing.reset-size4.size6 { + font-size: 1.25em; +} +.katex .fontsize-ensurer.reset-size4.size7, +.katex .sizing.reset-size4.size7 { + font-size: 1.5em; +} +.katex .fontsize-ensurer.reset-size4.size8, +.katex .sizing.reset-size4.size8 { + font-size: 1.8em; +} +.katex .fontsize-ensurer.reset-size4.size9, +.katex .sizing.reset-size4.size9 { + font-size: 2.16em; +} +.katex .fontsize-ensurer.reset-size4.size10, +.katex .sizing.reset-size4.size10 { + font-size: 2.5925em; +} +.katex .fontsize-ensurer.reset-size4.size11, +.katex .sizing.reset-size4.size11 { + font-size: 3.11em; +} +.katex .fontsize-ensurer.reset-size5.size1, +.katex .sizing.reset-size5.size1 { + font-size: 0.5555555556em; +} +.katex .fontsize-ensurer.reset-size5.size2, +.katex .sizing.reset-size5.size2 { + font-size: 0.6666666667em; +} +.katex .fontsize-ensurer.reset-size5.size3, +.katex .sizing.reset-size5.size3 { + font-size: 0.7777777778em; +} +.katex .fontsize-ensurer.reset-size5.size4, +.katex .sizing.reset-size5.size4 { + font-size: 0.8888888889em; +} +.katex .fontsize-ensurer.reset-size5.size5, +.katex .sizing.reset-size5.size5 { + font-size: 1em; +} +.katex .fontsize-ensurer.reset-size5.size6, +.katex .sizing.reset-size5.size6 { + font-size: 1.1111111111em; +} +.katex .fontsize-ensurer.reset-size5.size7, +.katex .sizing.reset-size5.size7 { + font-size: 1.3333333333em; +} +.katex .fontsize-ensurer.reset-size5.size8, +.katex .sizing.reset-size5.size8 { + font-size: 1.6em; +} +.katex .fontsize-ensurer.reset-size5.size9, +.katex .sizing.reset-size5.size9 { + font-size: 1.92em; +} +.katex .fontsize-ensurer.reset-size5.size10, +.katex .sizing.reset-size5.size10 { + font-size: 2.3044444444em; +} +.katex .fontsize-ensurer.reset-size5.size11, +.katex .sizing.reset-size5.size11 { + font-size: 2.7644444444em; +} +.katex .fontsize-ensurer.reset-size6.size1, +.katex .sizing.reset-size6.size1 { + font-size: 0.5em; +} +.katex .fontsize-ensurer.reset-size6.size2, +.katex .sizing.reset-size6.size2 { + font-size: 0.6em; +} +.katex .fontsize-ensurer.reset-size6.size3, +.katex .sizing.reset-size6.size3 { + font-size: 0.7em; +} +.katex .fontsize-ensurer.reset-size6.size4, +.katex .sizing.reset-size6.size4 { + font-size: 0.8em; +} +.katex .fontsize-ensurer.reset-size6.size5, +.katex .sizing.reset-size6.size5 { + font-size: 0.9em; +} +.katex .fontsize-ensurer.reset-size6.size6, +.katex .sizing.reset-size6.size6 { + font-size: 1em; +} +.katex .fontsize-ensurer.reset-size6.size7, +.katex .sizing.reset-size6.size7 { + font-size: 1.2em; +} +.katex .fontsize-ensurer.reset-size6.size8, +.katex .sizing.reset-size6.size8 { + font-size: 1.44em; +} +.katex .fontsize-ensurer.reset-size6.size9, +.katex .sizing.reset-size6.size9 { + font-size: 1.728em; +} +.katex .fontsize-ensurer.reset-size6.size10, +.katex .sizing.reset-size6.size10 { + font-size: 2.074em; +} +.katex .fontsize-ensurer.reset-size6.size11, +.katex .sizing.reset-size6.size11 { + font-size: 2.488em; +} +.katex .fontsize-ensurer.reset-size7.size1, +.katex .sizing.reset-size7.size1 { + font-size: 0.4166666667em; +} +.katex .fontsize-ensurer.reset-size7.size2, +.katex .sizing.reset-size7.size2 { + font-size: 0.5em; +} +.katex .fontsize-ensurer.reset-size7.size3, +.katex .sizing.reset-size7.size3 { + font-size: 0.5833333333em; +} +.katex .fontsize-ensurer.reset-size7.size4, +.katex .sizing.reset-size7.size4 { + font-size: 0.6666666667em; +} +.katex .fontsize-ensurer.reset-size7.size5, +.katex .sizing.reset-size7.size5 { + font-size: 0.75em; +} +.katex .fontsize-ensurer.reset-size7.size6, +.katex .sizing.reset-size7.size6 { + font-size: 0.8333333333em; +} +.katex .fontsize-ensurer.reset-size7.size7, +.katex .sizing.reset-size7.size7 { + font-size: 1em; +} +.katex .fontsize-ensurer.reset-size7.size8, +.katex .sizing.reset-size7.size8 { + font-size: 1.2em; +} +.katex .fontsize-ensurer.reset-size7.size9, +.katex .sizing.reset-size7.size9 { + font-size: 1.44em; +} +.katex .fontsize-ensurer.reset-size7.size10, +.katex .sizing.reset-size7.size10 { + font-size: 1.7283333333em; +} +.katex .fontsize-ensurer.reset-size7.size11, +.katex .sizing.reset-size7.size11 { + font-size: 2.0733333333em; +} +.katex .fontsize-ensurer.reset-size8.size1, +.katex .sizing.reset-size8.size1 { + font-size: 0.3472222222em; +} +.katex .fontsize-ensurer.reset-size8.size2, +.katex .sizing.reset-size8.size2 { + font-size: 0.4166666667em; +} +.katex .fontsize-ensurer.reset-size8.size3, +.katex .sizing.reset-size8.size3 { + font-size: 0.4861111111em; +} +.katex .fontsize-ensurer.reset-size8.size4, +.katex .sizing.reset-size8.size4 { + font-size: 0.5555555556em; +} +.katex .fontsize-ensurer.reset-size8.size5, +.katex .sizing.reset-size8.size5 { + font-size: 0.625em; +} +.katex .fontsize-ensurer.reset-size8.size6, +.katex .sizing.reset-size8.size6 { + font-size: 0.6944444444em; +} +.katex .fontsize-ensurer.reset-size8.size7, +.katex .sizing.reset-size8.size7 { + font-size: 0.8333333333em; +} +.katex .fontsize-ensurer.reset-size8.size8, +.katex .sizing.reset-size8.size8 { + font-size: 1em; +} +.katex .fontsize-ensurer.reset-size8.size9, +.katex .sizing.reset-size8.size9 { + font-size: 1.2em; +} +.katex .fontsize-ensurer.reset-size8.size10, +.katex .sizing.reset-size8.size10 { + font-size: 1.4402777778em; +} +.katex .fontsize-ensurer.reset-size8.size11, +.katex .sizing.reset-size8.size11 { + font-size: 1.7277777778em; +} +.katex .fontsize-ensurer.reset-size9.size1, +.katex .sizing.reset-size9.size1 { + font-size: 0.2893518519em; +} +.katex .fontsize-ensurer.reset-size9.size2, +.katex .sizing.reset-size9.size2 { + font-size: 0.3472222222em; +} +.katex .fontsize-ensurer.reset-size9.size3, +.katex .sizing.reset-size9.size3 { + font-size: 0.4050925926em; +} +.katex .fontsize-ensurer.reset-size9.size4, +.katex .sizing.reset-size9.size4 { + font-size: 0.462962963em; +} +.katex .fontsize-ensurer.reset-size9.size5, +.katex .sizing.reset-size9.size5 { + font-size: 0.5208333333em; +} +.katex .fontsize-ensurer.reset-size9.size6, +.katex .sizing.reset-size9.size6 { + font-size: 0.5787037037em; +} +.katex .fontsize-ensurer.reset-size9.size7, +.katex .sizing.reset-size9.size7 { + font-size: 0.6944444444em; +} +.katex .fontsize-ensurer.reset-size9.size8, +.katex .sizing.reset-size9.size8 { + font-size: 0.8333333333em; +} +.katex .fontsize-ensurer.reset-size9.size9, +.katex .sizing.reset-size9.size9 { + font-size: 1em; +} +.katex .fontsize-ensurer.reset-size9.size10, +.katex .sizing.reset-size9.size10 { + font-size: 1.2002314815em; +} +.katex .fontsize-ensurer.reset-size9.size11, +.katex .sizing.reset-size9.size11 { + font-size: 1.4398148148em; +} +.katex .fontsize-ensurer.reset-size10.size1, +.katex .sizing.reset-size10.size1 { + font-size: 0.2410800386em; +} +.katex .fontsize-ensurer.reset-size10.size2, +.katex .sizing.reset-size10.size2 { + font-size: 0.2892960463em; +} +.katex .fontsize-ensurer.reset-size10.size3, +.katex .sizing.reset-size10.size3 { + font-size: 0.337512054em; +} +.katex .fontsize-ensurer.reset-size10.size4, +.katex .sizing.reset-size10.size4 { + font-size: 0.3857280617em; +} +.katex .fontsize-ensurer.reset-size10.size5, +.katex .sizing.reset-size10.size5 { + font-size: 0.4339440694em; +} +.katex .fontsize-ensurer.reset-size10.size6, +.katex .sizing.reset-size10.size6 { + font-size: 0.4821600771em; +} +.katex .fontsize-ensurer.reset-size10.size7, +.katex .sizing.reset-size10.size7 { + font-size: 0.5785920926em; +} +.katex .fontsize-ensurer.reset-size10.size8, +.katex .sizing.reset-size10.size8 { + font-size: 0.6943105111em; +} +.katex .fontsize-ensurer.reset-size10.size9, +.katex .sizing.reset-size10.size9 { + font-size: 0.8331726133em; +} +.katex .fontsize-ensurer.reset-size10.size10, +.katex .sizing.reset-size10.size10 { + font-size: 1em; +} +.katex .fontsize-ensurer.reset-size10.size11, +.katex .sizing.reset-size10.size11 { + font-size: 1.1996142719em; +} +.katex .fontsize-ensurer.reset-size11.size1, +.katex .sizing.reset-size11.size1 { + font-size: 0.2009646302em; +} +.katex .fontsize-ensurer.reset-size11.size2, +.katex .sizing.reset-size11.size2 { + font-size: 0.2411575563em; +} +.katex .fontsize-ensurer.reset-size11.size3, +.katex .sizing.reset-size11.size3 { + font-size: 0.2813504823em; +} +.katex .fontsize-ensurer.reset-size11.size4, +.katex .sizing.reset-size11.size4 { + font-size: 0.3215434084em; +} +.katex .fontsize-ensurer.reset-size11.size5, +.katex .sizing.reset-size11.size5 { + font-size: 0.3617363344em; +} +.katex .fontsize-ensurer.reset-size11.size6, +.katex .sizing.reset-size11.size6 { + font-size: 0.4019292605em; +} +.katex .fontsize-ensurer.reset-size11.size7, +.katex .sizing.reset-size11.size7 { + font-size: 0.4823151125em; +} +.katex .fontsize-ensurer.reset-size11.size8, +.katex .sizing.reset-size11.size8 { + font-size: 0.578778135em; +} +.katex .fontsize-ensurer.reset-size11.size9, +.katex .sizing.reset-size11.size9 { + font-size: 0.6945337621em; +} +.katex .fontsize-ensurer.reset-size11.size10, +.katex .sizing.reset-size11.size10 { + font-size: 0.8336012862em; +} +.katex .fontsize-ensurer.reset-size11.size11, +.katex .sizing.reset-size11.size11 { + font-size: 1em; +} +.katex .delimsizing.size1 { + font-family: KaTeX_Size1; +} +.katex .delimsizing.size2 { + font-family: KaTeX_Size2; +} +.katex .delimsizing.size3 { + font-family: KaTeX_Size3; +} +.katex .delimsizing.size4 { + font-family: KaTeX_Size4; +} +.katex .delimsizing.mult .delim-size1 > span { + font-family: KaTeX_Size1; +} +.katex .delimsizing.mult .delim-size4 > span { + font-family: KaTeX_Size4; +} +.katex .nulldelimiter { + display: inline-block; + width: 0.12em; +} +.katex .delimcenter, +.katex .op-symbol { + position: relative; +} +.katex .op-symbol.small-op { + font-family: KaTeX_Size1; +} +.katex .op-symbol.large-op { + font-family: KaTeX_Size2; +} +.katex .accent > .vlist-t, +.katex .op-limits > .vlist-t { + text-align: center; +} +.katex .accent .accent-body { + position: relative; +} +.katex .accent .accent-body:not(.accent-full) { + width: 0; +} +.katex .overlay { + display: block; +} +.katex .mtable .vertical-separator { + display: inline-block; + min-width: 1px; +} +.katex .mtable .arraycolsep { + display: inline-block; +} +.katex .mtable .col-align-c > .vlist-t { + text-align: center; +} +.katex .mtable .col-align-l > .vlist-t { + text-align: left; +} +.katex .mtable .col-align-r > .vlist-t { + text-align: right; +} +.katex .svg-align { + text-align: left; +} +.katex svg { + fill: currentColor; + stroke: currentColor; + display: block; + height: inherit; + position: absolute; + width: 100%; +} +.katex svg path { + stroke: none; +} +.katex svg { + fill-rule: nonzero; + fill-opacity: 1; + stroke-width: 1; + stroke-linecap: butt; + stroke-linejoin: miter; + stroke-miterlimit: 4; + stroke-dasharray: none; + stroke-dashoffset: 0; + stroke-opacity: 1; +} +.katex img { + border-style: none; + max-height: none; + max-width: none; + min-height: 0; + min-width: 0; +} +.katex .stretchy { + display: block; + overflow: hidden; + position: relative; + width: 100%; +} +.katex .stretchy:after, +.katex .stretchy:before { + content: ""; +} +.katex .hide-tail { + overflow: hidden; + position: relative; + width: 100%; +} +.katex .halfarrow-left { + left: 0; + overflow: hidden; + position: absolute; + width: 50.2%; +} +.katex .halfarrow-right { + overflow: hidden; + position: absolute; + right: 0; + width: 50.2%; +} +.katex .brace-left { + left: 0; + overflow: hidden; + position: absolute; + width: 25.1%; +} +.katex .brace-center { + left: 25%; + overflow: hidden; + position: absolute; + width: 50%; +} +.katex .brace-right { + overflow: hidden; + position: absolute; + right: 0; + width: 25.1%; +} +.katex .x-arrow-pad { + padding: 0 0.5em; +} +.katex .cd-arrow-pad { + padding: 0 0.55556em 0 0.27778em; +} +.katex .mover, +.katex .munder, +.katex .x-arrow { + text-align: center; +} +.katex .boxpad { + padding: 0 0.3em; +} +.katex .fbox, +.katex .fcolorbox { + border: 0.04em solid; + box-sizing: border-box; +} +.katex .cancel-pad { + padding: 0 0.2em; +} +.katex .cancel-lap { + margin-left: -0.2em; + margin-right: -0.2em; +} +.katex .sout { + border-bottom-style: solid; + border-bottom-width: 0.08em; +} +.katex .angl { + border-right: 0.049em solid; + border-top: 0.049em solid; + box-sizing: border-box; + margin-right: 0.03889em; +} +.katex .anglpad { + padding: 0 0.03889em; +} +.katex .eqn-num:before { + content: "(" counter(katexEqnNo) ")"; + counter-increment: katexEqnNo; +} +.katex .mml-eqn-num:before { + content: "(" counter(mmlEqnNo) ")"; + counter-increment: mmlEqnNo; +} +.katex .mtr-glue { + width: 50%; +} +.katex .cd-vert-arrow { + display: inline-block; + position: relative; +} +.katex .cd-label-left { + display: inline-block; + position: absolute; + right: calc(50% + 0.3em); + text-align: left; +} +.katex .cd-label-right { + display: inline-block; + left: calc(50% + 0.3em); + position: absolute; + text-align: right; +} +.katex-display { + display: block; + margin: 1em 0; + text-align: center; +} +.katex-display > .katex { + display: block; + text-align: center; + white-space: nowrap; +} +.katex-display > .katex > .katex-html { + display: block; + position: relative; +} +.katex-display > .katex > .katex-html > .tag { + position: absolute; + right: 0; +} +.katex-display.leqno > .katex > .katex-html > .tag { + left: 0; + right: auto; +} +.katex-display.fleqn > .katex { + padding-left: 2em; + text-align: left; +} +body { + counter-reset: katexEqnNo mmlEqnNo; +} diff --git a/packages/extension/opencode-plugin/amicode_tools.ts b/packages/extension/opencode-plugin/amicode_tools.ts index 406b25fb..dc97916c 100644 --- a/packages/extension/opencode-plugin/amicode_tools.ts +++ b/packages/extension/opencode-plugin/amicode_tools.ts @@ -186,13 +186,11 @@ export const AmicodeTools = async (_input: unknown) => ({ items: { type: "string" }, description: "Optional one-per-option short qualifier rendered dimly under each button " + - "(e.g. \"fully supported end-to-end\"). Same length as options; omit for none.", + '(e.g. "fully supported end-to-end"). Same length as options; omit for none.', }, }, async execute(a: { question: string; options: string[]; details?: string[] | null }) { - const opts = Array.isArray(a.options) - ? a.options.filter((o) => typeof o === "string" && o.trim() !== "") - : []; + 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."; if (Array.isArray(a.details) && a.details.length > 0 && a.details.length !== opts.length) @@ -221,7 +219,8 @@ export const AmicodeTools = async (_input: unknown) => ({ }, name: { type: "string", - description: "For create/open: the problem name (or slug) to create/find. For rename/archive: the target slug.", + description: + "For create/open: the problem name (or slug) to create/find. For rename/archive: the target slug.", }, new_name: { type: ["string", "null"], @@ -381,7 +380,7 @@ export const AmicodeTools = async (_input: unknown) => ({ params: { type: ["object", "null"], additionalProperties: { type: "number" }, - description: "Extra named numeric model parameters to merge (e.g. {\"T1\": 80}); null for none.", + 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 }) { @@ -421,22 +420,22 @@ export const AmicodeTools = async (_input: unknown) => ({ args: { problem: { type: "string", - description: "Problem kind, e.g. \"gate_synthesis\", \"state_prep\", \"min_time\".", + description: 'Problem kind, e.g. "gate_synthesis", "state_prep", "min_time".', }, target: { type: "string", - description: "The target, e.g. \"X\", \"H\", \"sqrt(X)\", or a description of the unitary/state.", + 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\".", + description: 'Objective; null for the default "unitary infidelity".', }, constraints: { // Optional nullable array — see the details field above. legacyJsonSchema // strips "null" → optional singular-typed array (provider-agnostic). type: ["array", "null"], items: { type: "string" }, - description: "Constraint list; omit for the default [\"amplitude bound (drive_max)\"].", + description: 'Constraint list; omit for the default ["amplitude bound (drive_max)"].', }, }, async execute(a: { problem: string; target: string; objective?: string | null; constraints?: string[] | null }) { @@ -452,9 +451,7 @@ export const AmicodeTools = async (_input: unknown) => ({ target: a.target, objective: given(a.objective) ? a.objective : "unitary infidelity", constraints: - Array.isArray(a.constraints) && a.constraints.length > 0 - ? a.constraints - : ["amplitude bound (drive_max)"], + Array.isArray(a.constraints) && a.constraints.length > 0 ? a.constraints : ["amplitude bound (drive_max)"], }; if (existing?.solve) entity.solve = existing.solve; const problems = validateFormulation(entity); @@ -485,14 +482,17 @@ export const AmicodeTools = async (_input: unknown) => ({ T: { type: ["number", "null"], description: "Gate time T in ns; null if not applicable." }, N: { type: ["integer", "null"], description: "Number of timesteps N; null if not applicable." }, max_iter: { type: ["integer", "null"], description: "Solver max iterations; null for the default." }, - integrator: { type: ["string", "null"], description: "Integrator name (e.g. \"MagnusGL4\"); null for the default." }, + integrator: { + type: ["string", "null"], + description: 'Integrator name (e.g. "MagnusGL4"); null for the default.', + }, tier: { type: ["string", "null"], - description: "Authoring tier: \"vetted\" | \"composed\" | \"free\" (spec C); null if unknown.", + description: 'Authoring tier: "vetted" | "composed" | "free" (spec C); null if unknown.', }, note: { type: ["string", "null"], - description: "Short free-text note, e.g. \"X gate, T=10ns, N=50, defaults\"; null for none.", + description: 'Short free-text note, e.g. "X gate, T=10ns, N=50, defaults"; null for none.', }, }, async execute(a: { @@ -569,7 +569,7 @@ export const AmicodeTools = async (_input: unknown) => ({ amicode_verify: { description: "Record the free-tier re-rollout VERIFICATION outcome on the Run entity (spec C). " + - "Call this AFTER a `tier=\"free\"` solve finishes: amico-run runs the fixed re-rollout " + + 'Call this AFTER a `tier="free"` solve finishes: amico-run runs the fixed re-rollout ' + "harness and writes verification.toml; read it and pass agree + the two fidelities here. " + "Bookkeeping AFTER the fact — no stage gate (a verification record must never be lost). " + "Promotion of a free run is blocked until agree = true.", @@ -651,9 +651,10 @@ export const AmicodeTools = async (_input: unknown) => ({ } 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."; + const warn = + stub.pulse_ref || stub.run_dir + ? "" + : " Note: no pulse/run referenced yet — re-record after the solve finishes."; return ( `Hardware intent noted for "${meta.slug}" — pending your sign-off.${warn}\n\n` + `The send-to-device gate, when wired: (1) automated checks — fidelity ≥ threshold, ` + @@ -672,8 +673,7 @@ export const AmicodeTools = async (_input: unknown) => ({ args: { device_session_ref: { type: ["string", "null"], - description: - "Path to the recorded device_session.toml; null to auto-reference the recorded one if present.", + description: "Path to the recorded device_session.toml; null to auto-reference the recorded one if present.", }, note: { type: ["string", "null"], @@ -782,11 +782,20 @@ export const AmicodeTools = async (_input: unknown) => ({ param: { type: "string", description: "parameter name, e.g. N | T | levels | drive_max | warm_start" }, value: { type: ["string", "number", "boolean", "null"], description: "recommended value (propose)" }, confidence: { type: ["string", "null"], description: "high | medium | low (propose)" }, - provenance: { type: ["array", "null"], description: "[{source, ref, note}] (propose) — cite where it came from" }, + provenance: { + type: ["array", "null"], + description: "[{source, ref, note}] (propose) — cite where it came from", + }, alternatives: { type: ["array", "null"], description: "optional [{value, note}] considered (propose)" }, outcome: { type: ["string", "null"], description: "accepted | overridden (outcome)" }, - applied_value: { type: ["string", "number", "boolean", "null"], description: "the value actually applied (outcome)" }, - auto_accepted: { type: ["boolean", "null"], description: "true when Veloce (L2) auto-accepted this without asking (propose)" }, + applied_value: { + type: ["string", "number", "boolean", "null"], + description: "the value actually applied (outcome)", + }, + auto_accepted: { + type: ["boolean", "null"], + description: "true when Veloce (L2) auto-accepted this without asking (propose)", + }, }, async execute(a: { action: string; @@ -802,7 +811,8 @@ export const AmicodeTools = async (_input: unknown) => ({ }) { try { const slug = readActiveSlug(); - if (!slug) return "No active problem yet — recommendation not recorded (recommendations begin at the problem stage)."; + if (!slug) + return "No active problem yet — recommendation not recorded (recommendations begin at the problem stage)."; const key = `${a.stage ?? "?"}/${a.param ?? "?"}`; if (a.action === "outcome") { const seq = appendEvent(slug, { @@ -831,9 +841,10 @@ export const AmicodeTools = async (_input: unknown) => ({ }, source: { tool: "amicode_recommend", stage: a.stage }, }); - const prov = Array.isArray(a.provenance) && a.provenance.length - ? (a.provenance[0] as { source?: string }).source ?? "?" - : "none"; + const prov = + Array.isArray(a.provenance) && a.provenance.length + ? ((a.provenance[0] as { source?: string }).source ?? "?") + : "none"; const auto = a.auto_accepted ? " ⚡auto" : ""; return `Recommended ${a.param}=${JSON.stringify(a.value)} (${a.confidence ?? "?"}, via ${prov})${auto} [event ${seq}].`; } catch (err) { @@ -870,7 +881,9 @@ export const AmicodeTools = async (_input: unknown) => ({ const e = JSON.parse(line); if (e.entity === "veloce" && e.diff?.mode) mode = e.diff.mode; } - } catch { /* no events yet */ } + } catch { + /* no events yet */ + } return `Veloce is ${mode}.`; } const mode = a.action === "on" ? "on" : "off"; diff --git a/packages/extension/opencode-plugin/distill_queue.ts b/packages/extension/opencode-plugin/distill_queue.ts index d35831e7..d5d32f31 100644 --- a/packages/extension/opencode-plugin/distill_queue.ts +++ b/packages/extension/opencode-plugin/distill_queue.ts @@ -88,7 +88,11 @@ export function releaseLock(opsDir: string): void { /** Reclaim a stale lock (older than 15 min, dead pid) by renaming it aside — * only one reclaimer's rename succeeds — then claiming fresh. Returns true if * THIS caller now holds the lock. */ -export function reclaimIfStale(opsDir: string, pid: number, clock: { now: number; isPidAlive: (pid: number) => boolean }): boolean { +export function reclaimIfStale( + opsDir: string, + pid: number, + clock: { now: number; isPidAlive: (pid: number) => boolean }, +): boolean { let owner: { pid: number; ts: number }; try { owner = JSON.parse(fs.readFileSync(path.join(lockDir(opsDir), "owner"), "utf8")); @@ -227,7 +231,10 @@ export async function runDrainLoop( handler: (job: DistillJob) => Promise, clock: DrainClock, ): Promise { - if (!claimLock(opsDir, clock.pid) && !reclaimIfStale(opsDir, clock.pid, { now: clock.now(), isPidAlive: clock.isPidAlive })) { + if ( + !claimLock(opsDir, clock.pid) && + !reclaimIfStale(opsDir, clock.pid, { now: clock.now(), isPidAlive: clock.isPidAlive }) + ) { return false; } // We hold the lock. diff --git a/packages/extension/opencode-plugin/entities.ts b/packages/extension/opencode-plugin/entities.ts index ba581ff5..29afb950 100644 --- a/packages/extension/opencode-plugin/entities.ts +++ b/packages/extension/opencode-plugin/entities.ts @@ -377,7 +377,10 @@ export function canonicalJson(value: unknown): string { /** Kebab-case slug from a problem name; empty result → "untitled". */ export function deriveSlug(name: string): string { - const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); + const slug = name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); return slug || "untitled"; } @@ -422,8 +425,7 @@ export function truncateDiffForSentinel( diff: Record, maxBytes = 1024, ): Record { - const trunc = (v: unknown): unknown => - typeof v === "string" && v.length > 120 ? v.slice(0, 120) + "…" : v; + const trunc = (v: unknown): unknown => (typeof v === "string" && v.length > 120 ? v.slice(0, 120) + "…" : v); const out: Record = {}; for (const [k, { from, to }] of Object.entries(diff)) out[k] = { from: trunc(from), to: trunc(to) }; const keys = Object.keys(out); diff --git a/packages/extension/opencode-plugin/onboarding.ts b/packages/extension/opencode-plugin/onboarding.ts index 11c0ac47..41f75e78 100644 --- a/packages/extension/opencode-plugin/onboarding.ts +++ b/packages/extension/opencode-plugin/onboarding.ts @@ -49,7 +49,10 @@ export function sanitizePayload(entity: OnboardingEntity, payload: Record l.trim()).length; + return fs + .readFileSync(file, "utf8") + .split("\n") + .filter((l) => l.trim()).length; } catch { return 0; } diff --git a/packages/extension/opencode-plugin/problems.ts b/packages/extension/opencode-plugin/problems.ts index d3026648..cc0c087a 100644 --- a/packages/extension/opencode-plugin/problems.ts +++ b/packages/extension/opencode-plugin/problems.ts @@ -126,9 +126,7 @@ export function openProblem(query: string): ProblemMeta | undefined { return exact; } const q = query.toLowerCase().trim(); - const matches = listProblems().filter( - (m) => m.status !== "archived" && m.name.toLowerCase().includes(q), - ); + const matches = listProblems().filter((m) => m.status !== "archived" && m.name.toLowerCase().includes(q)); if (matches.length === 0) return undefined; matches.sort((a, b) => (b.recorded ?? "").localeCompare(a.recorded ?? "")); setActiveSlug(matches[0].slug); @@ -197,7 +195,10 @@ export interface EventInput { export function lastEventSeq(slug: string): number { const file = path.join(problemDir(slug), "events.jsonl"); if (!fs.existsSync(file)) return 0; - return fs.readFileSync(file, "utf8").split("\n").filter((l) => l.trim() !== "").length; + return fs + .readFileSync(file, "utf8") + .split("\n") + .filter((l) => l.trim() !== "").length; } /** Append one event to the problem's events.jsonl; returns its monotonic seq @@ -206,7 +207,11 @@ export function appendEvent(slug: string, input: EventInput): number { const file = path.join(problemDir(slug), "events.jsonl"); let seq = 1; if (fs.existsSync(file)) { - seq = fs.readFileSync(file, "utf8").split("\n").filter((l) => l.trim() !== "").length + 1; + seq = + fs + .readFileSync(file, "utf8") + .split("\n") + .filter((l) => l.trim() !== "").length + 1; } const record = { seq, diff --git a/packages/extension/opencode-plugin/score_guard.ts b/packages/extension/opencode-plugin/score_guard.ts index 396f248b..db4d5f10 100644 --- a/packages/extension/opencode-plugin/score_guard.ts +++ b/packages/extension/opencode-plugin/score_guard.ts @@ -152,7 +152,12 @@ export function guardAndRecordStage(manifestDir: string, stateDir: string, stage } if (!state) { state = freshScoreState(manifest.id, manifest.version); - appendUsage(stateDir, { kind: "session_started", ts: new Date().toISOString(), score_id: manifest.id, score_version: manifest.version }); + appendUsage(stateDir, { + 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) { diff --git a/packages/extension/opencode.lock.json b/packages/extension/opencode.lock.json index f6685932..9d7e0994 100644 --- a/packages/extension/opencode.lock.json +++ b/packages/extension/opencode.lock.json @@ -1,9 +1,15 @@ { "version": "1.17.3", "repo": "harmoniqs/opencode", - "tag": "v1.17.3-amicode.1", + "tag": "v1.17.3-amicode.2", "platforms": { - "darwin-arm64": { "asset": "opencode-darwin-arm64.zip", "sha256": "f1d6291485246e03a3d33eefb40b4d264d68c8e5812b11b4ffdfec4444ab9455" }, - "linux-x64": { "asset": "opencode-linux-x64.tar.gz", "sha256": "268596e61475dd79d3076dd65d39bf97926d56c28477419250ab3c678bd55b10" } + "darwin-arm64": { + "asset": "opencode-darwin-arm64.zip", + "sha256": "3b41ea7344718e2c985b38b8e166603dd6b06e5472af8ac95431c897a557df33" + }, + "linux-x64": { + "asset": "opencode-linux-x64.tar.gz", + "sha256": "2aff796ab4685ecf9e506255a1a45dd86a0096d76f2a1b8473f4367b204d1ae8" + } } } diff --git a/packages/extension/package.json b/packages/extension/package.json index 3d74850a..723b8678 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -76,6 +76,10 @@ "command": "amicode.openInspector", "title": "Amicode: Open Run Inspector" }, + { + "command": "amicode.selectRun", + "title": "Amicode: Select run to inspect…" + }, { "command": "amicode.restartServer", "title": "Amicode: Restart opencode server" @@ -99,6 +103,10 @@ { "command": "amicode.distillNow", "title": "Amicode: Distill now (update my memory)" + }, + { + "command": "amicode.catalog.remove", + "title": "Remove from Catalog" } ], "configuration": { @@ -106,8 +114,8 @@ "properties": { "amicode.inspector.autoOpen": { "type": "boolean", - "default": false, - "description": "Automatically reveal the Run Inspector panel when a solve starts. When off (default), use the status-bar item or the 'Amicode: Open Run Inspector' command." + "default": true, + "description": "Automatically reveal the Run Inspector panel when a solve starts (default on). Turn off if a starting solve stealing focus bothers you." }, "amicode.opencodeBinary": { "type": "string", @@ -136,19 +144,25 @@ }, "amicode.skillRoots": { "type": "array", - "items": { "type": "string" }, + "items": { + "type": "string" + }, "default": [], "description": "Roots to search for co-located package skills (.jl/skills//SKILL.md). Empty = ~/harmoniqs/packages. First root containing a package's skills wins." }, "amicode.platformSkills": { "type": "array", - "items": { "type": "string" }, + "items": { + "type": "string" + }, "default": [], "description": "Platform-skill names indexed from the central library (public). Empty = atoms, transmon, fluxonium, ions, bosonic. Only listed names are indexed — the library also holds process skills that must not leak." }, "amicode.skillLibraryRoots": { "type": "array", - "items": { "type": "string" }, + "items": { + "type": "string" + }, "default": [], "description": "Roots for the central platform-skill library. Empty = ~/harmoniqs/amico-plugin/skills." }, @@ -166,8 +180,28 @@ "type": "boolean", "default": false, "description": "Amico Veloce: start sessions with autonomy on — auto-accept high-confidence downstream recommendations without asking. Resource gates (solve launch, hardware) always confirm; any interruption drops veloce. Off by default." + }, + "amicode.chat.autoOpen": { + "type": "boolean", + "default": true, + "description": "Open the Amicode chat automatically once the opencode server is ready." } } + }, + "menus": { + "view/item/context": [ + { + "command": "amicode.catalog.remove", + "when": "view == amicode.catalog && viewItem == amicodeCatalogEntry", + "group": "7_modification" + } + ], + "commandPalette": [ + { + "command": "amicode.catalog.remove", + "when": "false" + } + ] } }, "scripts": { @@ -190,6 +224,7 @@ "@types/vscode": "^1.95.0", "@vscode/vsce": "^3.2.0", "esbuild": "^0.24.0", + "happy-dom": "^20.10.6", "smol-toml": "^1.3.0", "typescript": "^5.6.0", "vitest": "^2.1.0" @@ -197,4 +232,4 @@ "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 index f64041ab..d0573b51 100644 --- a/packages/extension/scores/README.md +++ b/packages/extension/scores/README.md @@ -22,34 +22,35 @@ Body = prose (per-stage narration, physics, defaults rationale, off-path guidanc ```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 +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 +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 + - 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)" + 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 + 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 + 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 + gate: heavy # light|heavy — checks must pass BEFORE entering optional: true --- [Amico's voice for this score — markdown + LaTeX, carried verbatim into the prompt] diff --git a/packages/extension/scores/memory/confidence-rubric.md b/packages/extension/scores/memory/confidence-rubric.md index 9a8c6ef7..f96d57da 100644 --- a/packages/extension/scores/memory/confidence-rubric.md +++ b/packages/extension/scores/memory/confidence-rubric.md @@ -11,7 +11,7 @@ never to model judgment. ## Resolution order (pick the highest available, then score it) 1. **own-precedent** — a `## Your recent problems` card matching the full 3-tuple - `(platform, problem_kind, target)`. A match is a *candidate*; score by §high. + `(platform, problem_kind, target)`. A match is a _candidate_; score by §high. 2. **demo** — a `## Reference demos` card matching the full 3-tuple → **medium**. 3. **physics** — the platform skill's canonical value (speed limit, cutoff sizing) → **medium**. @@ -20,6 +20,7 @@ never to model judgment. ## The `high` predicate (own-precedent only, mechanical) A candidate own-precedent card scores **high** iff: + - `platform`, `problem_kind`, `target` all equal, AND - **every gating scalar for the platform** matches within tolerance (below), AND - for a **warm-start** rec additionally: the card's `pulse_ref` resolves to a @@ -30,11 +31,11 @@ pulse). A bare 3-tuple match is NEVER high on its own. ### Gating scalars + tolerances (per platform) -| Platform | Gating scalars (tolerance) | -|---|---| -| transmon | `levels` (exact), `drive_max` (±10%) | -| cavity / bosonic | `fock_cutoff` (exact), `chi` (±10%), target `alpha` or Fock index (exact) | -| atoms (Rydberg) | `levels` (exact), `rabi_max` (±10%), `delta_max` (±10%), distance/blockade (±10%) | +| Platform | Gating scalars (tolerance) | +| ---------------- | --------------------------------------------------------------------------------- | +| transmon | `levels` (exact), `drive_max` (±10%) | +| cavity / bosonic | `fock_cutoff` (exact), `chi` (±10%), target `alpha` or Fock index (exact) | +| atoms (Rydberg) | `levels` (exact), `rabi_max` (±10%), `delta_max` (±10%), distance/blockade (±10%) | **Fail-safe:** a platform NOT listed here, or a card missing any required `sys_params` field, scores **medium, never high**. Unknown regime → fail safe. diff --git a/packages/extension/scores/overture/SCORE.md b/packages/extension/scores/overture/SCORE.md index 726745e4..4e010ad6 100644 --- a/packages/extension/scores/overture/SCORE.md +++ b/packages/extension/scores/overture/SCORE.md @@ -24,7 +24,13 @@ stages: questions: - id: environment prompt: "How will pulses eventually reach hardware — what are we patching into?" - choices: ["extant QICK control code (on-prem, à la Stanford/UChicago)", "a cloud system with an emulator (à la Pasqal)", "simulation only for now", "something else"] + choices: + [ + "extant QICK control code (on-prem, à la Stanford/UChicago)", + "a cloud system with an emulator (à la Pasqal)", + "simulation only for now", + "something else", + ] default: "simulation only for now" rationale_ref: "#environments" - id: devices @@ -90,13 +96,13 @@ Per-stage guidance and the `amicode_profile` mapping: is available in-flow. - **`local-sim`** — simulation only for now (nothing to patch into yet). - **`other`** — record exactly what they say. - Record: `amicode_profile {entity:"environment", payload:{slug, archetype, - control_stack, integration, emulator, endpoints}}` — where `slug` is a short - kebab name (e.g. `stanford-qick-lab`) and **`endpoints` holds pointers only, - NEVER tokens, keys, or passwords** (Amico refuses to store secrets). -4. **devices** *(optional)* — if they name a device, record + Record: `amicode_profile {entity:"environment", payload:{slug, archetype, +control_stack, integration, emulator, endpoints}}` — where `slug` is a short + kebab name (e.g. `stanford-qick-lab`) and **`endpoints` holds pointers only, + NEVER tokens, keys, or passwords** (Amico refuses to store secrets). +4. **devices** _(optional)_ — if they name a device, record `amicode_profile {entity:"device", payload:{name, platform, environment:, - qubits, params}}`. If they skip, move on — devices can be added any time. +qubits, params}}`. If they skip, move on — devices can be added any time. 5. **goals** — record `amicode_profile {entity:"profile", payload:{goals:"..."}}` in their own words. 6. **handoff** — this is the pivot. FIRST record the completion marker: diff --git a/packages/extension/scores/pulse-designer/SCORE.md b/packages/extension/scores/pulse-designer/SCORE.md index 038a63f2..b3c67291 100644 --- a/packages/extension/scores/pulse-designer/SCORE.md +++ b/packages/extension/scores/pulse-designer/SCORE.md @@ -159,12 +159,12 @@ Per-stage notes: (e.g. Piccolissimo **free-phase CZ path**); (3) no skill → **offer free-tier from-scratch authoring anyway** (public packages, **unvetted**, re-rollout- verified). When this FIRST entity records, mention once: "I'll track our progress - in the strip up top — click any part of it to inspect." Never repeat it. Then route, in order: (1) matching **platform skill** in the - `## Skill index` → skill-guided; (2) `issimo` + package skill → the private path - (e.g. Piccolissimo **free-phase CZ path**); (3) no skill → **offer free-tier - from-scratch authoring anyway** (public packages, **unvetted**, re-rollout- - verified). When this FIRST entity records, mention once: "I'll track our progress in the strip up top — click any part of it to inspect." Never repeat it. + + **Naming (user-facing):** `issimo` is an internal entitlement code — NEVER + write it in chat. When describing capabilities or paths to the user, name the + actual package (**Piccolissimo**, **Strettissimo**, **Intonatissimo**) or say + "private-package access"; bare `issimo` reads as a truncated "Piccolissimo". - transmon: $\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, @@ -185,6 +185,7 @@ Per-stage notes: lists a matching skill (`atoms`, `transmon`, `fluxonium`, `ions`, `bosonic`), **invoke it by name** for the physics before authoring — do not hand-roll the Hamiltonian from memory when a skill carries it. + 2. **model** — convention: **`T` = scalar gate time (ns), `N` = number of timesteps** — never conflate them. Record via `amicode_set_model`. Levels are **platform-dependent** — do not default @@ -206,7 +207,7 @@ Per-stage notes: 4. **problem** — Two problem TYPES, **both first-class** — never force one into the other: - **Gate synthesis** (target = a unitary). Transmon single-qubit gates (X, Y, Z, - H, S, T, √X, arbitrary unitary) use the vetted template. Multi-qubit *transmon* + H, S, T, √X, arbitrary unitary) use the vetted template. Multi-qubit _transmon_ gates (CNOT, CZ, iSWAP) have no vetted template — **not declined**: free-tier offer (author from scratch, **unvetted**, re-rollout-verified), caveat up front. **Rydberg CZ is the exception** — the composed `rydberg-cz` exemplar (2-qubit, diff --git a/packages/extension/scripts/build_exemplars.mjs b/packages/extension/scripts/build_exemplars.mjs index b4b54c8f..270903d6 100644 --- a/packages/extension/scripts/build_exemplars.mjs +++ b/packages/extension/scripts/build_exemplars.mjs @@ -8,69 +8,88 @@ // baseline_hash — the SAME mask+sha the amico-run gate recomputes at launch // (deliberately reimplemented here to keep the build dep-free of amico-run; // test/exemplars_build.test.ts cross-checks the two via a shared fixture). -import { createHash } from 'node:crypto' -import { existsSync, mkdirSync, copyFileSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' -import { dirname, join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { parse as parseToml } from 'smol-toml' +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, copyFileSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parse as parseToml } from "smol-toml"; -const here = dirname(fileURLToPath(import.meta.url)) -const exemplarsDir = join(here, '..', 'exemplars') +const here = dirname(fileURLToPath(import.meta.url)); +const exemplarsDir = join(here, "..", "exemplars"); // Masked baseline: interior lines between the fill markers → "#MASKED"; marker // lines kept; unterminated block masks to EOF. MUST match amico-run/src/baseline.ts. -const DEFAULT_BEGIN = /^# ── FILL IN/ -const DEFAULT_END = /^# ─────/ +const DEFAULT_BEGIN = /^# ── FILL IN/; +const DEFAULT_END = /^# ─────/; function maskFillPoints(text, beginSrc, endSrc) { - const begin = beginSrc ? new RegExp(beginSrc) : DEFAULT_BEGIN - const end = endSrc ? new RegExp(endSrc) : DEFAULT_END - const out = [] - let inside = false - for (const line of text.split('\n')) { - if (!inside && begin.test(line)) { inside = true; out.push(line); continue } - if (inside && end.test(line)) { inside = false; out.push(line); continue } - out.push(inside ? '#MASKED' : line) + const begin = beginSrc ? new RegExp(beginSrc) : DEFAULT_BEGIN; + const end = endSrc ? new RegExp(endSrc) : DEFAULT_END; + const out = []; + let inside = false; + for (const line of text.split("\n")) { + if (!inside && begin.test(line)) { + inside = true; + out.push(line); + continue; + } + if (inside && end.test(line)) { + inside = false; + out.push(line); + continue; + } + out.push(inside ? "#MASKED" : line); } - return out.join('\n') + return out.join("\n"); } function maskedHash(text, beginSrc, endSrc) { - return 'sha256:' + createHash('sha256').update(maskFillPoints(text, beginSrc, endSrc)).digest('hex') + return ( + "sha256:" + + createHash("sha256") + .update(maskFillPoints(text, beginSrc, endSrc)) + .digest("hex") + ); } function readEntries(tomlFile) { - if (!existsSync(tomlFile)) return [] - const parsed = parseToml(readFileSync(tomlFile, 'utf8')) - return Array.isArray(parsed.exemplar) ? parsed.exemplar : [] + if (!existsSync(tomlFile)) return []; + const parsed = parseToml(readFileSync(tomlFile, "utf8")); + return Array.isArray(parsed.exemplar) ? parsed.exemplar : []; } -const exemplars = [] +const exemplars = []; // 1. in-repo entries — scripts already live under exemplars/, paths are as-authored -for (const entry of readEntries(join(exemplarsDir, 'EXEMPLARS.toml'))) { - const scriptPath = join(exemplarsDir, entry.path) - if (!existsSync(scriptPath)) { console.error(`build_exemplars: missing in-repo script ${entry.path}`); process.exit(1) } - const text = readFileSync(scriptPath, 'utf8') - exemplars.push({ ...entry, baseline_hash: maskedHash(text, entry.fill_begin, entry.fill_end) }) +for (const entry of readEntries(join(exemplarsDir, "EXEMPLARS.toml"))) { + const scriptPath = join(exemplarsDir, entry.path); + if (!existsSync(scriptPath)) { + console.error(`build_exemplars: missing in-repo script ${entry.path}`); + process.exit(1); + } + const text = readFileSync(scriptPath, "utf8"); + exemplars.push({ ...entry, baseline_hash: maskedHash(text, entry.fill_begin, entry.fill_end) }); } // 2. external demo-repo entries — copy the script in-tree, rewrite path -const demosRoot = process.env.AMICODE_DEMOS_ROOT +const demosRoot = process.env.AMICODE_DEMOS_ROOT; if (demosRoot && existsSync(demosRoot)) { for (const demo of readdirSync(demosRoot, { withFileTypes: true })) { - if (!demo.isDirectory()) continue - const tomlFile = join(demosRoot, demo.name, 'EXEMPLARS.toml') + if (!demo.isDirectory()) continue; + const tomlFile = join(demosRoot, demo.name, "EXEMPLARS.toml"); for (const entry of readEntries(tomlFile)) { - const srcScript = join(demosRoot, demo.name, entry.path) - if (!existsSync(srcScript)) { console.error(`build_exemplars: missing demo script ${demo.name}/${entry.path}`); continue } - const destRel = join(entry.id, 'script.jl') - const destAbs = join(exemplarsDir, destRel) - mkdirSync(dirname(destAbs), { recursive: true }) - copyFileSync(srcScript, destAbs) - const text = readFileSync(destAbs, 'utf8') - exemplars.push({ ...entry, path: destRel, baseline_hash: maskedHash(text, entry.fill_begin, entry.fill_end) }) + const srcScript = join(demosRoot, demo.name, entry.path); + if (!existsSync(srcScript)) { + console.error(`build_exemplars: missing demo script ${demo.name}/${entry.path}`); + continue; + } + const destRel = join(entry.id, "script.jl"); + const destAbs = join(exemplarsDir, destRel); + mkdirSync(dirname(destAbs), { recursive: true }); + copyFileSync(srcScript, destAbs); + const text = readFileSync(destAbs, "utf8"); + exemplars.push({ ...entry, path: destRel, baseline_hash: maskedHash(text, entry.fill_begin, entry.fill_end) }); } } } -writeFileSync(join(exemplarsDir, 'index.json'), JSON.stringify({ schema_version: 1, exemplars }, null, 2) + '\n') -console.log(`build_exemplars: wrote index.json (${exemplars.length} exemplar${exemplars.length === 1 ? '' : 's'})`) +writeFileSync(join(exemplarsDir, "index.json"), JSON.stringify({ schema_version: 1, exemplars }, null, 2) + "\n"); +console.log(`build_exemplars: wrote index.json (${exemplars.length} exemplar${exemplars.length === 1 ? "" : "s"})`); diff --git a/packages/extension/scripts/distill_batch.mjs b/packages/extension/scripts/distill_batch.mjs index 303ddf1d..4c871660 100644 --- a/packages/extension/scripts/distill_batch.mjs +++ b/packages/extension/scripts/distill_batch.mjs @@ -66,7 +66,8 @@ function distillerConfig() { agent: { distiller: { description: "Amico's background memory distiller (headless; no subagents)", - prompt: "You are Amico's distiller. Follow the distiller instructions exactly. Your input is one JSON job object. Work silently; never spawn subagents; finish with a one-line summary.", + prompt: + "You are Amico's distiller. Follow the distiller instructions exactly. Your input is one JSON job object. Work silently; never spawn subagents; finish with a one-line summary.", model: MODEL, }, }, @@ -115,7 +116,14 @@ function workspaceHygiene() { if (e.entity === "formulation" && e.diff?.target?.to) target = e.diff.target.to; } catch {} } - if (target && !ws.toLowerCase().includes(String(target).toLowerCase().replace(/[^a-z0-9]/g, ""))) + if ( + target && + !ws.toLowerCase().includes( + String(target) + .toLowerCase() + .replace(/[^a-z0-9]/g, ""), + ) + ) flags.push(`${ws} → recorded target "${target}"`); } return flags; @@ -157,7 +165,9 @@ console.log(`model: ${MODEL}`); console.log(`runs w/ result.toml: ${runs.length} substantive sessions: ${sessions.length}`); console.log(`workspace hygiene flags (${hygiene.length}):`); for (const f of hygiene) console.log(` ⚠ ${f}`); -console.log(`opencode server alive: ${serverAlive} → DB archive step ${serverAlive ? "SKIPPED (deferred to a no-server window)" : "eligible"}`); +console.log( + `opencode server alive: ${serverAlive} → DB archive step ${serverAlive ? "SKIPPED (deferred to a no-server window)" : "eligible"}`, +); if (has("--dry-run")) { console.log("\n[dry-run] no distills spawned."); @@ -169,7 +179,8 @@ let ok = 0, if (has("--runs-only") || has("--all")) { const sel = runs.slice(0, limit === Infinity ? runs.length : limit); console.log(`\n[runs] distilling ${sel.length} run(s):`); - for (const r of sel) (distill({ kind: "run", run_id: r, vault: VAULT, ops: OPS, runs_root: RUNS_ROOT }, r) ? ok++ : fail++); + for (const r of sel) + distill({ kind: "run", run_id: r, vault: VAULT, ops: OPS, runs_root: RUNS_ROOT }, r) ? ok++ : fail++; } if (has("--demos-ingest")) { const DEMOS = path.join(HOME, "harmoniqs", "demos"); @@ -179,12 +190,17 @@ if (has("--demos-ingest")) { const sel = dirs.slice(0, limit === Infinity ? dirs.length : limit); console.log(`\n[demos] ingesting ${sel.length} demo(s) from ${DEMOS}:`); for (const d of sel) - (distill({ kind: "demo", demo_dir: path.join(DEMOS, d), vault: VAULT, ops: OPS, runs_root: RUNS_ROOT }, d) ? ok++ : fail++); + distill({ kind: "demo", demo_dir: path.join(DEMOS, d), vault: VAULT, ops: OPS, runs_root: RUNS_ROOT }, d) + ? ok++ + : fail++; } if (has("--sweeps") || has("--all")) { const sel = sessions.slice(0, limit === Infinity ? sessions.length : limit); console.log(`\n[sweeps] distilling ${sel.length} session(s):`); - for (const s of sel) (distill({ kind: "sweep", session_ids: [s], vault: VAULT, ops: OPS, runs_root: RUNS_ROOT }, s.slice(0, 20)) ? ok++ : fail++); + for (const s of sel) + distill({ kind: "sweep", session_ids: [s], vault: VAULT, ops: OPS, runs_root: RUNS_ROOT }, s.slice(0, 20)) + ? ok++ + : fail++; } // Summary report (spec §5 step 5) — stdout + a vault notes/ file. @@ -193,7 +209,8 @@ const report = [ `# Batch retro-ingest report — ${stamp}`, ``, `- runs distilled ok: ${ok}, failed: ${fail}`, - `- substantive sessions seen: ${sessions.length}` + (has("--sweeps") || has("--all") ? "" : " (sweeps NOT run this pass)"), + `- substantive sessions seen: ${sessions.length}` + + (has("--sweeps") || has("--all") ? "" : " (sweeps NOT run this pass)"), `- workspace hygiene flags: ${hygiene.length}`, ...hygiene.map((f) => ` - ⚠ ${f}`), `- DB archive of empty sessions: ${serverAlive ? "DEFERRED (server alive) — run with server stopped to sweep empties + agent='distiller' rows" : "eligible"}`, diff --git a/packages/extension/scripts/fetch_opencode.mjs b/packages/extension/scripts/fetch_opencode.mjs index 811ab9d9..c8ac46c1 100644 --- a/packages/extension/scripts/fetch_opencode.mjs +++ b/packages/extension/scripts/fetch_opencode.mjs @@ -1,35 +1,42 @@ #!/usr/bin/env node // Download-at-build vendoring of the opencode chat-server binary, pinned by // opencode.lock.json (spec §2/§3). Importable module + CLI in one file. -import { createHash } from 'node:crypto' -import { execFileSync } from 'node:child_process' +import { createHash } from "node:crypto"; +import { execFileSync } from "node:child_process"; import { - chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, - renameSync, rmSync, writeFileSync, -} from 'node:fs' -import { dirname, join } from 'node:path' -import { fileURLToPath, pathToFileURL } from 'node:url' + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; -const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') +const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); export function loadManifest(root = PKG_ROOT) { - const m = JSON.parse(readFileSync(join(root, 'opencode.lock.json'), 'utf8')) - if (typeof m.version !== 'string' || m.version === '') throw new Error('manifest: version must be a non-empty string') - const platforms = m.platforms ?? {} - if (Object.keys(platforms).length === 0) throw new Error('manifest: platforms missing') + const m = JSON.parse(readFileSync(join(root, "opencode.lock.json"), "utf8")); + if (typeof m.version !== "string" || m.version === "") + throw new Error("manifest: version must be a non-empty string"); + const platforms = m.platforms ?? {}; + if (Object.keys(platforms).length === 0) throw new Error("manifest: platforms missing"); for (const [key, p] of Object.entries(platforms)) { - if (typeof p.asset !== 'string' || p.asset === '') throw new Error(`manifest: ${key}.asset missing`) - if (!/^[0-9a-f]{64}$/.test(p.sha256 ?? '')) throw new Error(`manifest: ${key}.sha256 must be 64 hex chars`) + if (typeof p.asset !== "string" || p.asset === "") throw new Error(`manifest: ${key}.asset missing`); + if (!/^[0-9a-f]{64}$/.test(p.sha256 ?? "")) throw new Error(`manifest: ${key}.sha256 must be 64 hex chars`); } - return m + return m; } export function resolvePlatform(manifest, flag) { - const key = flag ?? `${process.platform}-${process.arch}` + const key = flag ?? `${process.platform}-${process.arch}`; if (!(key in manifest.platforms)) { - throw new Error(`platform ${key} not supported (supported: ${Object.keys(manifest.platforms).join(', ')})`) + throw new Error(`platform ${key} not supported (supported: ${Object.keys(manifest.platforms).join(", ")})`); } - return key + return key; } /** Release coordinates: default = upstream sst/opencode at v; a manifest @@ -37,98 +44,114 @@ export function resolvePlatform(manifest, flag) { * private — downloads go through the authenticated `gh` path in that case). */ export function releaseCoords(manifest) { return { - repo: manifest.repo ?? 'sst/opencode', + repo: manifest.repo ?? "sst/opencode", tag: manifest.tag ?? `v${manifest.version}`, - private: manifest.repo != null, // our mirror is private; upstream is not - } + private: manifest.repo != null, // our mirror is private; upstream is not + }; } export function assetUrl(manifest, platform) { - const { repo, tag } = releaseCoords(manifest) - return `https://github.com/${repo}/releases/download/${tag}/${manifest.platforms[platform].asset}` + const { repo, tag } = releaseCoords(manifest); + return `https://github.com/${repo}/releases/download/${tag}/${manifest.platforms[platform].asset}`; } -export const sha256 = (buf) => createHash('sha256').update(buf).digest('hex') +export const sha256 = (buf) => createHash("sha256").update(buf).digest("hex"); async function defaultDownload(url) { - let r - try { r = await fetch(url) } catch (e) { - throw new Error(`download failed: ${e.message} for ${url}`) // spec §6: URL on connection failures too + let r; + try { + r = await fetch(url); + } catch (e) { + throw new Error(`download failed: ${e.message} for ${url}`); // spec §6: URL on connection failures too } - if (!r.ok) throw new Error(`download failed: HTTP ${r.status} for ${url}`) - return Buffer.from(await r.arrayBuffer()) + if (!r.ok) throw new Error(`download failed: HTTP ${r.status} for ${url}`); + return Buffer.from(await r.arrayBuffer()); } /** Private-release download via the gh CLI (the team's auth path for our * private repos). Plain fetch 404s on private assets — gh handles the token. */ function ghDownload(repo, tag, asset) { - const work = mkdtempSync(join(PKG_ROOT, '.ghdl-')) + const work = mkdtempSync(join(PKG_ROOT, ".ghdl-")); try { - execFileSync('gh', ['release', 'download', tag, '--repo', repo, '--pattern', asset, '--dir', work], - { stdio: ['ignore', 'ignore', 'inherit'] }) - return readFileSync(join(work, asset)) + execFileSync("gh", ["release", "download", tag, "--repo", repo, "--pattern", asset, "--dir", work], { + stdio: ["ignore", "ignore", "inherit"], + }); + return readFileSync(join(work, asset)); } catch (e) { - throw new Error(`gh release download failed for ${repo}@${tag} ${asset}: ${e.message} — is \`gh\` installed and authed for ${repo}?`) + throw new Error( + `gh release download failed for ${repo}@${tag} ${asset}: ${e.message} — is \`gh\` installed and authed for ${repo}?`, + ); } finally { - rmSync(work, { recursive: true, force: true }) + rmSync(work, { recursive: true, force: true }); } } export async function fetchOpencode({ root = PKG_ROOT, platform, download = defaultDownload } = {}) { - const manifest = loadManifest(root) - const key = resolvePlatform(manifest, platform) - const { asset, sha256: want } = manifest.platforms[key] - const destDir = join(root, 'vendor', 'opencode', key) - const bin = join(destDir, 'opencode') - const stamp = join(destDir, '.sha256') + const manifest = loadManifest(root); + const key = resolvePlatform(manifest, platform); + const { asset, sha256: want } = manifest.platforms[key]; + const destDir = join(root, "vendor", "opencode", key); + const bin = join(destDir, "opencode"); + const stamp = join(destDir, ".sha256"); - if (existsSync(bin) && existsSync(stamp) && readFileSync(stamp, 'utf8').trim() === want) { - return { skipped: true, path: bin } // offline repeat builds + if (existsSync(bin) && existsSync(stamp) && readFileSync(stamp, "utf8").trim() === want) { + return { skipped: true, path: bin }; // offline repeat builds } - const coords = releaseCoords(manifest) - const bytes = coords.private && download === defaultDownload - ? ghDownload(coords.repo, coords.tag, asset) - : await download(assetUrl(manifest, key)) - const got = sha256(bytes) + const coords = releaseCoords(manifest); + const bytes = + coords.private && download === defaultDownload + ? ghDownload(coords.repo, coords.tag, asset) + : await download(assetUrl(manifest, key)); + const got = sha256(bytes); if (got !== want) { // Possible supply-chain signal: no retry, no override (spec §3 step 4). - throw new Error(`SHA256 mismatch for ${asset}: expected ${want}, actual ${got}`) + throw new Error(`SHA256 mismatch for ${asset}: expected ${want}, actual ${got}`); } - mkdirSync(destDir, { recursive: true }) - const work = mkdtempSync(join(destDir, '.unpack-')) // same fs → rename is atomic + mkdirSync(destDir, { recursive: true }); + const work = mkdtempSync(join(destDir, ".unpack-")); // same fs → rename is atomic try { - const archive = join(work, asset) - writeFileSync(archive, bytes) - if (asset.endsWith('.zip')) execFileSync('unzip', ['-oq', archive, '-d', work]) - else execFileSync('tar', ['-xzf', archive, '-C', work]) - if (!existsSync(join(work, 'opencode'))) throw new Error(`archive ${asset} did not contain a flat 'opencode' binary`) - renameSync(join(work, 'opencode'), bin) - chmodSync(bin, 0o755) - writeFileSync(stamp, got + '\n') // stamp last (spec §3 step 5) + const archive = join(work, asset); + writeFileSync(archive, bytes); + if (asset.endsWith(".zip")) execFileSync("unzip", ["-oq", archive, "-d", work]); + else execFileSync("tar", ["-xzf", archive, "-C", work]); + if (!existsSync(join(work, "opencode"))) + throw new Error(`archive ${asset} did not contain a flat 'opencode' binary`); + renameSync(join(work, "opencode"), bin); + chmodSync(bin, 0o755); + writeFileSync(stamp, got + "\n"); // stamp last (spec §3 step 5) } finally { - rmSync(work, { recursive: true, force: true }) + rmSync(work, { recursive: true, force: true }); } - return { skipped: false, path: bin } + return { skipped: false, path: bin }; } async function main(argv) { - const flagIdx = argv.indexOf('--platform') - const platform = flagIdx >= 0 ? argv[flagIdx + 1] : undefined - if (argv.includes('--record')) { // pin-time only (spec §3 step 6) - const manifest = loadManifest() + const flagIdx = argv.indexOf("--platform"); + const platform = flagIdx >= 0 ? argv[flagIdx + 1] : undefined; + if (argv.includes("--record")) { + // pin-time only (spec §3 step 6) + const manifest = loadManifest(); for (const key of Object.keys(manifest.platforms)) { - const bytes = await defaultDownload(assetUrl(manifest, key)) - console.log(`${key} ${sha256(bytes)}`) + const bytes = await defaultDownload(assetUrl(manifest, key)); + console.log(`${key} ${sha256(bytes)}`); } - return 0 + return 0; } - const r = await fetchOpencode({ platform }) - console.log(r.skipped ? `[fetch-opencode] up to date: ${r.path}` : `[fetch-opencode] installed: ${r.path}`) - return 0 + const r = await fetchOpencode({ platform }); + console.log(r.skipped ? `[fetch-opencode] up to date: ${r.path}` : `[fetch-opencode] installed: ${r.path}`); + return 0; } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - main(process.argv.slice(2)).then(c => { process.exitCode = c }, e => { console.error(`[fetch-opencode] ${e.message}`); process.exitCode = 1 }) + main(process.argv.slice(2)).then( + (c) => { + process.exitCode = c; + }, + (e) => { + console.error(`[fetch-opencode] ${e.message}`); + process.exitCode = 1; + }, + ); } diff --git a/packages/extension/scripts/healthcheck.mjs b/packages/extension/scripts/healthcheck.mjs index 40973e60..dc889395 100644 --- a/packages/extension/scripts/healthcheck.mjs +++ b/packages/extension/scripts/healthcheck.mjs @@ -1,46 +1,66 @@ #!/usr/bin/env node // Amicode healthcheck — exit 0 iff julia+project, opencode /event, amico-run, // and LLM creds all resolve; else non-zero with a precise ✗ line per failure. -import { execFileSync } from 'node:child_process' -import { existsSync, realpathSync } from 'node:fs' -import { homedir } from 'node:os' -import { dirname, join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { bootOpencodeAndProbe } from './opencode_probe.mjs' +import { execFileSync } from "node:child_process"; +import { existsSync, realpathSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { bootOpencodeAndProbe } from "./opencode_probe.mjs"; -const EXT_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') -const JULIA_PROJECT = join(homedir(), '.amico', 'julia') // absolute — '~' is NOT expanded in flags -const CHECK_ORDER = ['julia', 'opencode', 'amicorun', 'creds'] -const LABEL = { julia: 'julia+project', opencode: 'opencode /event', amicorun: 'amico-run', creds: 'LLM creds' } +const EXT_ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const JULIA_PROJECT = join(homedir(), ".amico", "julia"); // absolute — '~' is NOT expanded in flags +const CHECK_ORDER = ["julia", "opencode", "amicorun", "creds"]; +const LABEL = { julia: "julia+project", opencode: "opencode /event", amicorun: "amico-run", creds: "LLM creds" }; /** PURE: results = { [name]: {ok} | {ok:false,reason,fix} } → { exitCode, lines }. Unit-tested. */ export function resolveChecks(results) { - const lines = [] - let failed = 0 + const lines = []; + let failed = 0; for (const name of CHECK_ORDER) { - const r = results[name] ?? { ok: false, reason: 'not run', fix: 'internal' } - if (r.ok) lines.push(`✓ ${LABEL[name]}`) - else { failed++; lines.push(`✗ ${LABEL[name]}: ${r.reason} → ${r.fix}`) } + const r = results[name] ?? { ok: false, reason: "not run", fix: "internal" }; + if (r.ok) lines.push(`✓ ${LABEL[name]}`); + else { + failed++; + lines.push(`✗ ${LABEL[name]}: ${r.reason} → ${r.fix}`); + } } - lines.push(failed === 0 ? `\nAll ${CHECK_ORDER.length} checks passed.` : `\n${failed} check(s) failed.`) - return { exitCode: failed === 0 ? 0 : 1, lines } + lines.push(failed === 0 ? `\nAll ${CHECK_ORDER.length} checks passed.` : `\n${failed} check(s) failed.`); + return { exitCode: failed === 0 ? 0 : 1, lines }; } // ---- probe implementations (impure; run only when executed as CLI) ---- function probeJulia() { - if (!existsSync(JULIA_PROJECT)) return { ok: false, reason: `no julia project at ${JULIA_PROJECT}`, fix: 'run scripts/install.sh' } - try { execFileSync('julia', [`--project=${JULIA_PROJECT}`, '-e', 'using Piccolo'], { stdio: 'ignore', timeout: 300_000 }); return { ok: true } } - catch (e) { return { ok: false, reason: `julia/Piccolo load failed (${(e.message || '').slice(0, 80)})`, fix: 'run scripts/install.sh to instantiate' } } + if (!existsSync(JULIA_PROJECT)) + return { ok: false, reason: `no julia project at ${JULIA_PROJECT}`, fix: "run scripts/install.sh" }; + try { + execFileSync("julia", [`--project=${JULIA_PROJECT}`, "-e", "using Piccolo"], { stdio: "ignore", timeout: 300_000 }); + return { ok: true }; + } catch (e) { + return { + ok: false, + reason: `julia/Piccolo load failed (${(e.message || "").slice(0, 80)})`, + fix: "run scripts/install.sh to instantiate", + }; + } } function probeAmicorun() { - for (const dir of [join(EXT_ROOT, 'bin', 'launcher'), join(EXT_ROOT, '..', 'amico-run', 'launcher')]) { - const p = join(dir, 'amico-run') + for (const dir of [join(EXT_ROOT, "bin", "launcher"), join(EXT_ROOT, "..", "amico-run", "launcher")]) { + const p = join(dir, "amico-run"); if (existsSync(p)) { - try { execFileSync(p, ['--help'], { stdio: 'ignore', timeout: 15_000 }); return { ok: true } } - catch (e) { return { ok: false, reason: `amico-run --help failed (${(e.message || '').slice(0, 60)})`, fix: 'rebuild amico-run / check node on PATH' } } + try { + execFileSync(p, ["--help"], { stdio: "ignore", timeout: 15_000 }); + return { ok: true }; + } catch (e) { + return { + ok: false, + reason: `amico-run --help failed (${(e.message || "").slice(0, 60)})`, + fix: "rebuild amico-run / check node on PATH", + }; + } } } - return { ok: false, reason: 'amico-run launcher not found', fix: 'pnpm -r build (stages bin/) or check the VSIX' } + return { ok: false, reason: "amico-run launcher not found", fix: "pnpm -r build (stages bin/) or check the VSIX" }; } // opencode + LLM creds (0.3): ONE boot of the vendored opencode answers both — @@ -50,30 +70,45 @@ function probeAmicorun() { // LLM call. boot.signal is key-free (stripped at the probe boundary). function opencodeChecks(boot) { if (boot.binMissing) { - const miss = { ok: false, reason: 'vendored opencode binary missing', fix: 'pnpm --filter amicode-v2 fetch:opencode' } - return { opencode: miss, creds: { ok: false, reason: 'opencode unavailable (binary missing)', fix: miss.fix } } + const miss = { + ok: false, + reason: "vendored opencode binary missing", + fix: "pnpm --filter amicode-v2 fetch:opencode", + }; + return { opencode: miss, creds: { ok: false, reason: "opencode unavailable (binary missing)", fix: miss.fix } }; } const opencode = boot.eventOk ? { ok: true } - : { ok: false, reason: `vendored opencode did not serve /event 200 (${boot.up ? `status ${boot.eventStatus}` : 'server not up'})`, fix: 'pnpm --filter amicode-v2 fetch:opencode' } - const creds = boot.signal ?? { ok: false, reason: 'opencode did not boot — creds unverifiable', fix: 'fix opencode boot first' } - return { opencode, creds } + : { + ok: false, + reason: `vendored opencode did not serve /event 200 (${boot.up ? `status ${boot.eventStatus}` : "server not up"})`, + fix: "pnpm --filter amicode-v2 fetch:opencode", + }; + const creds = boot.signal ?? { + ok: false, + reason: "opencode did not boot — creds unverifiable", + fix: "fix opencode boot first", + }; + return { opencode, creds }; } async function main() { - const boot = await bootOpencodeAndProbe({ timeoutMs: 90_000 }) - const { opencode, creds } = opencodeChecks(boot) - const results = { julia: probeJulia(), opencode, amicorun: probeAmicorun(), creds } - const { exitCode, lines } = resolveChecks(results) - console.log(lines.join('\n')) - process.exitCode = exitCode + const boot = await bootOpencodeAndProbe({ timeoutMs: 90_000 }); + const { opencode, creds } = opencodeChecks(boot); + const results = { julia: probeJulia(), opencode, amicorun: probeAmicorun(), creds }; + const { exitCode, lines } = resolveChecks(results); + console.log(lines.join("\n")); + process.exitCode = exitCode; } // realpath-compare so a symlinked invocation path (e.g. macOS /tmp→/private/tmp) // can't make this silently no-op and exit 0 — a false "healthcheck passed". function isMain() { - if (!process.argv[1]) return false - try { return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)) } - catch { return false } + if (!process.argv[1]) return false; + try { + return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); + } catch { + return false; + } } -if (isMain()) await main() +if (isMain()) await main(); diff --git a/packages/extension/scripts/opencode_probe.mjs b/packages/extension/scripts/opencode_probe.mjs index a232b4b8..459379c4 100644 --- a/packages/extension/scripts/opencode_probe.mjs +++ b/packages/extension/scripts/opencode_probe.mjs @@ -40,7 +40,8 @@ function freePort() { * remaining fields explain why. */ export async function bootOpencodeAndProbe({ bin = vendoredOpencodeBin(), timeoutMs = 30000 } = {}) { - if (!existsSync(bin)) return { binMissing: true, up: false, eventOk: false, log: `vendored binary missing at ${bin}` }; + if (!existsSync(bin)) + return { binMissing: true, up: false, eventOk: false, log: `vendored binary missing at ${bin}` }; const proj = mkdtempSync(join(tmpdir(), "amicode-probe-")); mkdirSync(join(proj, ".opencode"), { recursive: true }); @@ -53,15 +54,23 @@ export async function bootOpencodeAndProbe({ bin = vendoredOpencodeBin(), timeou const port = await freePort(); let log = ""; const child = spawn(bin, ["serve", "--port", String(port)], { cwd: proj, stdio: ["ignore", "pipe", "pipe"] }); - const onData = (d) => { log += d; }; + const onData = (d) => { + log += d; + }; child.stdout.on("data", onData); child.stderr.on("data", onData); const cleanup = () => { - try { child.kill("SIGTERM"); } catch {} + try { + child.kill("SIGTERM"); + } catch {} // .unref() the SIGKILL fallback so it can't hold `node healthcheck.mjs` open // for 3s after it's otherwise done (the child usually exits on SIGTERM). - setTimeout(() => { try { child.kill("SIGKILL"); } catch {} }, 3000).unref(); + setTimeout(() => { + try { + child.kill("SIGKILL"); + } catch {} + }, 3000).unref(); }; try { @@ -71,13 +80,17 @@ export async function bootOpencodeAndProbe({ bin = vendoredOpencodeBin(), timeou try { const r = await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(500) }); if (r.status < 500) up = true; - } catch { /* not up yet */ } + } catch { + /* not up yet */ + } if (!up) await new Promise((r) => setTimeout(r, 200)); } if (!up) return { up: false, eventOk: false, log }; // /event gate (headers only; SSE body streaming doesn't block us). - let eventStatus, eventCtype, eventOk = false; + let eventStatus, + eventCtype, + eventOk = false; try { const ev = await fetch(`http://127.0.0.1:${port}/event`, { signal: AbortSignal.timeout(10000) }); eventStatus = ev.status; diff --git a/packages/extension/scripts/plugin_exercise.ts b/packages/extension/scripts/plugin_exercise.ts index 6a3393d1..6b396529 100644 --- a/packages/extension/scripts/plugin_exercise.ts +++ b/packages/extension/scripts/plugin_exercise.ts @@ -34,17 +34,26 @@ const pack: any = await AmicodeTools({}); const tools = pack.tool; // create → pick_system → set_model → formulate → solve -const s0 = lastSentinel(await tools.amicode_problem.execute({ action: "create", name: "X gate on Q1", new_name: null })); +const s0 = lastSentinel( + await tools.amicode_problem.execute({ action: "create", name: "X gate on Q1", new_name: null }), +); assert(s0.entity === "problem" && s0.action === "created", "problem/created sentinel"); const slug: string = s0.problem; lastSentinel(await tools.amicode_pick_system.execute({ platform: "transmon", omega: 4.8, delta: -0.2, notes: null })); lastSentinel(await tools.amicode_set_model.execute({ levels: 4, drive_max: 0.2, params: null })); -lastSentinel(await tools.amicode_formulate.execute({ problem: "gate_synthesis", target: "X", objective: null, constraints: null })); +lastSentinel( + await tools.amicode_formulate.execute({ problem: "gate_synthesis", target: "X", objective: null, constraints: null }), +); const s4 = lastSentinel( await tools.amicode_solve.execute({ run_dir: "/home/u/.amico/runs/default/20260703-190412-abcd", - T: 10, N: 50, max_iter: 60, integrator: "MagnusGL4", tier: "vetted", note: "X gate", + T: 10, + N: 50, + max_iter: 60, + integrator: "MagnusGL4", + tier: "vetted", + note: "X gate", }), ); assert(s4.entity === "run", "solve emits a run sentinel"); @@ -57,22 +66,38 @@ assert(s5.entity === "run" && s5.action === "updated", "verify updates the run e // Workspace layout const ws = path.join(tmp, slug); -for (const f of ["entities/system.toml", "entities/system.json", "entities/formulation.toml", "entities/run.toml", "problem.json"]) { +for (const f of [ + "entities/system.toml", + "entities/system.json", + "entities/formulation.toml", + "entities/run.toml", + "problem.json", +]) { assert(fs.existsSync(path.join(ws, f)), `workspace file ${f}`); } // Event log: >=5 events, monotonic seq, incl. the solve-params Formulation merge -const events = fs.readFileSync(path.join(ws, "events.jsonl"), "utf8").trim().split("\n").map((l) => JSON.parse(l)); +const events = fs + .readFileSync(path.join(ws, "events.jsonl"), "utf8") + .trim() + .split("\n") + .map((l) => JSON.parse(l)); assert(events.length >= 5, `>=5 events (got ${events.length})`); events.forEach((e: any, i: number) => assert(e.seq === i + 1, `monotonic seq at index ${i} (got ${e.seq})`)); const formEvents = events.filter((e: any) => e.entity === "formulation"); assert(formEvents.length >= 2, `formulation created + solve-merge update (got ${formEvents.length})`); const sysEvents = events.filter((e: any) => e.entity === "system"); -assert(sysEvents.some((e: any) => e.hash?.startsWith("sha256:")), "system events carry a content hash"); +assert( + sysEvents.some((e: any) => e.hash?.startsWith("sha256:")), + "system events carry a content hash", +); // Run ref parsed from run_dir's last two segments const runs = JSON.parse(fs.readFileSync(path.join(ws, "runs.json"), "utf8")); -assert(runs.runs.length === 1 && runs.runs[0].run_id === "20260703-190412-abcd" && runs.runs[0].lab === "default", "runs.json ref"); +assert( + runs.runs.length === 1 && runs.runs[0].run_id === "20260703-190412-abcd" && runs.runs[0].lab === "default", + "runs.json ref", +); assert(runs.runs[0].tier === "vetted", "run ref carries tier"); console.error(`OK — ${events.length} events, ${formEvents.length} formulation events, workspace "${slug}"`); diff --git a/packages/extension/src/catalog_card_shell.ts b/packages/extension/src/catalog_card_shell.ts new file mode 100644 index 00000000..aa6f36e7 --- /dev/null +++ b/packages/extension/src/catalog_card_shell.ts @@ -0,0 +1,145 @@ +// Catalog-card shell (#47, v1) — hosts the catalogcard component in a webview. +// +// The card appears only through the save-to-catalog flow: a converged run's +// promote prompt (live solves via the watcher, demo replays via the replay +// command) → "Save to catalog" → `amicode.catalogCard.open` with the run dir. +// The entry is hydrated from the REAL run artifacts: run.toml (identity), +// result.toml (fidelity, params — params.gate/params.system lifted to the +// entry's top level; iterations/wall → the proposed block), and the run.log +// pulse lines (meta + newest record → the card's plot). Not palette- +// contributed — there is no card without a run to save. +// +// Persistence note: opening a card stores nothing durable; the session +// catalog (trees.ts) records POINTERS only. Where promoted artifacts persist +// is the open Phase-3 CatalogStore design (Q91/Q92). + +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as vscode from "vscode"; +import { PulseStream, readTomlSafe, type PulseEvent } from "./run_dir_reader"; + +export function registerCatalogCard(ctx: vscode.ExtensionContext): void { + const open = new Map(); // run_id → live panel + ctx.subscriptions.push( + vscode.commands.registerCommand( + "amicode.catalogCard.open", + (runDir: string, systemName?: string, tags?: string[]) => { + const data = hydrateFromRunDir(runDir, systemName, tags); + if (!data) { + void vscode.window.showErrorMessage( + "Amicode: cannot build a catalog entry — run dir is missing run.toml/result.toml.", + ); + return; + } + const key = String(data.entry.run_id); + const existing = open.get(key); + if (existing) { + existing.reveal(vscode.ViewColumn.One); + return; + } // re-focus, don't re-create + const panel = vscode.window.createWebviewPanel( + "amicode.catalogCard", + `Catalog: ${data.entry.run_id}`, + vscode.ViewColumn.One, + { + enableScripts: true, + localResourceRoots: [ + vscode.Uri.joinPath(ctx.extensionUri, "dist"), + vscode.Uri.joinPath(ctx.extensionUri, "media"), + ], + }, + ); + open.set(key, panel); + panel.onDidDispose(() => open.delete(key), null, ctx.subscriptions); + panel.webview.onDidReceiveMessage((m) => { + if (m?.type !== "whatnext") return; + // Wire the save → tune → warm-start ladder to the CHAT (the agent owns the + // solve workflow): stage a concrete prompt on the clipboard and open the + // chat. Promote (team catalog) stays honestly unwired until Phase 3. + const e = data.entry; + const ident = `${e.gate ?? "gate"} on ${e.system ?? String(e.lab_id)} (run ${e.run_id}, F=${Number(e.fidelity).toFixed(5)})`; + if (m.id === "warmstart" || m.id === "tune") { + const prompt = + m.id === "warmstart" + ? `Warm-start a new solve from the banked pulse of ${ident}: load ${runDir}/pulse.jld2 as the initial trajectory (load_traj), keep the same formulation, and run it.` + : `Tune the solve for ${ident}: start from ${runDir}/pulse.jld2, keep the formulation but ask me which weights/params (Q, R, T, N, max_iter) to adjust before launching.`; + void vscode.env.clipboard.writeText(prompt).then(async () => { + await vscode.commands.executeCommand("amicode.openChat"); + void vscode.window.showInformationMessage( + `Amicode: ${m.id} prompt copied — paste into the chat to launch.`, + ); + }); + } else if (m.id === "promote") { + void vscode.window.showInformationMessage( + "Amicode: team-catalog promotion isn't wired yet (Phase 3) — the pulse stays in your local bank.", + ); + } + }); + const uri = (...p: string[]) => panel.webview.asWebviewUri(vscode.Uri.joinPath(ctx.extensionUri, ...p)); + const nonce = Math.random().toString(36).slice(2); + panel.webview.html = ` + + + + + + + +`; + }, + ), + ); +} + +/** Build the card's data from real run artifacts. Returns undefined when the + * dir lacks the promote-shaped basics. Shape mirrors the webview's CARD_DATA. + * Exported for tests. */ +export function hydrateFromRunDir( + runDir: string, + systemName?: string, + tags?: string[], +): { entry: Record; pulse?: { meta: unknown; record: unknown } } | undefined { + const manifest = readTomlSafe(path.join(runDir, "run.toml")); + const result = readTomlSafe(path.join(runDir, "result.toml")); + if (!manifest || !result) return undefined; + + const params = (result.params ?? {}) as Record; + const entry: Record = { + schema_version: "1", + run_id: String(manifest.run_id ?? path.basename(runDir)), + lab_id: String(manifest.lab_id ?? "default"), + gate: typeof params.gate === "string" ? params.gate : undefined, + fidelity: Number(result.fidelity ?? 0), + pulse_path: path.join(runDir, "pulse.jld2"), + created_at: manifest.created_at, + params, + // Not in catalog-entry.schema.json — rendered visibly marked. The + // user-assigned system name is the sharpest schema question here: human + // identity ("Emerald-Q3") vs machine params (family/levels/δ). + proposed: { + system_name: systemName, + tags, + iterations: result.iterations, + wall_seconds: result.wall_seconds, + }, + }; + + // Pulse plot from the run's own AMICODE_PULSE lines: meta + newest record. + let pulse: { meta: unknown; record: unknown } | undefined; + try { + const stream = new PulseStream(); + let meta: PulseEvent | undefined, newest: PulseEvent | undefined; + for (const line of fs.readFileSync(path.join(runDir, "run.log"), "utf8").split("\n")) { + const e = stream.onLine(line); + if (e?.type === "meta") { + meta = e; + newest = undefined; + } else if (e?.type === "record") newest = e; + } + if (meta?.type === "meta" && newest?.type === "record") pulse = { meta: meta.meta, record: newest.record }; + } catch { + /* no run.log → card renders the not-hydrated state */ + } + + return { entry, pulse }; +} diff --git a/packages/extension/src/catalog_card_webview.ts b/packages/extension/src/catalog_card_webview.ts new file mode 100644 index 00000000..ab937b8b --- /dev/null +++ b/packages/extension/src/catalog_card_webview.ts @@ -0,0 +1,72 @@ +// Catalog-card webview entry (#47) — mounts the card from host-injected data +// (window.__CARD_DATA__, hydrated from the real run dir by the save-to-catalog +// flow); the baked fixture below is the fallback for hostless debugging. + +import { applyBrandAccent } from "../media/ui/brand_accent"; +import { catalogcard, type CatalogEntry, type CardPulse } from "../media/ui/components/catalogcard"; + +applyBrandAccent(); // theme-calculated Harmoniqs yellow (brand-wide contract) + +declare function acquireVsCodeApi(): { postMessage(msg: unknown): void }; +declare global { + interface Window { + __CARD_DATA__?: { entry: CatalogEntry; pulse?: CardPulse }; + } +} + +// Grounded in packages/schema/test/fixtures/valid/catalog-entry.toml; the +// `proposed` block is NOT schema — it renders visibly marked (field-selection +// feedback artifact for Krishna/Andrew). +const ENTRY: CatalogEntry = { + schema_version: "1", + run_id: "r20260615-000000Z-ab12", + lab_id: "default", + gate: "X", + fidelity: 0.99995, + pulse_path: "/Users/researcher/.amico/runs/default/r20260615-000000Z-ab12/pulse.jld2", + created_at: "2026-06-15T00:00:00Z", + params: { system: "transmon", levels: 3, T: 10.0, N: 50, drive_max: 0.2 }, + proposed: { tags: ["smooth"], index: 1, iterations: 60, wall_seconds: 41 }, +}; + +const PULSE: CardPulse = { + meta: { + drives: 2, + knots: 25, + labels: ["u_1", "u_2"], + bounds: [ + [-0.2, 0.2], + [-0.2, 0.2], + ], + }, + record: { + iter: 60, + dt: 0.4, + values: [ + [ + 0.012, 0.048, 0.096, 0.141, 0.172, 0.184, 0.176, 0.149, 0.108, 0.058, 0.006, -0.043, -0.084, -0.113, -0.128, + -0.127, -0.111, -0.083, -0.047, -0.008, 0.028, 0.055, 0.068, 0.062, 0.033, + ], + [ + -0.021, -0.052, -0.079, -0.096, -0.1, -0.089, -0.065, -0.031, 0.009, 0.049, 0.084, 0.109, 0.121, 0.118, 0.1, + 0.07, 0.032, -0.009, -0.048, -0.079, -0.098, -0.102, -0.089, -0.061, -0.024, + ], + ], + }, +}; + +const SIBLINGS = [ + { gate: "X", system: "transmon", tags: ["fast"], index: 2 }, + { gate: "X", system: "transmon", tags: ["robust"], index: 3 }, + { gate: "H", system: "transmon", tags: ["smooth"], index: 7 }, +]; + +const vscodeApi = acquireVsCodeApi(); +const injected = window.__CARD_DATA__; +const card = catalogcard(injected?.entry ?? ENTRY, { + pulse: injected ? injected.pulse : PULSE, + siblings: injected ? [] : SIBLINGS, // sibling entries need a store — none yet on the real path + onAction: (id) => vscodeApi.postMessage({ type: "whatnext", id }), +}); +document.body.style.padding = "16px"; +document.body.append(card.el); diff --git a/packages/extension/src/chat_panel.ts b/packages/extension/src/chat_panel.ts index f82c40a5..7f043f07 100644 --- a/packages/extension/src/chat_panel.ts +++ b/packages/extension/src/chat_panel.ts @@ -1,5 +1,7 @@ import * as vscode from "vscode"; import { randomBytes } from "node:crypto"; +import * as path from "node:path"; +import * as os from "node:os"; // ============================================================================ // ChatPanel — a single WebviewPanel that iframes opencode's SolidJS chat at @@ -18,15 +20,39 @@ const BRIDGE_ALLOWED_COMMANDS: ReadonlySet = new Set([ "amicode.savePulse", "amicode.openRunDir", "amicode.openInspector", + // ⌘⇧P inside the chat iframe lands in the APP's palette, not VS Code's — + // the fork forwards it here so the editor's Command Palette (where every + // Amicode: command lives) opens as users expect. + "workbench.action.showCommands", ]); +/** VS Code theme kind → the fork app's ColorScheme. */ +function themeKindToScheme(kind: vscode.ColorThemeKind): "light" | "dark" { + return kind === vscode.ColorThemeKind.Light || kind === vscode.ColorThemeKind.HighContrastLight ? "light" : "dark"; +} + export class ChatPanel { private static current?: ChatPanel; private readonly disposables: vscode.Disposable[] = []; - private constructor(private readonly panel: vscode.WebviewPanel, opencodeUrl: URL) { + private constructor( + private readonly panel: vscode.WebviewPanel, + opencodeUrl: URL, + ) { this.panel.webview.html = this.renderHtml(opencodeUrl); this.panel.onDidDispose(() => this.dispose(), null, this.disposables); + // Live theme bridge: editor theme changes flow extension → outer relay → + // iframe → the app's setColorScheme (boot theme rides ?colorScheme=). + vscode.window.onDidChangeActiveColorTheme( + (t) => + void this.panel.webview.postMessage({ + source: "amicode", + kind: "theme", + colorScheme: themeKindToScheme(t.kind), + }), + null, + this.disposables, + ); this.panel.webview.onDidReceiveMessage( (msg) => { // iframe → extension command bridge: the opencode "Amico" palette group @@ -34,6 +60,70 @@ export class ChatPanel { // the outer webview relay (renderHtml) forwards it here. We honor ONLY // allowlisted amicode.* commands so the framed app can't run arbitrary // vscode commands. + if ( + msg && + typeof msg === "object" && + (msg as { source?: unknown }).source === "amicode" && + (msg as { kind?: unknown }).kind === "open-external" && + typeof (msg as { url?: unknown }).url === "string" && + /^https:\/\//i.test((msg as { url: string }).url) // scheme is case-insensitive (RFC 3986) + ) { + // target=_blank/window.open are dead inside the framed app — open + // https links via the editor (system browser). https-only. + void vscode.env.openExternal(vscode.Uri.parse((msg as { url: string }).url)); + return; + } + if ( + msg && + typeof msg === "object" && + (msg as { source?: unknown }).source === "amicode" && + (msg as { kind?: unknown }).kind === "clipboard-request" + ) { + // Paste bridge: navigator.clipboard is unavailable to the framed app + // (the webview parent has no clipboard-read to delegate), so the app + // asks US — the extension host reads the OS clipboard and replies. + // Visibility gate: the app renders LLM-driven content, so a hidden + // panel must not be able to sample the clipboard in the background — + // reads only answer while the user can see the chat. + if (!this.panel.visible) return; + void vscode.env.clipboard.readText().then((text) => + this.panel.webview.postMessage({ + source: "amicode", + kind: "clipboard", + nonce: (msg as { nonce?: string }).nonce, + text, + }), + ); + return; + } + if ( + msg && + typeof msg === "object" && + (msg as { source?: unknown }).source === "amicode" && + (msg as { kind?: unknown }).kind === "save-file" && + typeof (msg as { filename?: unknown }).filename === "string" && + typeof (msg as { dataUrl?: unknown }).dataUrl === "string" + ) { + // Save bridge (run-card PNG export): downloads are dead inside the + // framed app — the extension shows a save dialog and writes the file. + // PNG-only, basename-only, bounded size: the payload is untrusted. + const raw = msg as { filename: string; dataUrl: string }; + const prefix = "data:image/png;base64,"; + const base64 = raw.dataUrl.startsWith(prefix) ? raw.dataUrl.slice(prefix.length) : undefined; + const name = path.basename(raw.filename).replace(/[^\w.-]+/g, "-"); + if (!base64 || base64.length > 24_000_000 || !name.endsWith(".png")) return; + void (async () => { + const target = await vscode.window.showSaveDialog({ + defaultUri: vscode.Uri.file(path.join(os.homedir(), "Downloads", name)), + filters: { Images: ["png"] }, + }); + if (!target) return; + await vscode.workspace.fs.writeFile(target, Buffer.from(base64, "base64")); + const pick = await vscode.window.showInformationMessage(`Amicode: saved ${path.basename(target.fsPath)}`, "Reveal"); + if (pick === "Reveal") await vscode.commands.executeCommand("revealFileInOS", target); + })(); + return; + } if ( msg && typeof msg === "object" && @@ -57,19 +147,14 @@ export class ChatPanel { ChatPanel.current.panel.reveal(vscode.ViewColumn.One); return ChatPanel.current; } - const panel = vscode.window.createWebviewPanel( - "amicode.chat", - "Amicode Chat", - vscode.ViewColumn.One, - { - enableScripts: true, - retainContextWhenHidden: true, - // The chat lives at localhost; we let the webview reach out via http://127.0.0.1 - // through normal browser networking. No localResourceRoots needed for the iframe - // itself — we only host one extension-local asset (the loading splash). - localResourceRoots: [vscode.Uri.joinPath(ctx.extensionUri, "media")], - }, - ); + const panel = vscode.window.createWebviewPanel("amicode.chat", "Amicode Chat", vscode.ViewColumn.One, { + enableScripts: true, + retainContextWhenHidden: true, + // The chat lives at localhost; we let the webview reach out via http://127.0.0.1 + // through normal browser networking. No localResourceRoots needed for the iframe + // itself — we only host one extension-local asset (the loading splash). + localResourceRoots: [vscode.Uri.joinPath(ctx.extensionUri, "media")], + }); panel.iconPath = vscode.Uri.joinPath(ctx.extensionUri, "media", "amico.svg"); ChatPanel.current = new ChatPanel(panel, opencodeUrl); return ChatPanel.current; @@ -88,6 +173,11 @@ export class ChatPanel { "connect-src 'self'", ].join("; "); const origin = JSON.stringify(opencodeUrl.origin); + // Boot theme: the app's preload reads ?colorScheme= and seeds its scheme + // storage, so the chat opens in the EDITOR's theme (prefers-color-scheme + // inside the webview iframe reports the OS, not VS Code). + const framed = new URL(opencodeUrl.href); + framed.searchParams.set("colorScheme", themeKindToScheme(vscode.window.activeColorTheme.kind)); return /* html */ ` @@ -100,7 +190,7 @@ export class ChatPanel { - +