diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 00000000..4627c288 Binary files /dev/null and b/.DS_Store differ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe3cb610..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: | @@ -70,7 +75,11 @@ 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) + 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: strategy: @@ -84,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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..8df9dd61 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,53 @@ +# Tag-triggered VSIX release. Push a `v*` tag → full package chain (amico-run + +# extension + exemplars + vendored opencode via fetch:opencode), channel-gate +# check on the vendored binary, packaging-manifest gate, then a GitHub release +# with amicode.vsix attached. +name: release +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + tag: + description: "Existing tag to package and release" + required: true + +concurrency: ${{ github.workflow }}-${{ github.ref }} + +permissions: + contents: write + +jobs: + vsix: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag || github.ref_name }} + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: { node-version: 20, cache: pnpm } + - run: pnpm install --frozen-lockfile + - run: pnpm --filter amicode-v2 run package # amico-run + build + exemplars + fetch:opencode + vsce + env: + GH_TOKEN: ${{ secrets.OPENCODE_FETCH_TOKEN }} # private-fork release fetch (see ci.yml) + - name: Gate check — vendored binary ships the amicode UI ON + run: | + set -euo pipefail + BIN=packages/extension/vendor/opencode/linux-x64/opencode + cat packages/extension/vendor/opencode/linux-x64/.source + # minified shape: `...general?.newLayoutDesigns,)` with `=!0` (on) / `=!1` (off) + VAR=$(grep -aoh 'newLayoutDesigns,[A-Za-z$_]\{1,8\})' "$BIN" | head -1 | sed 's/newLayoutDesigns,//; s/)//') + test -n "$VAR" || { echo "FAIL: gate pattern not found (minifier drift? update this check)"; exit 1; } + grep -aq "[^A-Za-z0-9_\$]${VAR}=!0" "$BIN" \ + || { echo "FAIL: channel gate OFF (${VAR}=!1) — vendored binary hides the amicode UI"; exit 1; } + echo "OK: gate ON (${VAR}=!0)" + - run: AMICODE_REQUIRE_VSIX=1 pnpm --filter amicode-v2 exec vitest run test/packaging.test.ts + - name: Create GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: | + TAG="${{ inputs.tag || github.ref_name }}" + gh release create "$TAG" --title "amicode $TAG" --generate-notes \ + packages/extension/amicode.vsix 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/.prettierrc b/.prettierrc new file mode 100644 index 00000000..6622177b --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "printWidth": 120, + "semi": true +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..77a8e29b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,80 @@ +# 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 + # (lock source=local: builds from a harmoniqs/opencode + # clone at the locked ref — sibling ../opencode, or + # AMICODE_OPENCODE_SRC / --local ; needs bun. + # No clone → falls back to the gh release download) +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 is the ACTUAL binary hash; `.source` records provenance + (`local ` or `release @`). Local-source installs always rebuild; a + release-mode run re-downloads whenever the stamp differs from the lock manifest. +- 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`. diff --git a/packages/.DS_Store b/packages/.DS_Store new file mode 100644 index 00000000..46610f70 Binary files /dev/null and b/packages/.DS_Store differ diff --git a/packages/amico-run/.DS_Store b/packages/amico-run/.DS_Store new file mode 100644 index 00000000..294803c0 Binary files /dev/null and b/packages/amico-run/.DS_Store differ 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/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/authoring.ts b/packages/amico-run/src/authoring.ts new file mode 100644 index 00000000..788046eb --- /dev/null +++ b/packages/amico-run/src/authoring.ts @@ -0,0 +1,67 @@ +// 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"; + +// NOTE (spec-20260704-113005 §3): session prep ALSO writes an additive +// `skills: [{source: "library"|"package", package?, name, description, path}]` +// array — the dual-source skill index the agent was given this session. It is a +// 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) +} + +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"); +} + +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/baseline.ts b/packages/amico-run/src/baseline.ts new file mode 100644 index 00000000..e3f59ab1 --- /dev/null +++ b/packages/amico-run/src/baseline.ts @@ -0,0 +1,42 @@ +// 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..855f0a50 --- /dev/null +++ b/packages/amico-run/src/catalog.ts @@ -0,0 +1,167 @@ +// 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/src/cli.ts b/packages/amico-run/src/cli.ts index ccbe1f64..2ef2ddc0 100644 --- a/packages/amico-run/src/cli.ts +++ b/packages/amico-run/src/cli.ts @@ -1,44 +1,140 @@ -import { existsSync } from 'node:fs' -import { join } from 'node:path' -import { LocalExecutor } from './local_executor.js' -import { ConfigError, type Finished, type SubmitOpts } from './types.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; + } +} 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) + 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 { - let script: string | undefined - let executor = 'local' - const opts: SubmitOpts = { julia: {} } + // 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; + 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(); break - case '--sysimage': opts.julia!.sysimage = 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; + } + + // ── 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, + julia_binary: opts.julia!.julia, + env_project: opts.julia!.project, + }; } - 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 } // NOTE: `--sysimage ` is honored (passed through to the Julia process and // recorded in the manifest) but amicode does NOT build one — the local @@ -47,43 +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}`); } // 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 new file mode 100644 index 00000000..ad83bfe9 --- /dev/null +++ b/packages/amico-run/src/gate.ts @@ -0,0 +1,110 @@ +// 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/src/import_scan.ts b/packages/amico-run/src/import_scan.ts new file mode 100644 index 00000000..41b7801c --- /dev/null +++ b/packages/amico-run/src/import_scan.ts @@ -0,0 +1,69 @@ +// 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/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/local_executor.ts b/packages/amico-run/src/local_executor.ts index 0ccba0fd..39da67e3 100644 --- a/packages/amico-run/src/local_executor.ts +++ b/packages/amico-run/src/local_executor.ts @@ -1,136 +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, 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, { - schema_version: '1', run_id: runId, script_path: script, - lab, lab_id: labId, created_at: createdAt, + // 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 }, - }) - 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 e7899629..5c5db6f1 100644 --- a/packages/amico-run/src/run_dir.ts +++ b/packages/amico-run/src/run_dir.ts @@ -1,92 +1,99 @@ -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' - 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; } 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)}`, `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)}`] : []), - ] - 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 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/src/subcommands.ts b/packages/amico-run/src/subcommands.ts new file mode 100644 index 00000000..c01b1263 --- /dev/null +++ b/packages/amico-run/src/subcommands.ts @@ -0,0 +1,115 @@ +// 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 { 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"]; + +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); + + // 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 = 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; + } 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); + + // 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 + // is robust to ANY stdlib an authored script imports (spec-20260704-113005 §3 + // 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 { 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; + } + + 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; +} + +/** 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/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 069d7561..86550fa6 100644 --- a/packages/amico-run/src/types.ts +++ b/packages/amico-run/src/types.ts @@ -1,36 +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. + 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 } 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 } + | { 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 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 new file mode 100644 index 00000000..e168bd83 --- /dev/null +++ b/packages/amico-run/src/verify.ts @@ -0,0 +1,58 @@ +// 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/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 new file mode 100644 index 00000000..9060e22e --- /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.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"); + }); +}); diff --git a/packages/amico-run/test/baseline.test.ts b/packages/amico-run/test/baseline.test.ts new file mode 100644 index 00000000..a0b06a87 --- /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..737022af --- /dev/null +++ b/packages/amico-run/test/catalog.test.ts @@ -0,0 +1,146 @@ +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"); + }); +}); diff --git a/packages/amico-run/test/cli.test.ts b/packages/amico-run/test/cli.test.ts index 892cc966..b814297f 100644 --- a/packages/amico-run/test/cli.test.ts +++ b/packages/amico-run/test/cli.test.ts @@ -1,66 +1,204 @@ -import { describe, it, expect, beforeAll } from 'vitest' -import { execFileSync, execFile } from 'node:child_process' -import { join } from 'node:path' -import { tmpRoot, fakeJulia } 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[]): { 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' }) - 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('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) -}) +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`); + 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`); + // 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)`); + 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 new file mode 100644 index 00000000..f73a3741 --- /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/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 new file mode 100644 index 00000000..4bbba736 --- /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); + }); +}); 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 adf7aba1..b408ba47 100644 --- a/packages/amico-run/test/run_dir.test.ts +++ b/packages/amico-run/test/run_dir.test.ts @@ -1,71 +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' + 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 - }) - 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: "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 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); + 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 0567ff55..89f6a08b 100644 --- a/packages/amico-run/test/s31.test.ts +++ b/packages/amico-run/test/s31.test.ts @@ -1,19 +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 SolveSpec, no MCP, no HTTP in the orchestrator. -const FORBIDDEN = [/SolveSpec/, /--gate\b/, /--system\b/, /--pulse\b/, - /modelcontextprotocol/i, /node:https?\b/, /\bfetch\s*\(/] +// 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', () => { - 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 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/); + }); +}); diff --git a/packages/amico-run/test/schemas.test.ts b/packages/amico-run/test/schemas.test.ts index 04296653..0e5f3ca4 100644 --- a/packages/amico-run/test/schemas.test.ts +++ b/packages/amico-run/test/schemas.test.ts @@ -1,57 +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', () => - expect(validateManifest({ ...goodManifest, schema_version: '2' }).ok).toBe(false)) -}) +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 new file mode 100644 index 00000000..6f374fae --- /dev/null +++ b/packages/amico-run/test/subcommands.test.ts @@ -0,0 +1,162 @@ +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 }); + }); + 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-")); + // 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 }); + }); +}); + +// Production-path (spec-20260704-113005 §3 defect #2): the EXACT tier-free +// resolve output (TIER3_MIN_PACKAGES) must sandbox against the BUNDLED registry, +// not a fixture that happens to carry its own [uuids]. Printf/TOML are stdlibs +// (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"); + writeFileSync( + join(dir, "authoring.json"), + JSON.stringify({ + schema_version: 1, + allowlist: ["Piccolo", "Legato", "Intonato", "NamedTrajectories", "DirectTrajOpt"], + support_set: ["JLD2", "CairoMakie", "Makie", "TOML", "Printf"], + registry, + exemplars, + verify_tolerance: 0.001, + }), + ); + return dir; + } + it("TIER3_MIN_PACKAGES sandboxes clean against the bundled registry", () => { + 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 }); + }); +}); 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 new file mode 100644 index 00000000..2b25c1e0 --- /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); + }); +}); diff --git a/packages/extension/.DS_Store b/packages/extension/.DS_Store new file mode 100644 index 00000000..ea98653f Binary files /dev/null and b/packages/extension/.DS_Store differ diff --git a/packages/extension/AGENTS.md b/packages/extension/AGENTS.md index 73093f03..c0b66fad 100644 --- a/packages/extension/AGENTS.md +++ b/packages/extension/AGENTS.md @@ -1,52 +1,253 @@ # 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. +## Voice + +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. + +- **Witty and plucky, never chummy.** Dry, confident, a little cheeky. A clean + solve earns a "Bravo — F = 0.9982 in 137 iterations," not "Great job! 🎉". No + exclamation spam, no emoji, no "as an AI assistant." +- **First person, collaborative.** "Let's try…", "we solved it", "I'd pin the + globals here." You and the user are a pair, not a form and its filler. +- **Concrete, not vague.** "Bilinear wants zero-order pulses; your script has a + spline." Never "there's a compatibility issue." +- **Opinionated, with escape hatches.** "Pin the globals (recommended) — or + co-optimize, if you fancy living dangerously." +- **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. +- **Atomic.** One question per turn, readable in two seconds. + ## 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 - 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 resolve --platform --kind --size ``` -3. Run it **detached** so the chat doesn't block on the ~minutes-long solve: + 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 - ( nohup amico-run --project --lab default /tmp/amicode-work/solve.jl \ - > /tmp/amicode-work/solve.log 2>&1 < /dev/null & ) + amico-run sandbox ~/.amico/problems/ --packages + # then run the printed JULIA_PKG_USE_CLI_GIT=true julia --project=… Pkg.instantiate() line ``` - (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///`. - -There is **no MCP server**. The only tool is `amico-run` via bash. -`amico-run --help` prints usage. +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 --spec ~/.amico/problems//solvespec.json \ + --project {{JULIA_PROJECT}} --lab default \ + ~/.amico/problems//solve.jl \ + > ~/.amico/problems//solve.log 2>&1 < /dev/null & ) + ``` + 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 the Problem +workspace — they never replace the bash launch. `amico-run --help` prints usage. + +## Answering "What can Amicode do?" + +When the user asks what Amico or 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, and I've run more of these than I +> can count. 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. +> +> **How I work (author-first):** I author a custom solve script for your problem +> and independently verify it before we trust it — you don't have to fit into a +> fixed menu. Known platforms with a **platform skill** in the `## Skill index` +> (transmon, atoms/Rydberg, …) get skill-guided authoring; with `issimo` held, +> Rydberg CZ upgrades to the **Piccolissimo free-phase CZ path**. Anything else — +> spin qubits, cavities, a gate with no template — is authored from scratch at the +> **free tier** (public packages, re-rollout-checked), honestly caveated as +> **unvetted**. Vetted templates/exemplars are accelerators and verification +> baselines, not the boundary of what I can do. + +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, +when the user asks to be walked through designing a pulse, — and **proactively**: +if a session opens with a greeting or no specific request ("hello", "who are +you?", "what is this?"), introduce yourself as Amico in one line and ask the +stage-1 PLATFORM question. If the user already knows their parameters ("X gate, +10 ns, defaults"), **skip straight to the workflow above** — never force the +interview on someone with a specific ask. The user can say "fast-forward" at +any stage to jump to defaults. + +**Protocol: ONE question at a time.** Never batch questions. Ask, wait, record, +advance. After each answer, record the stage's state: call the matching +`amicode_*` tool if it is available; if not, summarize the recorded values in +one line and continue (the tools record entities — System, Formulation, Run — +they are bookkeeping, not gates). + +**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. 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 +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: + +1. **PLATFORM** — "What kind of system are you working with?" Acknowledge whatever + the user states **as stated** — transmon, neutral-atom Rydberg, spin qubits, + cavities, anything. **Never coerce** an unfamiliar platform into a known one, + and never decline for lack of a template. Record the **actual platform string** + via `amicode_pick_system` (the arg is free-form; e.g. `platform = "spin"`). + Then route, in order: + 1. a matching **platform skill** in the `## Skill index` → skill-guided authoring; + 2. `issimo` held + a package skill applies → recommend the private path (e.g. the + 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. + - 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, + blockade on $|rr\rangle$): show the form, record `platform = "rydberg"`. When + the `## Skill index` lists `Piccolissimo/piccolissimo-authoring`, recommend the + Piccolissimo **free-phase CZ path** (skill-guided, from scratch, + `subsystem_levels=[3,3]`). Otherwise the **composed** `rydberg-cz` exemplar is + the public fallback — **experimental / not-yet-vetted**, fixed-phase + virtual-Z + scan, slow at 2 qubits (splice params into the exemplar; the gate's + masked-baseline check keeps its physics intact). Don't tell the user Rydberg is + unsupported. +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 via the vetted template). Multi-qubit + and other-platform gates are **not out of bounds** — they route through the + free-tier offer (author from scratch, unvetted, verified), 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); pass them to `amicode_solve` (it records them on the Formulation and + 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 + gate (fidelity + amplitude/bandwidth checks, then human sign-off) and the + calibration loop that follows; record interest via `amicode_to_hardware` and + `amicode_calibrate` (bookkeeping stubs — they perform NO device I/O), set no + expectations of device I/O in this build. ## Scope & parameter guidance -**Single qubit only.** The bundled template builds ONE `TransmonSystem` (scalar -`ω`/`δ`) and embeds a single-qubit target. Supported: X, Y, Z, H, S, T, √X, and -arbitrary single-qubit unitaries. **Multi-qubit gates (CNOT, CZ, iSWAP, …) are -out of scope for this single-lab build** — don't build a coupled multi-transmon -system (Piccolo's `MultiTransmonSystem` exists and would construct, but it's not -what this template or lab is set up for). If the user asks for a 2-qubit-or-larger -gate, tell them plainly it isn't supported yet and stop. +**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 +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 +model is fair game at the free tier — just honest about the tier.) **The Rydberg +CZ is the exception:** it resolves to the composed `rydberg-cz` exemplar +(2-qubit, experimental), or the Piccolissimo free-phase path when the Skill index +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. @@ -79,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 @@ -137,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 new file mode 100644 index 00000000..dd80de46 --- /dev/null +++ b/packages/extension/DISTILLER.md @@ -0,0 +1,280 @@ +# Amico Distiller + +You are Amico's **distiller** — a headless background agent. You turn finished +runs, closed sessions, and completed onboarding interviews into the user's +durable memory: problem cards, a pulse bank, a profile. You spawn **no +subagents** (depth-1: never use a task/agent tool). You are not conversational; +work silently and precisely. Your final message is a one-line summary of what +you wrote (it goes to a log, not a human). + +**Your working root is `/amicode/`** (given in the job as `vault`; the +`amicode/` dir already exists). Read and write ONLY there — e.g. +`/amicode/KNOWLEDGE.md`, `/amicode/problems/`, +`/amicode/pulses/`. **NEVER read or list the vault root** or anything +outside `amicode/` (you don't have permission and don't need it). On the very +first run `KNOWLEDGE.md` / `problems/` may be empty or absent — that just means +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 +- `{"kind":"demo","demo_dir":"/home/.../demos/","vault":"...","ops":"..."}` — write a demo card (§ Demos) +- `{"kind":"batch", ...}` — same as sweep but larger; same rules + +## Hard rules (violating any of these is failure) + +1. **Vault commits are pathspec-scoped.** After writing, commit with EXACTLY: + `git -C add amicode/ && git -C commit -m "distill:

" -- amicode/` + NEVER a bare `git commit` — the user may have unrelated files staged; they + must not ride into your commit. If there is nothing to commit, don't. +2. **Never write to opencode.db.** Read it ONLY via + `sqlite3 "file:/.local/share/opencode/opencode.db?mode=ro" "..."`. +3. **Never invent a fidelity.** A fidelity may ONLY come from a run's + `result.toml`. No result.toml ⇒ status `attempted`/`failed`, NO pulse-bank + entry, no `best_fidelity`. +4. **Profile materialization requires the completion marker.** Only an + `onboarding` job, and only when `/onboarding/events.jsonl` contains an + `onboarding_completed` entity, may write `PROFILE.md`/environment/device + cards. `run`/`sweep`/`batch` jobs NEVER touch `PROFILE.md`, `environment/`, + or `devices/` (the user stratum changes only deliberately). +5. **No secrets in the vault.** Environment cards carry pointers/endpoints + only. If an entity contains anything matching + `api[_-]?key|token|secret|password|Bearer |AKIA[0-9A-Z]{16}|-----BEGIN`, + write the card WITHOUT that value and note "credential omitted". +6. **Idempotency.** If the job's run_id (or every session_id) is already + recorded in the matching card's `best_run:`/`sessions:` frontmatter, do + nothing and exit ("no-op: already distilled"). Re-running any job twice must + produce zero diff. +7. **Match before create.** Before creating a card, read `amicode/KNOWLEDGE.md` + and the frontmatter of every file in `amicode/problems/`. If an existing + card has the same `platform` + `problem_kind` + `target` (and materially the + same system params), UPDATE that card (bump `solve_count`, `last_seen`, + `sessions`, and `best_*`/`pulse_ref` only if strictly better). Create only + on no-match. The slug is a human-readable kebab name + `-` — identity is the semantic match, not the string. +8. **Exclude yourself.** Skip any session whose `agent` column is `distiller`. + +## Token discipline (in order) + +1. Artifacts first: `//result.toml`, `run.toml`, `FINISHED`. +2. Entities second: the problem workspace's `events.jsonl` + (`~/.amico/problems//events.jsonl`) — authoritative for + system/formulation facts. Prefer entities over transcript prose. +3. Transcript LAST, and only for **lessons** (what went wrong, what the user + corrected). Read only the sessions the job names or the join (below) finds. + Precedence on conflict: result.toml > events.jsonl > transcript. + +## Joining a run to its session and workspace + +`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 +`~/.amico/problems/` path appearing in that launch command or that +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='' + AND json_extract(data,'$.type')='tool'; +-- lesson mining (assistant text + tool errors only as needed): +SELECT json_extract(data,'$.text') FROM part WHERE session_id='' + AND json_extract(data,'$.type')='text'; +``` + +## What you write (templates — follow EXACTLY) + +### Problem card → `/amicode/problems/.md` + +```markdown +--- +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 +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: + # transmon: levels (int), drive_max (float) + # cavity/bosonic: fock_cutoff (int), chi (float), alpha or fock_index + # atoms: levels, rabi_max, delta_max, distance + # Only include scalars you actually read from the artifacts; omit unknowns + # (a missing scalar makes L1 score medium, never high — fail-safe). +--- + +# on + +## System + + + +## Formulation + + + +## History + +- solves , , F ∈ [, ]. + +## Lessons + +- +``` + +### Pulse bank entry → `/amicode/pulses/-v/` + +Copy the run's `pulse.jld2` into the entry dir. Write `metadata.toml`: + +```toml +id = "x-gate-transmon-v1" +platform = "transmon" +gate = "X" # FIXED key, always present; "" for state_prep +fidelity = 0.99995 +duration_us = 0.010 +pulse_type = "linear" +N_knots = 50 +free_phase = false +path = "pulses/x-gate-transmon-v1/pulse.jld2" +branch = "" +warm_start = "" +tags = ["amicode", "distilled"] +date = "2026-07-03" +# additive (amicode-specific): +problem_kind = "gate_synthesis" +target = "" # state name for state_prep; "" for gates +problem_ref = "problems/x-gate-transmon.md" +run_id = "r20260703-095831Z-e5b7" +``` + +New `-v` ONLY when fidelity strictly improves on the card's +`best_fidelity` or the formulation materially changed. Otherwise no new binary. + +### `KNOWLEDGE.md` line (insert newest-first; update in place on card update; cap 50 lines) + +```markdown +- [x-gate-transmon](problems/x-gate-transmon.md) — transmon gate X, solved 8×, + best F=0.99995, pulse: x-gate-transmon-v1 +- [cat-state-transmon-cavity](problems/cat-state-transmon-cavity.md) — cavity-transmon + state_prep, ATTEMPTED (launch failed: no solvespec), no pulse yet +``` + +### Onboarding materialization (only per Hard rule 4) + +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 +- Goals: +- Onboarded: (re-run onboarding to update) +``` + +- `environment` entity (`slug`,`archetype`,`control_stack`,`integration`, + `emulator`,`endpoints`) → `environment/.md`: + +```markdown +--- +type: amicode-environment +slug: +archetype: +control_stack: "" +integration: "" +emulator: +endpoints: [] +status: active +--- +``` + +- `device` entity (`name`,`platform`,`environment`,`qubits`,`params`,`status`) + → `devices/.md`: + +```markdown +--- +type: amicode-device +name: +platform: +environment: environment/.md +qubits: +status: known +calibration: null +allocation: null +--- + + +``` + +## Demos (kind: `demo`) — the expert corpus (L1 §3) + +A `demo` job points at a curated demo directory (`demo_dir`). Write a **demo +card** — same shape as a problem card but `source: demo`, `status: reference` — +into `/amicode/demos/.md`, and add a line to `/amicode/DEMOS.md` +(a SEPARATE index from KNOWLEDGE.md). + +Reading the demo, in order: (1) a `demo.toml` if present (entry script + +metadata); else (2) the demo's `README`; else (3) docstrings of the +representative script. **Representative script** for a many-script dir: the one +named after the demo (`.jl` / `main.jl` / `optimize_*.jl`), else the one +importing the platform skill's system builder, else the largest top-level script. + +**Honesty:** never invent a fidelity or a param. If nothing readable yields +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 +source: demo +status: reference +platform: cavity +problem_kind: state_prep +target: cat-state +fidelity: +script: +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 +``` + +Match/idempotency: a `demo` job for a `demo_dir` already carded (same slug) with +no change is a no-op. Demo cards use the same 3-tuple identity as problem cards +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)`. 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 new file mode 100644 index 00000000..abb3d6cc --- /dev/null +++ b/packages/extension/TESTING.md @@ -0,0 +1,69 @@ +# 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: 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. + +## 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/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/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/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/julia/make_verify_golden.jl b/packages/extension/julia/make_verify_golden.jl new file mode 100644 index 00000000..4f7e8bc8 --- /dev/null +++ b/packages/extension/julia/make_verify_golden.jl @@ -0,0 +1,72 @@ +#!/usr/bin/env julia +# Golden-fixture generator for verify_rollout.jl (spec-20260704-113005 §4/§9). +# Builds a REAL 2-subsystem EmbeddedOperator with UNEQUAL levels [2,3] (a qubit ⊗ +# a qutrit), rolls out an unsolved trajectory, and writes a run dir whose reported +# fidelity is computed with the rydberg EXEMPLAR's explicit virtual-Z convention. +# The harness must reproduce it from pulse_dense.jld2 using its GENERAL +# binary-decomposition builder — so agreement validates that builder against the +# shipped convention, on unequal levels. No solve (fast); the point is the phase +# convention + dense precedence + serialization round-trip, not gate quality. +# Usage: julia --project= make_verify_golden.jl +using Piccolo +using JLD2, TOML +using LinearAlgebra + +function main(out_dir::String) + mkpath(out_dir) + + # qubit (2) ⊗ qutrit (3), dim 6 + σx2 = ComplexF64[0 1; 1 0]; σy2 = ComplexF64[0 -im; im 0]; I2 = Matrix{ComplexF64}(I, 2, 2) + σx3 = ComplexF64[0 1 0; 1 0 0; 0 0 0]; σy3 = ComplexF64[0 -im 0; im 0 0; 0 0 0]; I3 = Matrix{ComplexF64}(I, 3, 3) + Hx = kron(σx2, I3) + kron(I2, σx3) + Hy = kron(σy2, I3) + kron(I2, σy3) + H_drift = zeros(ComplexF64, 6, 6) + drive_bounds = [(-1.0, 1.0), (-1.0, 1.0)] + sys = QuantumSystem(H_drift, [Hx, Hy], drive_bounds) + + # CZ on {|0⟩,|1⟩}⊗{|0⟩,|1⟩} embedded in [2,3] = 6-dim. + op = EmbeddedOperator(GATES[:CZ], [1, 2], [1:2, 1:2], [2, 3]) + + N = 21 + times = collect(range(0.0, 1.0, length = N)) + # deterministic init (no randn) — the trajectory is saved, so the harness + # re-rolls this exact one either way. + initial = 0.1 .* [sin(2π * (k + d) / N) for d = 1:sys.n_drives, k = 1: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) + traj = get_trajectory(qcp) # initial-guess trajectory (NOT solved) + + # both artifacts saved; harness prefers pulse_dense.jld2 (same knots here — the + # golden tests convention + precedence, not spline resampling). + JLD2.save(joinpath(out_dir, "pulse.jld2"), "traj", traj) + JLD2.save(joinpath(out_dir, "pulse_dense.jld2"), "traj", traj) + + Uroll = iso_vec_to_operator(unitary_rollout(traj, sys)[:, end]) + + # reported free-phase fidelity. unitary_fidelity restricts BOTH args to + # `subspace`, so the goal must be FULL-space with the virtual-Z applied to its + # computational block (the exemplar's bare 4×4 form BoundsErrors — that report + # block never ran; the exemplar never solved). The phase vector here is written + # EXPLICITLY in basis order [00,01,10,11] — independent of the harness's general + # binary-decomposition builder, so agreement validates that builder. + φ1, φ2 = 0.3, -0.9 + sub = collect(op.subspace) + goal = copy(op.operator) + goal[sub, sub] = Diagonal(ComplexF64[1, exp(im * φ2), exp(im * φ1), exp(im * (φ1 + φ2))]) * goal[sub, sub] + fid = unitary_fidelity(Uroll, goal; subspace = sub) + + JLD2.save(joinpath(out_dir, "system_verify.jld2"), + "goal_kind", "unitary", "goal", op.operator, "subspace", collect(op.subspace), + "H_drift", H_drift, "H_drives", [Hx, Hy], "drive_bounds", drive_bounds) + + open(joinpath(out_dir, "result.toml"), "w") do io + TOML.print(io, Dict( + "schema_version" => "1", "fidelity" => fid, "iterations" => 0, + "pulse_kind" => "spline", "fidelity_convention" => "free_phase", + "free_phases" => [φ1, φ2], "subsystem_levels" => [2, 3])) + end + println("GOLDEN out=$(out_dir) reported=$(fid)") +end + +main(ARGS[1]) diff --git a/packages/extension/julia/verify_rollout.jl b/packages/extension/julia/verify_rollout.jl new file mode 100644 index 00000000..a0920aec --- /dev/null +++ b/packages/extension/julia/verify_rollout.jl @@ -0,0 +1,99 @@ +#!/usr/bin/env julia +# Amicode verification harness (spec C + spec-20260704-113005 §6) — FIXED, VETTED +# ASSET. Usage: julia --project= verify_rollout.jl [tolerance] +# +# Independently re-checks a run's reported fidelity by re-rolling its pulse with +# Piccolo's NATIVE unitary_rollout + unitary_fidelity — the same idiom as the +# vetted template tail and the rydberg exemplar report block. The independence +# the trust chain needs is from the AUTHORED SCRIPT's optimizer transcription, +# NOT from Piccolo: no custom integration code by design (Aaron directive +# 2026-07-03). Shipped with the extension, never model-authored. +# +# spec-20260704-113005 §6 additions: +# - pulse_kind dispatch: a "spline" solve MUST emit pulse_dense.jld2 (a densely +# resampled trajectory) — re-rolling the sparse knots as PWC hits the +# spline-vs-bare-rollout cliff. Precedence: dense present → re-roll dense; +# pulse_kind="spline" but dense absent → FAIL CLOSED (missing_dense_pulse); +# otherwise the plain pulse.jld2 path (unchanged). +# - free-phase: when fidelity_convention="free_phase", apply the optimized +# per-qubit virtual-Z phases POST-HOC to the goal (never in dynamics), using +# Piccolo's exact convention — the phase on computational basis |b₁…b_n⟩ is +# Σ_j φ[j]·b_j (binary decomposition; rollouts_extensions.jl:906-914, matching +# the rydberg exemplar's Diagonal([1,e^{iφ₂},e^{iφ₁},e^{i(φ₁+φ₂)}])·gate). +using JLD2, TOML +using Piccolo +using LinearAlgebra + +function write_verification(run_dir::String, fields::Dict) + open(joinpath(run_dir, "verification.toml.tmp"), "w") do io + TOML.print(io, fields) + end + mv(joinpath(run_dir, "verification.toml.tmp"), joinpath(run_dir, "verification.toml"); force = true) +end + +# Per-qubit virtual-Z phase diagonal over the computational subspace, in the +# subspace's basis order (standard binary: |0…0⟩, …, |1…1⟩). Matches +# Rollouts.fidelity(qtraj; phases) and the rydberg exemplar's virtual-Z scan. +function free_phase_diag(phases::Vector{Float64}, n_sub::Int) + nq = length(phases) + return ComplexF64[ + exp(im * sum(phases[j] for j = 1:nq if ((i - 1) >> (nq - j)) & 1 == 1; init = 0.0)) + for i = 1:n_sub + ] +end + +function main(run_dir::String, tol::Float64) + result = try TOML.parsefile(joinpath(run_dir, "result.toml")) catch; Dict{String,Any}() end + pulse_kind = get(result, "pulse_kind", "pwc") + dense_path = joinpath(run_dir, "pulse_dense.jld2") + + # ── pulse-artifact precedence (spec §6) ── + local traj, integrator_tag + if isfile(dense_path) + traj = JLD2.load(dense_path, "traj") + integrator_tag = "piccolo_unitary_rollout_dense" + elseif pulse_kind == "spline" + write_verification(run_dir, Dict( + "schema_version" => "1", "agree" => false, + "fidelity_rerolled" => "nan", "fidelity_reported" => get(result, "fidelity", "nan"), + "tolerance" => tol, "integrator" => "none", + "error" => "missing_dense_pulse", + "checked_at" => string(round(Int, time())))) + println("VERIFY agree=false error=missing_dense_pulse") + return + else + traj = JLD2.load(joinpath(run_dir, "pulse.jld2"), "traj") + integrator_tag = "piccolo_unitary_rollout" + end + + sv = JLD2.load(joinpath(run_dir, "system_verify.jld2")) + sys = QuantumSystem(sv["H_drift"], sv["H_drives"], sv["drive_bounds"]) + + fid = if sv["goal_kind"] == "unitary" + Uroll = iso_vec_to_operator(unitary_rollout(traj, sys)[:, end]) + sub = haskey(sv, "subspace") ? collect(Int, sv["subspace"]) : nothing + goal = sv["goal"] + # ── free-phase post-hoc (spec §6): phases NEVER enter dynamics ── + if get(result, "fidelity_convention", "fixed") == "free_phase" && sub !== nothing + phases = Float64.(result["free_phases"]) + goal = copy(goal) + goal[sub, sub] = Diagonal(free_phase_diag(phases, length(sub))) * goal[sub, sub] + end + sub === nothing ? unitary_fidelity(Uroll, goal) : unitary_fidelity(Uroll, goal; subspace = sub) + else + ψroll = rollout(sv["initial_state"], traj, sys)[:, end] + fidelity(ψroll, sv["goal"]) + end + + reported = try Float64(result["fidelity"]) catch; NaN end + agree = isfinite(reported) && abs(fid - reported) <= tol + write_verification(run_dir, Dict( + "schema_version" => "1", + "fidelity_rerolled" => fid, "fidelity_reported" => reported, + "tolerance" => tol, "agree" => agree, + "integrator" => integrator_tag, + "checked_at" => string(round(Int, time())))) + println("VERIFY agree=$(agree) rerolled=$(fid) reported=$(reported)") +end + +main(ARGS[1], length(ARGS) >= 2 ? parse(Float64, ARGS[2]) : 0.001) 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 @@ - - - - - - + + + + + + + + + + + + + + + + + + diff --git a/packages/extension/media/brand.css b/packages/extension/media/brand.css index 12c0790d..fc55f1a3 100644 --- a/packages/extension/media/brand.css +++ b/packages/extension/media/brand.css @@ -1,10 +1,20 @@ /* brand.css — style variables only. Prefer --vscode-* (inherit the theme); * literals are reserved for brand identity. */ +/* JuliaMono — Amicode's brand mono (matches the app, AMICODE-PATCHES #14). + * url() resolves relative to this stylesheet's webview URI; needs the CSP + * font-src grant (run_inspector.ts) + localResourceRoots covering media/. */ +@font-face { + font-family: "JuliaMono"; + src: url("ui/atoms/JuliaMono-Regular.woff2") format("woff2"); + font-weight: 400; + font-display: swap; +} + :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); @@ -12,7 +22,7 @@ /* text */ --text-font: var(--vscode-font-family); - --text-mono: var(--vscode-editor-font-family, monospace); + --text-mono: "JuliaMono", var(--vscode-editor-font-family, monospace); --text-body: var(--vscode-font-size, 12px); --text-label: 0.72em; --text-small: 0.8em; diff --git a/packages/extension/media/layout.css b/packages/extension/media/layout.css index baf2942c..3b7fcb86 100644 --- a/packages/extension/media/layout.css +++ b/packages/extension/media/layout.css @@ -1,13 +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); } -.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); } +* { + 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); +} diff --git a/packages/extension/media/ui/atoms/JuliaMono-Regular.woff2 b/packages/extension/media/ui/atoms/JuliaMono-Regular.woff2 new file mode 100644 index 00000000..69819592 Binary files /dev/null and b/packages/extension/media/ui/atoms/JuliaMono-Regular.woff2 differ diff --git a/packages/extension/media/ui/atoms/button.ts b/packages/extension/media/ui/atoms/button.ts new file mode 100644 index 00000000..1febcf5f --- /dev/null +++ b/packages/extension/media/ui/atoms/button.ts @@ -0,0 +1,38 @@ +// Button atom — a compact control button. Disabled state is a property here. + +import { defineStyle } from "../style"; + +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); + border: var(--border-width) solid var(--border-color); + border-radius: var(--border-radius); padding: var(--space-xs) var(--space-sm); + 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; + enable(on: boolean): void; +} + +export function button(label: string, onClick: () => void): ButtonAtom { + const el = document.createElement("button"); + el.className = "btn"; + el.type = "button"; + el.textContent = label; + 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 index 6df3b640..e6d15be8 100644 --- a/packages/extension/media/ui/components/catalogcard.ts +++ b/packages/extension/media/ui/components/catalogcard.ts @@ -16,7 +16,9 @@ import { metric } from "./metric"; import { chip, type ChipFields } from "./chip"; import { pulseplot, type PulsePlotMeta, type PulsePlotRecord } from "./pulseplot"; -defineStyle("catalogcard", ` +defineStyle( + "catalogcard", + ` .catalogcard { display: flex; flex-direction: column; gap: var(--space-md); padding: var(--space-lg); min-width: 0; background: var(--bg-box); @@ -48,7 +50,8 @@ defineStyle("catalogcard", ` 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 { @@ -61,12 +64,20 @@ export interface CatalogEntry { 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 }; + 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 CardPulse { + meta: PulsePlotMeta; + record: PulsePlotRecord; +} export interface CatalogCard { el: HTMLDivElement; @@ -101,8 +112,10 @@ export function catalogcard(entry: CatalogEntry, opts: CatalogCardOpts = {}): Ca // 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); + 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) { @@ -143,7 +156,9 @@ export function catalogcard(entry: CatalogEntry, opts: CatalogCardOpts = {}): Ca 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)] : []), + ...(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("/")), @@ -198,7 +213,7 @@ function metricsPanel(entry: CatalogEntry, pulse?: CardPulse): HTMLDivElement { // 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, { hero: true }); + const m = metric(label, { variant: "hero" }); m.value(value); if (proposed) m.el.classList.add("proposed"); return m.el; @@ -245,8 +260,10 @@ function spectralBandwidth(pulse?: CardPulse): number | undefined { 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 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); @@ -260,7 +277,7 @@ function spectralBandwidth(pulse?: CardPulse): number | undefined { 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 + const f = (k + 1) / (n * dt); // bin k+1 → frequency if (worst === undefined || f > worst) worst = f; break; } diff --git a/packages/extension/media/ui/components/chip.ts b/packages/extension/media/ui/components/chip.ts index 24336e7b..7ecd0b7e 100644 --- a/packages/extension/media/ui/components/chip.ts +++ b/packages/extension/media/ui/components/chip.ts @@ -9,7 +9,9 @@ import { defineStyle } from "../style"; import { text } from "../atoms/text"; -defineStyle("chip", ` +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); @@ -19,7 +21,8 @@ defineStyle("chip", ` .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; diff --git a/packages/extension/media/ui/components/metric.ts b/packages/extension/media/ui/components/metric.ts index a0cb3e68..d4866221 100644 --- a/packages/extension/media/ui/components/metric.ts +++ b/packages/extension/media/ui/components/metric.ts @@ -3,15 +3,23 @@ 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); display: flex; flex-direction: column; gap: var(--space-xs); } .metric .v { font-family: var(--text-mono); font-size: var(--text-value); } - .metric.hero { border-color: var(--border-color-hero); } - .metric.hero .v { font-size: var(--text-hero); font-weight: 600; } -`); + /* counter = a compact integer (iteration): no growth, tight. */ + .metric-counter { flex: 0 0 auto; } + /* 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"; export interface Metric { el: HTMLDivElement; @@ -20,9 +28,9 @@ export interface Metric { clear(): void; } -export function metric(labelText: string, opts: { hero?: boolean } = {}): Metric { +export function metric(labelText: string, opts: { variant?: MetricVariant } = {}): Metric { const el = document.createElement("div"); - el.className = opts.hero ? "metric hero" : "metric"; + el.className = `metric metric-${opts.variant ?? "small"}`; const l = text("label-k", labelText); const v = text("v", "–"); el.append(l.el, v.el); 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 new file mode 100644 index 00000000..110aab90 --- /dev/null +++ b/packages/extension/media/ui/components/sparkline.ts @@ -0,0 +1,85 @@ +// Sparkline — a tiny log-y line of the objective trend on the hero. The ring +// buffer is a pure export (unit-tested in node); the SVG render needs a DOM. + +import { defineStyle } from "../style"; + +defineStyle( + "sparkline", + ` + .sparkline { display: block; margin-top: var(--space-xs); } +`, +); + +const SVGNS = "http://www.w3.org/2000/svg"; + +/** Bounded ring buffer, newest last. Pure — no DOM. */ +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; + }, + }; +} + +export interface Sparkline { + el: SVGSVGElement; + update(v: number): void; + reset(): void; +} + +export function sparkline(capacity = 60): Sparkline { + const buf = makeSparkBuffer(capacity); + 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)); + svg.setAttribute("height", String(H)); + svg.classList.add("sparkline"); + const poly = document.createElementNS(SVGNS, "polyline"); + poly.setAttribute("fill", "none"); + poly.setAttribute("stroke", "var(--color-accent)"); + poly.setAttribute("stroke-width", "1.5"); + svg.append(poly); + + const render = () => { + // 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; + } + const logs = vs.map((v) => Math.log10(v)); + 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); + return `${x.toFixed(1)},${y.toFixed(1)}`; + }); + poly.setAttribute("points", pts.join(" ")); + }; + + return { + el: svg, + update(v: number) { + buf.push(v); + render(); + }, + reset() { + buf.reset(); + render(); + }, + }; +} diff --git a/packages/extension/media/ui/control-state.ts b/packages/extension/media/ui/control-state.ts new file mode 100644 index 00000000..86e58f80 --- /dev/null +++ b/packages/extension/media/ui/control-state.ts @@ -0,0 +1,26 @@ +// Pure enablement logic for the Run Inspector control row. No DOM — unit-tested +// in node; the view applies the result to button .disabled flags. + +export type ControlStatus = "idle" | "warming" | "running" | "completed" | "failed" | "stopped"; + +export interface ControlEnablement { + /** Stop only makes sense while the solve is live. */ + stop: boolean; + /** Save needs pulse.jld2, which exists once ≥1 iteration/pulse has landed + * (hasData) or the run has finished. */ + save: boolean; + /** Open the run dir whenever there is a run at all. */ + open: boolean; +} + +export function controlEnablement(status: ControlStatus, hasData: boolean): ControlEnablement { + const live = status === "warming" || status === "running"; + // A converged/stopped run has a pulse.jld2 worth saving; a failed run only if + // it actually emitted data before dying. + const producedPulse = status === "completed" || status === "stopped"; + return { + stop: live, + save: hasData || producedPulse, + open: status !== "idle", + }; +} diff --git a/packages/extension/media/ui/style.ts b/packages/extension/media/ui/style.ts index 9f46f52c..dbd5e2be 100644 --- a/packages/extension/media/ui/style.ts +++ b/packages/extension/media/ui/style.ts @@ -7,6 +7,9 @@ const registered = new Set(); export function defineStyle(key: string, css: string): void { if (registered.has(key)) return; registered.add(key); + // No-op outside a browser (node/vitest): lets view modules be imported for + // unit tests of their pure exports without a DOM / constructable-stylesheet. + if (typeof document === "undefined" || typeof CSSStyleSheet === "undefined") return; const sheet = new CSSStyleSheet(); sheet.replaceSync(css); document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet]; diff --git a/packages/extension/media/ui/views/inspector.ts b/packages/extension/media/ui/views/inspector.ts index 9dfb4446..52cf193c 100644 --- a/packages/extension/media/ui/views/inspector.ts +++ b/packages/extension/media/ui/views/inspector.ts @@ -14,10 +14,16 @@ import { defineStyle } from "../style"; import { mark } from "../atoms/icon"; import { pill } from "../atoms/pill"; import { text } from "../atoms/text"; +import { button } from "../atoms/button"; import { metric } from "../components/metric"; import { pulseplot } from "../components/pulseplot"; +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; } @@ -25,7 +31,8 @@ defineStyle("inspector-view", ` 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."; @@ -42,18 +49,27 @@ export interface InspectorView { 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(): 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); - const hero = metric("objective", { hero: true }); - const iteration = metric("iteration"); - const feasibility = metric("feasibility"); - const optimality = metric("optimality"); - const metrics = [hero, iteration, feasibility, optimality]; - let gotPulse = false; // per-pane: decides the completed-without-data hint + // Layout order (left→right): ITER counter · OBJECTIVE hero · FEAS · OPT. + const iteration = metric("iteration", { variant: "counter" }); + const hero = metric("objective", { variant: "hero" }); + const feasibility = metric("feasibility", { variant: "small" }); + const optimality = metric("optimality", { variant: "small" }); + const metrics = [iteration, hero, feasibility, optimality]; + + // Convergence sparkline lives inside the hero, under the objective value. + const spark = sparkline(); + hero.el.append(spark.el); + + let gotPulse = false; // per-pane: decides the completed-without-data hint const brand = document.createElement("div"); brand.className = "row gap-sm brand"; @@ -65,53 +81,156 @@ function createPanel(): Panel { topbar.append(brand, runLabel.el, status.el); const grid = document.createElement("div"); - grid.className = "grid-fit"; + grid.className = "metric-row"; grid.append(...metrics.map((m) => m.el)); + // 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", 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); + + let controlStatus: ControlStatus = "idle"; + let hasData = false; + const applyControls = () => { + const e = controlEnablement(controlStatus, hasData); + stopBtn.enable(e.stop); + saveBtn.enable(e.save); + openBtn.enable(e.open); + }; + applyControls(); + + // Elapsed / rate / ETA strip. Ticks 1 Hz while running from run.toml + // created_at; freezes at result.toml wall_seconds on finish. + const timing = text("mono small dim"); + let createdAtMs: number | undefined; + let maxIter: number | undefined; + let latestIter = 0; + const iterStamps: number[] = []; // arrival times → rate + let tick: ReturnType | undefined; + const clearTick = () => { + if (tick) { + clearInterval(tick); + tick = undefined; + } + }; + const renderTiming = () => { + if (createdAtMs === undefined) { + timing.set(""); + return; + } + const parts = [`elapsed ${formatElapsed((Date.now() - createdAtMs) / 1000)}`]; + const r = ratePerSec(iterStamps); + if (r !== undefined) { + parts.push(`${r.toFixed(1)} it/s`); + const eta = computeEta({ iter: latestIter, maxIter, ratePerSec: r }); + if (eta !== undefined) parts.push(`ETA ~${formatElapsed(eta)}`); + } + timing.set(parts.join(" · ")); + }; + + const footer = document.createElement("div"); + footer.className = "row wrap"; + footer.append(timing.el, controls); + const el = document.createElement("div"); el.className = "pane stack pad-lg scroll-y"; el.style.height = "100vh"; - el.append(topbar, pulse.el, grid); + el.append(topbar, pulse.el, grid, footer); 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": runLabel.set(String(msg.text ?? "")); break; - case "iteration": + case "timing": { + if (msg.terminal) { + clearTick(); + createdAtMs = undefined; + if (typeof msg.wallSeconds === "number") timing.set(`elapsed ${formatElapsed(msg.wallSeconds)}`); + break; + } + if (typeof msg.createdAtMs === "number") createdAtMs = msg.createdAtMs; + if (typeof msg.maxIter === "number") maxIter = msg.maxIter; + renderTiming(); + if (!tick) tick = setInterval(renderTiming, 1000); + break; + } + case "iteration": { hero.label("objective"); hero.value((msg.f_val as number).toExponential(4)); iteration.value(String(msg.iter)); 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(); + latestIter = msg.iter as number; + iterStamps.push(Date.now()); + if (iterStamps.length > 12) iterStamps.shift(); + renderTiming(); + spark.update(msg.f_val as number); break; + } 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 + spark.reset(); break; case "completed": { const ok = msg.status === "completed"; - status.set(ok ? "done" : "failed", ok ? "converged" : String(msg.status)); + const stopped = msg.status === "stopped"; + // stopped = graceful user stop (neutral, dim); completed = success; + // anything else = failure. + status.set(ok ? "done" : stopped ? "idle" : "failed", ok ? "converged" : String(msg.status)); + // Promote the hero card to the final fidelity — the number that matters. if (ok && typeof msg.fidelity === "number") { hero.label("fidelity"); hero.value((msg.fidelity as number).toFixed(5)); } - if (!gotPulse) pulse.waiting(NO_DATA_HINT); + 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 as number, knots: msg.knots as number, labels: msg.labels as string[], bounds: msg.bounds as [number, number][] }); + 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 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 as number, dt: msg.dt as number, values: msg.values as number[][] }); break; } @@ -134,7 +253,7 @@ export function createInspectorView(post: (msg: unknown) => void): InspectorView const panelFor = (runId: string): Panel => { let p = panels.get(runId); if (!p) { - p = createPanel(); + p = createPanel(post, runId); panels.set(runId, p); el.append(p.el); } @@ -144,16 +263,25 @@ 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); - if (!panels.has(runId)) panelFor(runId).el.classList.add("active"); // pane may arrive before data + 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; } + 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; 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 00000000..c6f9a5e7 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_AMS-Regular.ttf differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_AMS-Regular.woff b/packages/extension/media/vendor/katex/fonts/KaTeX_AMS-Regular.woff new file mode 100644 index 00000000..b804d7b3 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_AMS-Regular.woff differ 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 00000000..0acaaff0 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Caligraphic-Bold.ttf b/packages/extension/media/vendor/katex/fonts/KaTeX_Caligraphic-Bold.ttf new file mode 100644 index 00000000..9ff4a5e0 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Caligraphic-Bold.ttf differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff b/packages/extension/media/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff new file mode 100644 index 00000000..9759710d Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 b/packages/extension/media/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 new file mode 100644 index 00000000..f390922e Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 differ 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 00000000..f522294f Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Caligraphic-Regular.ttf differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff b/packages/extension/media/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff new file mode 100644 index 00000000..9bdd534f Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff differ 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 00000000..75344a1f Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 differ 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 00000000..4e98259c Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Fraktur-Bold.ttf differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff b/packages/extension/media/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff new file mode 100644 index 00000000..e7730f66 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff differ 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 00000000..395f28be Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Fraktur-Regular.ttf b/packages/extension/media/vendor/katex/fonts/KaTeX_Fraktur-Regular.ttf new file mode 100644 index 00000000..b8461b27 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Fraktur-Regular.ttf differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff b/packages/extension/media/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff new file mode 100644 index 00000000..acab069f Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 b/packages/extension/media/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 new file mode 100644 index 00000000..735f6948 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Main-Bold.ttf b/packages/extension/media/vendor/katex/fonts/KaTeX_Main-Bold.ttf new file mode 100644 index 00000000..4060e627 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Main-Bold.ttf differ 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 00000000..f38136ac Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Main-Bold.woff differ 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 00000000..ab2ad21d Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Main-Bold.woff2 differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Main-BoldItalic.ttf b/packages/extension/media/vendor/katex/fonts/KaTeX_Main-BoldItalic.ttf new file mode 100644 index 00000000..dc007977 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Main-BoldItalic.ttf differ 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 00000000..67807b0b Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 b/packages/extension/media/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 new file mode 100644 index 00000000..5931794d Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 differ 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 00000000..0e9b0f35 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Main-Italic.ttf differ 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 00000000..6f43b594 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Main-Italic.woff differ 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 00000000..b50920e1 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Main-Italic.woff2 differ 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 00000000..dd45e1ed Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Main-Regular.ttf differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Main-Regular.woff b/packages/extension/media/vendor/katex/fonts/KaTeX_Main-Regular.woff new file mode 100644 index 00000000..21f58129 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Main-Regular.woff differ 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 00000000..eb24a7ba Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Main-Regular.woff2 differ 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 00000000..728ce7a1 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Math-BoldItalic.ttf differ 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 00000000..0ae390d7 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff differ 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 00000000..29657023 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 differ 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 00000000..70d559b4 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Math-Italic.ttf differ 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 00000000..eb5159d4 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Math-Italic.woff differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Math-Italic.woff2 b/packages/extension/media/vendor/katex/fonts/KaTeX_Math-Italic.woff2 new file mode 100644 index 00000000..215c143f Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Math-Italic.woff2 differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Bold.ttf b/packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Bold.ttf new file mode 100644 index 00000000..2f65a8a3 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Bold.ttf differ 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 00000000..8d47c02d Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff differ 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 00000000..cfaa3bda Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 differ 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 00000000..d5850df9 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Italic.ttf differ 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 00000000..7e02df96 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 b/packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 new file mode 100644 index 00000000..349c06dc Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Regular.ttf b/packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Regular.ttf new file mode 100644 index 00000000..537279f6 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Regular.ttf differ 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 00000000..31b84829 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff differ 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 00000000..a90eea85 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 differ 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 00000000..fd679bf3 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Script-Regular.ttf differ 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 00000000..0e7da821 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Script-Regular.woff differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Script-Regular.woff2 b/packages/extension/media/vendor/katex/fonts/KaTeX_Script-Regular.woff2 new file mode 100644 index 00000000..b3048fc1 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Script-Regular.woff2 differ 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 00000000..871fd7d1 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Size1-Regular.ttf differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Size1-Regular.woff b/packages/extension/media/vendor/katex/fonts/KaTeX_Size1-Regular.woff new file mode 100644 index 00000000..7f292d91 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Size1-Regular.woff differ 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 00000000..c5a8462f Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Size2-Regular.ttf b/packages/extension/media/vendor/katex/fonts/KaTeX_Size2-Regular.ttf new file mode 100644 index 00000000..7a212caf Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Size2-Regular.ttf differ 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 00000000..d241d9be Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Size2-Regular.woff differ 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 00000000..e1bccfe2 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Size3-Regular.ttf b/packages/extension/media/vendor/katex/fonts/KaTeX_Size3-Regular.ttf new file mode 100644 index 00000000..00bff349 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Size3-Regular.ttf differ 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 00000000..e6e9b658 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Size3-Regular.woff differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 b/packages/extension/media/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 new file mode 100644 index 00000000..249a2866 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 differ 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 00000000..74f08921 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Size4-Regular.ttf differ 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 00000000..e1ec5457 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Size4-Regular.woff differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 b/packages/extension/media/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 new file mode 100644 index 00000000..680c1308 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 differ 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 00000000..c83252c5 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Typewriter-Regular.ttf differ 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 00000000..2432419f Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff differ diff --git a/packages/extension/media/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 b/packages/extension/media/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 new file mode 100644 index 00000000..771f1af7 Binary files /dev/null and b/packages/extension/media/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 differ diff --git a/packages/extension/media/vendor/katex/katex.min.css b/packages/extension/media/vendor/katex/katex.min.css new file mode 100644 index 00000000..317de128 --- /dev/null +++ b/packages/extension/media/vendor/katex/katex.min.css @@ -0,0 +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: 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 new file mode 100644 index 00000000..dc97916c --- /dev/null +++ b/packages/extension/opencode-plugin/amicode_tools.ts @@ -0,0 +1,905 @@ +// ============================================================================ +// 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` / `./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. +// +// 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 (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 path from "node:path"; +import { + systemToml, + formulationToml, + runStubToml, + deviceSessionStubToml, + calibrationStubToml, + updateSystem, + validateSystem, + validateFormulation, + entityDiff, + truncateDiffForSentinel, + KNOWN_PLATFORMS, + MAX_LEVELS, + type SystemEntity, + type FormulationEntity, + type RunStub, + type DeviceSessionStub, + type CalibrationStub, +} from "./entities"; +import { entityHash } from "./hashes"; +import { + ensureActiveProblem, + readActiveSlug, + problemsDir, + problemDir, + writeEntityFiles, + appendEvent, + appendRunRef, + createProblem, + openProblem, + renameProblem, + archiveProblem, + listProblems, + lastEventSeq, + migrateLegacyEntities, +} from "./problems"; +import { guardAndRecordStage, completeStage } from "./score_guard"; +import { + onboardingStreamDir, + isOnboardingEntity, + appendOnboardingEvent, + statusSummary, + triggerOnboardingDistill, +} from "./onboarding"; + +// 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 v1 (problems → " + problemsDir() + ")"); + +// 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 + * "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 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 T; + } catch { + return undefined; + } +} + +/** 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) }); +} + +/** 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 +// "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 = + "Good news, with an asterisk: Rydberg solve authoring IS wired. When the ## Skill " + + "index lists `Piccolissimo/piccolissimo-authoring`, recommend the Piccolissimo " + + "free-phase CZ path (skill-guided, from scratch, subsystem_levels=[3,3]) — free-phase " + + "is the honest primary metric for entangling gates. Otherwise the composed `rydberg-cz` " + + "exemplar is the public fallback (experimental, not-yet-vetted, fixed-phase + virtual-Z " + + "scan; the 2-qubit CZ is a touch sluggish in the current Piccolo path). Either way, be " + + "honest about the tier — and do NOT tell the user Rydberg is unsupported, because it isn't."; + +// The plugin: exactly one export (see header). opencode calls it on session +// creation with PluginInput; we need nothing from it today. +export const AmicodeTools = async (_input: unknown) => ({ + tool: { + amicode_ask: { + description: + "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: { + type: "string", + description: "The single question to ask.", + }, + options: { + type: "array", + items: { type: "string" }, + description: "2-6 short option labels, one per button.", + }, + details: { + // Optional nullable array. The registry's legacyJsonSchema strips the + // "null" and marks the field optional, yielding a clean singular-typed + // schema every provider accepts (a raw nullable array is what Gemini + // rejected). execute() also tolerates absent/empty defensively. + 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; 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() !== "") : []; + 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) + return "Cannot ask: details must be one per option (or omitted)."; + // 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. 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.` + ); + }, + }, + + 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. " + + "Platform is free-form — known platforms (" + + KNOWN_PLATFORMS.join(", ") + + ") get built-in affordances; others are recorded honestly. Bookkeeping only.", + args: { + platform: { + type: "string", + 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 applicable/known.", + }, + delta: { + type: ["number", "null"], + 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; 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; + // 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 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 ( + `Transmon it is — ${levelsDesc}, ${paramsSummary(params)}. Filed under "${meta.slug}".\n\n` + + `Model Hamiltonian:\n${TRANSMON_LATEX}\n\n` + + `Show this to the user and confirm it matches their device.\n\n${sentinel}` + ); + } + if (a.platform === "rydberg") { + return ( + `Rydberg, ${levelsDesc}, ${paramsSummary(params)} — noted and filed under "${meta.slug}".\n\n` + + `Model: ${RYDBERG_DESC}\n\n${RYDBERG_SCOPE_NOTE}\n\n${sentinel}` + ); + } + // Author-first / open intake (spec-20260704-113005 §5). Any platform is + // acknowledged AS STATED (recorded here with the actual string). No vetted + // template ≠ decline: offer free-tier from-scratch authoring, honest that + // it is unvetted and that every result is independently re-rolled before + // we trust it. (This return string previously said "I won't improvise an + // unvetted script" — the exact tool output that declined the spin-CX ask.) + return ( + `${a.platform}, ${levelsDesc}, ${paramsSummary(params)} — noted and filed under "${meta.slug}".\n\n` + + `No vetted template for ${a.platform} in this build. That's fine — I can author a ` + + `from-scratch script for it against the public stack (unvetted), and every result is ` + + `independently re-checked (re-rolled) before we trust it. If a platform skill for ` + + `${a.platform} is listed in the Skill index, I'll follow it; otherwise I'll build from ` + + `first principles and flag it honestly. Want me to proceed?\n\n${sentinel}` + ); + }, + }, + + 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 levels to model (>=2, 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 meta = ensureActiveProblem(); + const dir = problemDir(meta.slug); + const blocked = guardAndRecordStage(problemsDir(), dir, "model"); + if (blocked) return blocked; + 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; + try { + const merged = updateSystem(existing, { + levels: given(a.levels) ? a.levels : undefined, + params: patchParams, + }); + 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 `Tweaked — ${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)}`; + } + }, + }, + + 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, 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.', + }, + objective: { + type: ["string", "null"], + 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)"].', + }, + }, + async execute(a: { problem: string; target: string; objective?: string | null; constraints?: string[] | null }) { + 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: + 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); + if (problems.length) return `Cannot record formulation: ${problems.join("; ")}`; + const sentinel = recordEntity(meta.slug, "formulation", entity as any, formulationToml(entity), { + tool: "amicode_formulate", + stage: "formulate", + }); + completeStage(dir, "formulate"); + return ( + `Formulation's locked for "${meta.slug}" — ${entity.problem}, targeting ${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), 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 …`). " + + "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; + 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, "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 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 `Solve knobs set for "${meta.slug}".${warn}${runWarn}\n\n${sentinel}`; + }, + }, + + 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's in for "${meta.slug}". ${verdict}\n\n${sentinel}`; + }, + }, + + 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 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 sentinel: string; + try { + 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)}`; + } + 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, ` + + `|drive| ≤ amplitude cap, bandwidth within hardware limits, leakage bounded; ` + + `(2) a human eyeballs the pulse and signs off before anything ships. ` + + `Full disclosure — this build touches no real silicon, so this is a promissory note, not a live session.\n\n${sentinel}` + ); + }, + }, + + 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 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 { + 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 sentinel: string; + try { + 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)}`; + } + const warn = stub.device_session_ref + ? "" + : " Note: no device session recorded yet — amicode_to_hardware comes first."; + return ( + `Calibration follow-up on the books for "${meta.slug}" (loop: ILC, status: not-wired).${warn}\n\n` + + `Once hardware runs, an ILC loop (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 on paper. In this build ` + + `that loop's a recorded follow-up only — nothing actually fires here yet.\n\n${sentinel}` + ); + }, + }, + // ── Onboarding (spec-20260705-002847 §3) — NOT a problem stage: UNGATED + // (no guardAndRecordStage), writes the ops-side onboarding stream, never the + // vault. The distiller materializes the cards on the completion marker. + amicode_profile: { + description: + "Record onboarding entities during the overture interview (session zero), and read them " + + "back to resume. Entities: `profile` {name, role, org, platforms[], goals}; " + + "`environment` {slug, archetype: qick-lab|cloud-pasqal|local-sim|other, control_stack, " + + "integration, emulator, endpoints[] — POINTERS ONLY, never credentials}; " + + "`device` {name, platform, environment, qubits, params, status}; and " + + "`onboarding_completed` {} — record it EXACTLY ONCE, at the handoff stage (it is what " + + "lets the background distiller materialize the user's profile). " + + "Pass `status` as the entity to read back everything recorded so far — call that FIRST " + + "when the overture starts, and resume from it (ask only what's missing).", + args: { + entity: { + type: "string", + description: "profile | environment | device | onboarding_completed | status", + }, + payload: { + type: ["object", "null"], + description: "The entity's fields (see tool description). Pass null for status / onboarding_completed.", + }, + }, + async execute(a: { entity: string; payload?: Record | null }) { + try { + const dir = onboardingStreamDir(); + if (a.entity === "status") return statusSummary(dir); + if (!isOnboardingEntity(a.entity)) { + return `Cannot record "${a.entity}" — valid entities: profile, environment, device, onboarding_completed, status.`; + } + const { seq, clean } = appendOnboardingEvent(dir, a.entity, a.payload ?? {}); + if (a.entity === "onboarding_completed") { + const spawned = triggerOnboardingDistill(); + return ( + `Onboarding complete (event ${seq}). ` + + (spawned + ? "Your profile is being materialized in the background — the next session opens personalized." + : "Profile materialization queued (distiller transport not armed yet — it runs on the next drain).") + ); + } + const fields = Object.keys(clean).join(", ") || "(empty)"; + return `Recorded ${a.entity} (event ${seq}): ${fields}.`; + } catch (err) { + return `Cannot record onboarding entity: ${err instanceof Error ? err.message : String(err)}`; + } + }, + }, + // ── Recommendations (spec-20260705-024340 L1) — advisory, UNGATED. Records + // WHY a parameter is what it is (value + confidence + cited provenance), and + // the accept/override outcome. The value still LANDS via amicode_set_model / + // amicode_formulate; this annotates the decision so it's inspectable and so + // L2 (Veloce) has a machine-readable confidence to act on. + amicode_recommend: { + description: + "Record a parameter recommendation and its outcome (L1). Two actions: " + + "action=`propose` logs {stage, param, value, confidence: high|medium|low, " + + "provenance:[{source: own-precedent|demo|physics|default, ref, note}], alternatives?} — " + + "confidence is MECHANICAL per scores/memory/confidence-rubric.md (never a guess); " + + "action=`outcome` logs {stage, param, outcome: accepted|overridden, applied_value} AFTER " + + "the value lands via set_model/formulate (append-only pair, keyed on stage+param). " + + "No active problem workspace yet → a no-op receipt (recommendations begin at the problem stage).", + args: { + action: { type: "string", description: "propose | outcome" }, + stage: { type: "string", description: "interview stage, e.g. model | formulate" }, + 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", + }, + 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)", + }, + }, + async execute(a: { + action: string; + stage?: string; + param?: string; + value?: unknown; + confidence?: string | null; + provenance?: unknown[] | null; + alternatives?: unknown[] | null; + outcome?: string | null; + applied_value?: unknown; + auto_accepted?: boolean | null; + }) { + try { + const slug = readActiveSlug(); + 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, { + entity: "recommendation", + action: "outcome", + diff: { key, stage: a.stage, param: a.param, outcome: a.outcome, applied_value: a.applied_value }, + source: { tool: "amicode_recommend", stage: a.stage }, + }); + return `Recorded outcome for ${key}: ${a.outcome} (applied ${JSON.stringify(a.applied_value)}) [event ${seq}].`; + } + // default: propose + const seq = appendEvent(slug, { + entity: "recommendation", + action: "proposed", + diff: { + key, + stage: a.stage, + param: a.param, + value: a.value, + confidence: a.confidence, + provenance: a.provenance ?? [], + ...(a.alternatives ? { alternatives: a.alternatives } : {}), + // Veloce (L2): an auto-accept records auto_accepted AND outcome:accepted + // in one step (spec L2 §5). + ...(a.auto_accepted ? { auto_accepted: true, outcome: "accepted", applied_value: a.value } : {}), + }, + 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 auto = a.auto_accepted ? " ⚡auto" : ""; + return `Recommended ${a.param}=${JSON.stringify(a.value)} (${a.confidence ?? "?"}, via ${prov})${auto} [event ${seq}].`; + } catch (err) { + return `Cannot record recommendation: ${err instanceof Error ? err.message : String(err)}`; + } + }, + }, + // ── Veloce (spec-20260705-024341 L2) — records the autonomy-mode transition. + // The policy itself (auto-accept HIGH-confidence downstream params; resource + // gates always confirm; interrupt-off) is prompt-level in SCORE.md; this tool + // makes the mode durable + inspectable (⚡ badge) and returns current state. + amicode_veloce: { + description: + "Turn Amico Veloce on/off, or read its state. Veloce auto-accepts HIGH-confidence " + + "downstream recommendations (never system params, never past a resource gate — those " + + "always confirm). action=`on` {reason: explicit|offered|persisted}, action=`off` " + + "{reason: interrupt|explicit}, action=`status` returns current mode. Record `off, " + + "reason:interrupt` the moment the user corrects a value, asks a question, or says stop.", + args: { + action: { type: "string", description: "on | off | status" }, + reason: { type: ["string", "null"], description: "explicit | offered | persisted | interrupt" }, + }, + async execute(a: { action: string; reason?: string | null }) { + try { + const slug = readActiveSlug(); + if (!slug) return "No active problem yet — veloce state not recorded."; + if (a.action === "status") { + // Latest veloce event wins. + const file = path.join(problemDir(slug), "events.jsonl"); + let mode = "off"; + try { + for (const line of fs.readFileSync(file, "utf8").split("\n")) { + if (!line.trim()) continue; + const e = JSON.parse(line); + if (e.entity === "veloce" && e.diff?.mode) mode = e.diff.mode; + } + } catch { + /* no events yet */ + } + return `Veloce is ${mode}.`; + } + const mode = a.action === "on" ? "on" : "off"; + const seq = appendEvent(slug, { + entity: "veloce", + action: "transition", + diff: { mode, reason: a.reason ?? "explicit" }, + source: { tool: "amicode_veloce" }, + }); + return mode === "on" + ? `⚡ Veloce ON (${a.reason ?? "explicit"}) — I'll auto-accept high-confidence choices and still confirm before any solve [event ${seq}].` + : `Veloce OFF (${a.reason ?? "explicit"}) — back to asking each step [event ${seq}].`; + } catch (err) { + return `Cannot set veloce: ${err instanceof Error ? err.message : String(err)}`; + } + }, + }, + }, +}); diff --git a/packages/extension/opencode-plugin/distill_queue.ts b/packages/extension/opencode-plugin/distill_queue.ts new file mode 100644 index 00000000..d5d32f31 --- /dev/null +++ b/packages/extension/opencode-plugin/distill_queue.ts @@ -0,0 +1,249 @@ +/** Distill job queue + the single global distiller lock (spec-20260705-002847 §4.1). + * + * Shared by BOTH runtimes: the extension (node) and the opencode plugin (Bun) — + * so: node:fs/node:path only, no vscode, no Bun APIs. + * + * Lock protocol (reviewer-hardened, 4 rounds): + * - claim = `mkdir distiller.lock` (atomic, EEXIST on contention — NEVER + * rename-onto-destination, which silently replaces an existing lock) + * - reclaim = rename the stale dir ASIDE (only one reclaimer's rename can + * succeed), then a normal mkdir claim — never remove-then-mkdir + * - drain = winner processes the ENTIRE queue sequentially, releases, then + * RE-CHECKS the queue and re-claims if non-empty (closes the race where a + * loser's job lands after the final listing but before the release) + * - a failing job is set aside as `.failed-` (kept for inspection, not + * retried forever), and the drain continues */ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { execFile } from "node:child_process"; + +export const STALE_LOCK_MS = 15 * 60 * 1000; +/** One distill job gets at most 10 min of LLM time before it's set aside. */ +export const JOB_TIMEOUT_MS = 10 * 60 * 1000; + +export interface DistillJob { + kind: "run" | "sweep" | "onboarding" | "batch"; + [key: string]: unknown; +} + +export interface DrainClock { + pid: number; + now: () => number; + isPidAlive: (pid: number) => boolean; +} + +function queueDir(opsDir: string): string { + return path.join(opsDir, "distill-queue"); +} +function lockDir(opsDir: string): string { + return path.join(opsDir, "distiller.lock"); +} + +let enqueueCounter = 0; + +export function enqueueJob(opsDir: string, job: DistillJob): string { + const dir = queueDir(opsDir); + fs.mkdirSync(dir, { recursive: true }); + const name = `${Date.now()}-${String(enqueueCounter++).padStart(4, "0")}-${job.kind}.json`; + const file = path.join(dir, name); + fs.writeFileSync(file, JSON.stringify(job, null, 2) + "\n"); + return file; +} + +export function listJobs(opsDir: string): string[] { + try { + return fs + .readdirSync(queueDir(opsDir)) + .filter((f) => f.endsWith(".json")) + .sort() + .map((f) => path.join(queueDir(opsDir), f)); + } catch { + return []; + } +} + +export function queueIsEmpty(opsDir: string): boolean { + return listJobs(opsDir).length === 0; +} + +export function claimLock(opsDir: string, pid: number): boolean { + fs.mkdirSync(opsDir, { recursive: true }); + try { + fs.mkdirSync(lockDir(opsDir)); // no recursive: must fail EEXIST on contention + } catch { + return false; + } + fs.writeFileSync(path.join(lockDir(opsDir), "owner"), JSON.stringify({ pid, ts: Date.now() }) + "\n"); + return true; +} + +export function releaseLock(opsDir: string): void { + try { + fs.rmSync(lockDir(opsDir), { recursive: true, force: true }); + } catch { + /* released is released */ + } +} + +/** 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 { + let owner: { pid: number; ts: number }; + try { + owner = JSON.parse(fs.readFileSync(path.join(lockDir(opsDir), "owner"), "utf8")); + } catch { + return false; // no lock (or unreadable — leave it to the 15-min clock) + } + if (clock.now - owner.ts <= STALE_LOCK_MS) return false; + if (clock.isPidAlive(owner.pid)) return false; + const aside = `${lockDir(opsDir)}.stale-${pid}-${clock.now}`; + try { + fs.renameSync(lockDir(opsDir), aside); // atomic: exactly one reclaimer wins the source + } catch { + return false; // someone else already renamed it aside + } + return claimLock(opsDir, pid); +} + +/** Process every queued job in order. A job file is removed only after its + * handler resolves; a throwing handler sets the job aside as .failed-. */ +export async function drainOnce(opsDir: string, handler: (job: DistillJob) => Promise): Promise { + let n = 0; + for (const file of listJobs(opsDir)) { + let job: DistillJob; + try { + job = JSON.parse(fs.readFileSync(file, "utf8")); + } catch { + fs.renameSync(file, `${file}.failed-${Date.now()}`); + continue; + } + try { + await handler(job); + fs.unlinkSync(file); + n++; + } catch { + try { + fs.renameSync(file, `${file}.failed-${Date.now()}`); + } catch { + /* leave it */ + } + } + } + return n; +} + +/** The distiller spawn transport (spec §4 config transport, plan reviewer #5): + * `distiller.config.json` — written by the extension at activation, read by + * every spawner (extension trigger, plugin trigger-4, batch shell) so all + * three produce identical distiller processes. */ +export interface DistillerConfigFile { + /** Absolute path of the opencode binary to spawn. */ + binary: string; + /** The OPENCODE_CONFIG_CONTENT object for the distiller variant. */ + config: Record; + /** Merged into every job by spawners that don't know the paths themselves + * (the Bun plugin, the batch shell): vault, ops, runs_root. */ + job_defaults?: Record; +} + +export function distillerConfigPath(opsDir: string): string { + return path.join(opsDir, "distiller.config.json"); +} + +export function readDistillerConfig(opsDir: string): DistillerConfigFile | null { + try { + const parsed = JSON.parse(fs.readFileSync(distillerConfigPath(opsDir), "utf8")); + if (typeof parsed.binary === "string" && parsed.config) return parsed; + return null; + } catch { + return null; + } +} + +export function writeDistillerConfig(opsDir: string, file: DistillerConfigFile): void { + fs.mkdirSync(opsDir, { recursive: true }); + fs.writeFileSync(distillerConfigPath(opsDir), JSON.stringify(file, null, 2) + "\n"); +} + +/** Run ONE distill job as a headless opencode child (`run --agent distiller`), + * awaited — the drain loop is deterministic code; only the job itself is LLM. */ +export function runDistillerJob( + cfg: DistillerConfigFile, + job: DistillJob, + timeoutMs: number = JOB_TIMEOUT_MS, +): Promise<{ code: number; output: string }> { + return new Promise((resolve, reject) => { + execFile( + cfg.binary, + ["run", "--agent", "distiller", JSON.stringify(job)], + { + env: { ...process.env, OPENCODE_CONFIG_CONTENT: JSON.stringify(cfg.config) }, + timeout: timeoutMs, + maxBuffer: 4 * 1024 * 1024, + }, + (err, stdout, stderr) => { + const output = `${stdout}\n${stderr}`.trim(); + if (err && (err as { code?: unknown }).code !== 0) { + reject(new Error(`distiller job failed (${(err as { code?: unknown }).code}): ${output.slice(-500)}`)); + } else { + resolve({ code: 0, output }); + } + }, + ); + }); +} + +/** Convenience used by every trigger: enqueue, then drain the queue through + * headless distiller children. No-ops (returns false) when the transport + * config hasn't been written yet — the job stays queued for the next drain. */ +export async function enqueueAndDrain(opsDir: string, job: DistillJob, clock: DrainClock): Promise { + enqueueJob(opsDir, job); + const cfg = readDistillerConfig(opsDir); + if (!cfg) return false; + return runDrainLoop(opsDir, (j) => runDistillerJob(cfg, j).then(() => undefined), clock); +} + +export function defaultClock(): DrainClock { + return { + pid: process.pid, + now: () => Date.now(), + isPidAlive: (pid: number) => { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } + }, + }; +} + +/** Full winner protocol: claim (or reclaim stale) → drain → release → + * post-release re-check → re-claim if jobs landed during the handoff. + * Returns false immediately if we lost the claim (a holder will drain). */ +export async function runDrainLoop( + opsDir: string, + handler: (job: DistillJob) => Promise, + clock: DrainClock, +): Promise { + if ( + !claimLock(opsDir, clock.pid) && + !reclaimIfStale(opsDir, clock.pid, { now: clock.now(), isPidAlive: clock.isPidAlive }) + ) { + return false; + } + // We hold the lock. + for (;;) { + while (!queueIsEmpty(opsDir)) { + await drainOnce(opsDir, handler); + } + releaseLock(opsDir); + if (queueIsEmpty(opsDir)) return true; // post-release re-check: truly done + if (!claimLock(opsDir, clock.pid)) return true; // a new holder owns the late jobs + } +} diff --git a/packages/extension/opencode-plugin/entities.ts b/packages/extension/opencode-plugin/entities.ts new file mode 100644 index 00000000..29afb950 --- /dev/null +++ b/packages/extension/opencode-plugin/entities.ts @@ -0,0 +1,481 @@ +// ============================================================================ +// 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 { + /** 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 { + problem: string; + target: string; + objective: string; + constraints: string[]; + /** Solve params (spec A) — present once amicode_solve has recorded them. */ + solve?: SolveParams; +} + +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; + /** 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; + /** 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; +} + +/** 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 + * 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; +} + +/** 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. 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 (typeof e.platform !== "string" || e.platform.trim() === "") { + problems.push(`platform must be a non-empty string`); + } + 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)) { + 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 ?? {}) }, + }; + 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; +} + +// --- 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)}`]; + 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"; +} + +/** 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))}`, + ]; + // [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"; +} + +/** 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)}`); + 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))}`); + 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"; +} + +// --- 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() === "") { + throw new Error(`${name} must be non-empty when given — omit it (null) if unknown`); + } +} + +/** The stage-8 send-to-device gate's automated checks — fixed, not caller data: + * they describe what the gate WILL verify, not what happened (nothing happens + * in this build). Human visual sign-off follows the auto checks. */ +const HARDWARE_CHECKS = ["fidelity>=threshold", "|drive|<=cap", "bandwidth", "leakage"] as const; + +/** Serialize the DeviceSession stub under [device_session]. `gate` is pinned to + * "pending-human-signoff" and `checks` to HARDWARE_CHECKS (see interface note). */ +export function deviceSessionStubToml(stub: DeviceSessionStub, now?: Date): string { + requireNonEmptyRef("pulse_ref", stub.pulse_ref); + requireNonEmptyRef("run_dir", stub.run_dir); + const lines = ["[device_session]"]; + if (stub.pulse_ref !== undefined) lines.push(`pulse_ref = ${tomlEscape(stub.pulse_ref)}`); + if (stub.run_dir !== undefined) lines.push(`run_dir = ${tomlEscape(stub.run_dir)}`); + lines.push(`gate = ${tomlEscape("pending-human-signoff")}`); + lines.push(`checks = [${HARDWARE_CHECKS.map(tomlEscape).join(", ")}]`); + if (stub.note !== undefined) lines.push(`note = ${tomlEscape(stub.note)}`); + lines.push(`recorded = ${tomlEscape(isoNow(now))}`); + return lines.join("\n") + "\n"; +} + +/** Serialize the Calibration stub under [calibration]. `loop` is pinned to "ILC" + * (iterative learning control — the loop that follows hardware runs) and + * `status` to "not-wired": this build records the follow-up, nothing more. */ +export function calibrationStubToml(stub: CalibrationStub, now?: Date): string { + requireNonEmptyRef("device_session_ref", stub.device_session_ref); + const lines = ["[calibration]"]; + if (stub.device_session_ref !== undefined) { + lines.push(`device_session_ref = ${tomlEscape(stub.device_session_ref)}`); + } + lines.push(`loop = ${tomlEscape("ILC")}`); + lines.push(`status = ${tomlEscape("not-wired")}`); + if (stub.note !== undefined) lines.push(`note = ${tomlEscape(stub.note)}`); + lines.push(`recorded = ${tomlEscape(isoNow(now))}`); + return lines.join("\n") + "\n"; +} diff --git a/packages/extension/opencode-plugin/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/opencode-plugin/onboarding.ts b/packages/extension/opencode-plugin/onboarding.ts new file mode 100644 index 00000000..41f75e78 --- /dev/null +++ b/packages/extension/opencode-plugin/onboarding.ts @@ -0,0 +1,148 @@ +/** Onboarding entity stream (spec-20260705-002847 §3.1–3.2). + * + * The overture interview NEVER writes the vault: it records profile / + * environment / device entities here (ops-side, append-only), and the + * DISTILLER materializes the cards — only when the `onboarding_completed` + * marker is present. Same JSON envelope as the problem-workspace events. */ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { entityHash } from "./hashes"; +import { enqueueAndDrain, defaultClock, readDistillerConfig } from "./distill_queue"; + +export function opsDir(): string { + return process.env.AMICODE_OPS_DIR ?? path.join(os.homedir(), ".amico", "amicode"); +} +export function onboardingStreamDir(ops: string = opsDir()): string { + return path.join(ops, "onboarding"); +} + +/** Spec §7.8 / §2.5: pointers only, enforced AT RECORD TIME (extend, never shrink). */ +export const SECRET_RE = /api[_-]?key|token|secret|password|Bearer |AKIA[0-9A-Z]{16}|-----BEGIN/; + +export type OnboardingEntity = "profile" | "environment" | "device" | "onboarding_completed"; + +const ENTITY_FIELDS: Record = { + profile: ["name", "role", "org", "platforms", "goals"], + environment: ["slug", "archetype", "control_stack", "integration", "emulator", "endpoints"], + device: ["name", "platform", "environment", "qubits", "params", "status"], + onboarding_completed: [], +}; + +export function isOnboardingEntity(e: string): e is OnboardingEntity { + return e in ENTITY_FIELDS; +} + +/** Keep only the §3.2 fields; scrub credential-looking values (never store, + * never echo — replace so the card notes the omission). */ +export function sanitizePayload(entity: OnboardingEntity, payload: Record): Record { + const out: Record = {}; + for (const f of ENTITY_FIELDS[entity]) { + if (!(f in payload) || payload[f] === undefined || payload[f] === null) continue; + let v = payload[f]; + if (typeof v === "string" && SECRET_RE.test(v)) v = "«credential omitted»"; + if (Array.isArray(v)) v = v.map((x) => (typeof x === "string" && SECRET_RE.test(x) ? "«credential omitted»" : x)); + out[f] = v; + } + return out; +} + +function lastSeq(file: string): number { + try { + return fs + .readFileSync(file, "utf8") + .split("\n") + .filter((l) => l.trim()).length; + } catch { + return 0; + } +} + +/** Append one event (spec §3.2 envelope: seq/ts/entity/action/diff/hash/source). */ +export function appendOnboardingEvent( + dir: string, + entity: OnboardingEntity, + payload: Record, +): { seq: number; clean: Record } { + fs.mkdirSync(dir, { recursive: true }); + const file = path.join(dir, "events.jsonl"); + const clean = sanitizePayload(entity, payload); + const seq = lastSeq(file) + 1; + const diff: Record = {}; + for (const [k, v] of Object.entries(clean)) diff[k] = { from: null, to: v }; + const event = { + seq, + ts: new Date().toISOString(), + entity, + action: "created", + diff, + hash: entityHash(clean), + source: { tool: "amicode_profile", stage: "onboarding" }, + provenance: null, + }; + fs.appendFileSync(file, JSON.stringify(event) + "\n"); + return { seq, clean }; +} + +/** Replay the stream → latest state per entity instance (later entries win — + * environments keyed by slug, devices by name, profile is a singleton). + * This is what the overture's RESUME reads (spec §3: ask only what's missing). */ +export function readOnboardingState(dir: string): { + profile?: Record; + environments: Record>; + devices: Record>; + completed: boolean; +} { + const state = { + profile: undefined as Record | undefined, + environments: {} as Record>, + devices: {} as Record>, + completed: false, + }; + let text: string; + try { + text = fs.readFileSync(path.join(dir, "events.jsonl"), "utf8"); + } catch { + return state; + } + for (const line of text.split("\n")) { + if (!line.trim()) continue; + let ev: { entity?: string; diff?: Record }; + try { + ev = JSON.parse(line); + } catch { + continue; + } + const vals: Record = {}; + for (const [k, d] of Object.entries(ev.diff ?? {})) vals[k] = d.to; + if (ev.entity === "profile") state.profile = { ...(state.profile ?? {}), ...vals }; + else if (ev.entity === "environment" && typeof vals.slug === "string") + state.environments[vals.slug] = { ...(state.environments[vals.slug] ?? {}), ...vals }; + else if (ev.entity === "device" && typeof vals.name === "string") + state.devices[vals.name] = { ...(state.devices[vals.name] ?? {}), ...vals }; + else if (ev.entity === "onboarding_completed") state.completed = true; + } + return state; +} + +/** One line per recorded thing — the tool's `status` answer (resume anchor). */ +export function statusSummary(dir: string): string { + const s = readOnboardingState(dir); + const lines: string[] = []; + if (s.profile) lines.push(`profile: ${JSON.stringify(s.profile)}`); + for (const [slug, e] of Object.entries(s.environments)) lines.push(`environment ${slug}: ${JSON.stringify(e)}`); + for (const [name, d] of Object.entries(s.devices)) lines.push(`device ${name}: ${JSON.stringify(d)}`); + lines.push(s.completed ? "onboarding: COMPLETED (re-run updates in place)" : "onboarding: in progress"); + return lines.length === 1 ? `nothing recorded yet\n${lines[0]}` : lines.join("\n"); +} + +/** Trigger 4 (spec §4.1): on the completion marker, the PLUGIN both enqueues + * AND spawns a drain via the distiller.config.json transport — fire-and-forget + * so the tool return never waits on an LLM child. Without the transport file + * the job simply stays queued for the next drain. */ +export function triggerOnboardingDistill(ops: string = opsDir()): boolean { + const cfg = readDistillerConfig(ops); + const defaults = (cfg && (cfg as { job_defaults?: Record }).job_defaults) ?? {}; + void enqueueAndDrain(ops, { kind: "onboarding", ops, ...defaults }, defaultClock()).catch(() => {}); + return cfg !== null; +} diff --git a/packages/extension/opencode-plugin/problems.ts b/packages/extension/opencode-plugin/problems.ts new file mode 100644 index 00000000..cc0c087a --- /dev/null +++ b/packages/extension/opencode-plugin/problems.ts @@ -0,0 +1,298 @@ +// ============================================================================ +// 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 }; +} + +/** 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 { + 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 --------------------------------------------------------------- + +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/opencode-plugin/score_guard.ts b/packages/extension/opencode-plugin/score_guard.ts new file mode 100644 index 00000000..db4d5f10 --- /dev/null +++ b/packages/extension/opencode-plugin/score_guard.ts @@ -0,0 +1,188 @@ +// ============================================================================ +// 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 (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). 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 +// 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. + * 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(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(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) { + 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(stateDir, { kind: "stage_entered", ts: new Date().toISOString(), stage: stageId }); + } + state.stage_cursor = stageId; + saveScoreState(stateDir, 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/opencode.lock.json b/packages/extension/opencode.lock.json index aeb4282b..1942726b 100644 --- a/packages/extension/opencode.lock.json +++ b/packages/extension/opencode.lock.json @@ -1,7 +1,17 @@ { "version": "1.17.3", + "repo": "harmoniqs/opencode", + "tag": "v1.17.3-amicode.4", + "source": "local", + "ref": "e9b695191120365f7592a7885308bb333b85df0f", "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": "035a62a156b60c72c4127a421bd793d4a51f286947a36b9f55c53623f483aba4" + }, + "linux-x64": { + "asset": "opencode-linux-x64.tar.gz", + "sha256": "8d64f58f00eb5c1f4969c90ea4085e87153985b86a9aa2f3328e999ca8179102" + } } } diff --git a/packages/extension/package.json b/packages/extension/package.json index 3c52b323..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" @@ -84,6 +88,22 @@ "command": "amicode.replayDemo", "title": "Amicode: Replay demo run" }, + { + "command": "amicode.stopRun", + "title": "Amicode: Stop current solve" + }, + { + "command": "amicode.savePulse", + "title": "Amicode: Save pulse from current run" + }, + { + "command": "amicode.openRunDir", + "title": "Amicode: Open current run directory" + }, + { + "command": "amicode.distillNow", + "title": "Amicode: Distill now (update my memory)" + }, { "command": "amicode.catalog.remove", "title": "Remove from Catalog" @@ -92,11 +112,21 @@ "configuration": { "title": "Amicode", "properties": { + "amicode.inspector.autoOpen": { + "type": "boolean", + "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", "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": "", @@ -111,6 +141,50 @@ "type": "string", "default": "", "description": "Path to the lab.toml hardware profile, validated on load. Empty = ~/.amico/lab.toml (where install.sh writes the starter)." + }, + "amicode.skillRoots": { + "type": "array", + "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" + }, + "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" + }, + "default": [], + "description": "Roots for the central platform-skill library. Empty = ~/harmoniqs/amico-plugin/skills." + }, + "amicode.vaultDir": { + "type": "string", + "default": "", + "description": "Personal Armonia vault for the user-memory substrate (profile, problem cards, pulse bank). Empty = auto-resolve the first kind=personal mount under ~/.amico/vaults." + }, + "amicode.distillerModel": { + "type": "string", + "default": "opencode/big-pickle", + "description": "Model (providerID/modelID) for the headless distiller agent. Pinned separately from the chat model so distillation survives provider rate limits." + }, + "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." + }, + "amicode.chat.autoOpen": { + "type": "boolean", + "default": true, + "description": "Open the Amicode chat automatically once the opencode server is ready." } } }, @@ -138,8 +212,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": { @@ -153,5 +228,8 @@ "smol-toml": "^1.3.0", "typescript": "^5.6.0", "vitest": "^2.1.0" + }, + "dependencies": { + "yaml": "^2.9.0" } -} \ No newline at end of file +} diff --git a/packages/extension/scores/README.md b/packages/extension/scores/README.md new file mode 100644 index 00000000..d0573b51 --- /dev/null +++ b/packages/extension/scores/README.md @@ -0,0 +1,88 @@ +# The repertoire — authoring scores + +A **score** is a packaged guided path: the interview a user rides from intent to a +result. Scores are **content, not code** — adding a user path means adding a +directory here; the runtime never changes. (Naming: Amico is the conductor; it +performs scores; this directory is its repertoire.) + +``` +scores/ + entitlements.toml # registered entitlement ids (typos fail CI) + memory/.md # bundled [Why?] hook content, shared across scores + / + SCORE.md # the manifest (YAML frontmatter) + Amico's voice (body) + templates/*.jl|*.py # vetted templates the score's stages instantiate +``` + +## SCORE.md anatomy + +Frontmatter = structure (what the runtime, UI, lint, and tests read). +Body = prose (per-stage narration, physics, defaults rationale, off-path guidance). + +```yaml +--- +type: score +schema_version: 1 # supported: 1; unknown FIELDS are ignored (additive policy) +id: my-score # directory name must match +version: 1 # bump on revision; in-flight sessions stay pinned to theirs +derived_from: null # or a sibling score id — lineage for forks +name: "Shown on the entry card" +outcome: "What the user will HAVE at the end" +audience: [algorithms, no-physics-assumed] +duration_estimate: "60–90 min" +device: { backend: pasqal, qpu_runnable: true, emulators: [emu-mps] } # optional +entitlements: [] # empty/absent = public; ids must be in entitlements.toml +stages: + - id: application # ordered list; loopbacks OK, no DAGs (v1) + emits: + [circuit] # ONLY workflow-frames entities: circuit, system, + # formulation, pulse, run, device_session, knowledge + questions: + - id: graph + prompt: "Which graph?" + choices: [sample, upload] # choices → rendered as amicode_ask buttons + default: sample # must be one of choices; marked "(recommended)" + skip_if: "mode == simulate" # optional + memory_hooks: [some-slug] # optional; must resolve to memory/.md + - id: solve + emits: [run, pulse] + executor: cloud-altissimo # or local + template: templates/solve.jl # resolved relative to the score dir; must exist + - id: device-qpu + emits: [device_session] + gate: heavy # light|heavy — checks must pass BEFORE entering + optional: true +--- +[Amico's voice for this score — markdown + LaTeX, carried verbatim into the prompt] +``` + +## Rules the lint enforces (`pnpm --filter amicode-v2 test -- repertoire_lint`) + +- manifest validates (schema_version supported, version ≥ 1, no duplicate stages, + defaults ∈ choices, `emits` only known entities, `gate` only known classes) +- every `template` resolves inside the score dir +- every `memory_hooks` slug resolves to `scores/memory/.md` +- `derived_from` is null or an existing score id +- every entitlement id is registered in `entitlements.toml` +- new files ship in the .vsix (`test/packaging.test.ts`) + +## How it runs ("data-defined, prompt-executed") + +At session prep, `prepareOpencodeProject` loads the repertoire, filters it by the +user's entitlements (no code → public scores only; failures fall back to public — +never a dead end), compiles the selected score + the onset router into the injected +AGENTS.md, and writes `score_manifest.json` for the Bun-side plugin. The `amicode_*` +tools enforce stage order and gates against that manifest (entity dependencies +block; conversational stages don't) and record `interview_state.json` + +`usage.jsonl` — the funnel data future learned-traversal work consumes. + +If score loading fails, the runtime falls back to the hardcoded interview section +in `AGENTS.md` — a broken score can never brick the boot (and a broken score is +skipped, not fatal, in the repertoire). + +## Known v1 limits + +- **Score selection is boot-time** (score #0). Multi-score repertoires need the + router-time select→recompile step — see the scores-runtime handoff note. +- Stage funnel events come from tool-mapped stages; purely conversational stages + aren't individually tracked yet. diff --git a/packages/extension/scores/entitlements.toml b/packages/extension/scores/entitlements.toml new file mode 100644 index 00000000..caea3677 --- /dev/null +++ b/packages/extension/scores/entitlements.toml @@ -0,0 +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", "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/scores/memory/confidence-rubric.md b/packages/extension/scores/memory/confidence-rubric.md new file mode 100644 index 00000000..f96d57da --- /dev/null +++ b/packages/extension/scores/memory/confidence-rubric.md @@ -0,0 +1,57 @@ +# Confidence rubric (canonical) — L1 recommendations & L2 Veloce + +This is the SINGLE source of truth for how Amico scores a recommendation's +confidence. Both the interview (L1) and Veloce (L2) read THIS file — never +restate the numbers elsewhere, so the two can't drift. + +A recommendation is `{param, value, confidence, provenance:[{source,ref,note}]}`. +`confidence ∈ high | medium | low`, keyed to **provenance type** (mechanical), +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. +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**. +4. **default** — the SCORE.md static default → **low**. + +## 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 + `pulse.jld2` that **exists on disk** (stat it before labeling). + +Otherwise → **medium** (same problem, different regime / missing data / missing +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%) | + +**Fail-safe:** a platform NOT listed here, or a card missing any required +`sys_params` field, scores **medium, never high**. Unknown regime → fail safe. + +`±10%` means `|a − b| ≤ 0.10 × max(|a|, |b|)`. + +## medium / low + +- **medium**: demo (3-tuple), physics-canonical, or an own-precedent 3-tuple + match failing the `high` predicate. +- **low**: heuristic / extrapolated / static default with no matching precedent + or physics anchor. Always say it's a heuristic. + +## L2 (Veloce) consumes this + +Veloce auto-accepts iff `confidence == high` AND the decision is a reversible +interview parameter (never a resource gate). medium/low always ask. Because +`high` is the mechanical predicate above, auto-accepting it re-uses a decision +the user already made — not a new bet. 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/overture/SCORE.md b/packages/extension/scores/overture/SCORE.md new file mode 100644 index 00000000..4e010ad6 --- /dev/null +++ b/packages/extension/scores/overture/SCORE.md @@ -0,0 +1,114 @@ +--- +type: score +schema_version: 1 +id: overture +version: 1 +derived_from: null +name: "Welcome — let's set up your studio" +outcome: "A profile Amico remembers: who you are, your platforms, your control environment, your devices" +audience: [researchers, general] +duration_estimate: "3–5 min, then straight into designing a pulse" +entitlements: [] +stages: + - id: identity + questions: + - id: identity + prompt: "First — who am I working with? Your name, and your role or lab if you'd like." + default: "just a name is fine" + - id: platforms + questions: + - id: platforms + prompt: "Which qubit platforms do you work with?" + default: "transmon" + - id: environment + 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", + ] + default: "simulation only for now" + rationale_ref: "#environments" + - id: devices + optional: true + questions: + - id: devices + prompt: "Any specific device(s) you want me to remember? (name, platform, qubit count — or skip)" + default: "skip for now" + - id: goals + questions: + - id: goals + prompt: "Last one — what are you hoping to get done with Amico? In your own words." + default: "explore what's possible" + - id: handoff + questions: + - id: handoff + prompt: "Great — I've got you. What would you like to design first?" + default: "walk me through designing a pulse" +--- + +You are running the **overture** — Amico's onboarding interview (session zero). +This runs the first time someone opens Amico (no profile on file yet). Your job +is to learn who they are and how their world is wired, record it, and then flow +straight into designing their first pulse — all in this one session. + +**Persona.** You are Amico: warm, curious, terse. A friend who happens to be a +world-class pulse-design copilot. Speak in the first person. This is a +conversation, not a form. + +**FIRST, before greeting — call `amicode_profile` with `entity: "status"`.** +This tells you what (if anything) is already recorded. If the user abandoned an +earlier overture, entities will already be there: acknowledge them warmly +("welcome back — I've still got that you're at the Schuster Lab…") and ask ONLY +what's still missing. Never re-ask a question the status already answers. + +**Protocol: ONE question at a time.** Ask, wait, record, advance — never batch. +For every choice question use the native `question` tool (options in order, +default first with "(recommended)"); free-form answers can be plain text. After +each answer, record it immediately with `amicode_profile` (see the mapping +below). Recording is bookkeeping, not a gate — it never blocks the conversation. + +**Author-first / open intake.** Take every answer as given. If someone names a +platform, environment, or device you don't recognize, record it verbatim — never +coerce it into a known category, never decline. The taxonomy below is a guide, +not a gate. + +Per-stage guidance and the `amicode_profile` mapping: + +1. **identity** — greet in one line ("Ciao — I'm Amico, and I'll be your + pulse-design copilot"), then ask. Record: + `amicode_profile {entity:"profile", payload:{name, role, org}}`. +2. **platforms** — which platforms they work with (transmon, cavity/bosonic, + Rydberg atoms, fluxonium, ions, spins, …). Multi-select or free text is fine. + Record: `amicode_profile {entity:"profile", payload:{platforms:[...]}}`. + (Profile updates merge — recording platforms doesn't erase the name.) +3. **environment** — the load-bearing question: **what + are we patching into?** Three common archetypes, plus anything else: + - **`qick-lab`** — extant QICK control code, on-prem (the Stanford / UChicago + mode). Follow up: QICK tProc version, and where the extant control code + lives (a repo pointer — NOT credentials). + - **`cloud-pasqal`** — a cloud provider with an emulator in the loop (Pasqal / + Pulser is the archetype). Follow up: which provider, and whether an emulator + 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 + `amicode_profile {entity:"device", payload:{name, platform, environment:, +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: + `amicode_profile {entity:"onboarding_completed"}` (exactly once — it's what + lets Amico remember them next time). Then take their answer to "what would you + like to design first?" and **continue straight into the pulse-design + interview below, in this same session** — do not send them away or make them + start over. Use everything you just learned (platform, environment) to skip + pulse-design questions they've effectively already answered. diff --git a/packages/extension/scores/pulse-designer/SCORE.md b/packages/extension/scores/pulse-designer/SCORE.md new file mode 100644 index 00000000..b3c67291 --- /dev/null +++ b/packages/extension/scores/pulse-designer/SCORE.md @@ -0,0 +1,252 @@ +--- +type: score +schema_version: 1 +id: pulse-designer +version: 3 +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", "cavity / bosonic", "other"] + default: "transmon" + - id: model + emits: [system] + questions: + - id: levels + prompt: "How many levels should the model keep? (I'll recommend based on your system — see guidance)" + default: "platform-dependent (transmon 3–4; a cavity/bosonic mode wants a Fock cutoff)" + 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) — including one from your pulse bank — or cold start?" + choices: ["cold start", "warm start"] + default: "cold start" + skip_if: "mode == simulate" + rationale_ref: "#warm-start-bank" + - id: problem + questions: + - id: target + prompt: "What is the target — a gate, or a state to prepare?" + default: "a single-qubit gate" + rationale_ref: "#scope" + - id: formulate + emits: [formulation] + questions: + - id: objective + prompt: "Objective and constraints? (gate → unitary infidelity; state preparation → ket infidelity to the target state; both under the amplitude bound)" + default: "the standard objective for this problem type" + 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: "Pulse duration 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). + +**Asking choice questions — MANDATORY tool use.** Whenever you present the user +a choice among options (every question above with a `choices` list, and any +either/or you pose), you **MUST call the native `question` tool**. Do NOT type +the options out in prose. Listing choices as text — "Are you working with (a) +transmon, (b) neutral-atom Rydberg, or (c) other?" — is WRONG even when it seems +simpler or faster; the user answers by clicking the form, so a prose list gives +them nothing to click. If you catch yourself about to write options as text, +stop and call `question` instead. One `question` call = ONE question; 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: never also ask in prose, 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 a plain-text list only +if the `question` tool is genuinely 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 — +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. + +**Recommendations (L1) — every parameter carries confidence + provenance.** +Before proposing any parameter (T, N, max_iter, drive_max, levels/Fock cutoff, +objective, warm-start), derive a recommendation and score its confidence +MECHANICALLY per `scores/memory/confidence-rubric.md` (read it — do not guess +confidence): resolve own-precedent (a matching `## Your recent problems` card) → +reference demos (`## Reference demos`) → the platform skill's physics → static +default, and take the highest available. State it inline as +`value — confidence — one-line provenance` (e.g. "N = 50 — high — your +`x-gate-transmon` card, 8 solves"), call `amicode_recommend {action:"propose", …}` +to record it, then offer it as the default and ask. After the value lands via +`amicode_set_model`/`amicode_formulate`, call +`amicode_recommend {action:"outcome", …}` (accepted if applied == recommended, +else overridden). A warm-start is "high" ONLY if the banked pulse exists. + +**Veloce (L2) — confident autonomy, opt-in.** Veloce is OFF by default (ask every +stage). When ON (`amicode_veloce {action:"status"}` to check; the user turns it on +by saying "go veloce"/"just run with your recommendations" → `amicode_veloce +{action:"on"}`): auto-accept a recommendation ONLY when its confidence is **high** +AND it is a downstream solve param (`T`, `N`, `max_iter`, `objective`, +`warm-start`) — NEVER the regime-defining system params (`levels`, `drive_max`, +`fock_cutoff`), which always get a human glance. On auto-accept, call +`amicode_recommend {action:"propose", …, auto_accepted:true}` (records +outcome:accepted too) and emit a one-line ⚡ receipt; do NOT ask. `medium`/`low` +always ask. **Resource gates always confirm** even in veloce: before launching a +solve, show a digest ENUMERATING every auto-accepted param (including `max_iter`) +and get an explicit go; hardware/calibration likewise. **Interrupt = off:** the +moment the user corrects a value, asks a question, or says stop, call +`amicode_veloce {action:"off", reason:"interrupt"}` (and if they overrode an +already-auto-accepted param, append `amicode_recommend {action:"outcome", +outcome:"overridden"}` for it) and return to asking. **Offer once:** after 3 +consecutive high-confidence recs the user ACCEPTED, you MAY offer veloce once ("want +me to just run with my recommendations? — I'll still confirm before compute"); if +declined, don't offer again this session. + +**Anchor on the user's memory.** If an `## About this user` section is present, +you already know their name, platforms, environment, and devices — greet them by +name, lead with their platform, and NEVER ask what a section already answers. If a +`## Your recent problems` section is present, check whether their target matches a +card before asking boilerplate; a matching card means you have priors (typical +params, best fidelity, lessons) — use them. + +Per-stage notes: + +1. **platform** — **author-first / open intake.** Acknowledge whatever the user + states **as stated** — transmon, Rydberg, spin qubits, cavities, anything. + **Never coerce** an unfamiliar platform into a known one; never decline for lack + of a template. Record the **actual platform string** via `amicode_pick_system` + (free-form). If `## About this user` names their platform(s), lead with that + instead of asking cold. 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, + blockade on $|rr\rangle$): show the form, record `platform = "rydberg"`. When the + `## Skill index` lists `Piccolissimo/piccolissimo-authoring`, recommend the + Piccolissimo **free-phase CZ path** (`subsystem_levels=[3,3]`); otherwise the + **composed** `rydberg-cz` exemplar is the public fallback (experimental / + not-yet-vetted, fixed-phase + virtual-Z scan, slow at 2 qubits). Do not claim + Rydberg is unsupported. + - cavity / bosonic (a harmonic mode, optionally coupled to a transmon): + $\hat H/\hbar = \omega\,\hat a^\dagger\hat a + u_1(t)(\hat a+\hat a^\dagger) + i\,u_2(t)(\hat a-\hat a^\dagger) + \dots$ + record `platform = "cavity"` (or `"transmon-cavity"` for the coupled system). + The natural targets here are **states** (cat, Fock, GKP), not gates — see the + problem stage. **Invoke the `bosonic` skill** for the displaced-frame model and + Fock-cutoff sizing, and (with `issimo`) `piccolissimo-authoring` for the + `KetTrajectory` state-prep flow. + - **General routing (skills-first):** for ANY platform, if the `## Skill index` + 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 + to 3 blindly. A **transmon** qubit keeps 3 (default) or 4 for leakage realism; + avoid 5+ (worse conditioning/leakage, higher solve cost). A **cavity / bosonic + mode** is different: it needs a **Fock cutoff** large enough to contain the + target state and its transients — a cat state $|\alpha\rangle+|{-}\alpha\rangle$ + with $|\alpha|\sim 2$ wants ~15–25 Fock levels; too small a cutoff silently + truncates the state and corrupts the fidelity. Invoke the **`bosonic`** skill + for a cutoff appropriate to the target. (For a transmon⊗cavity system, the + dimension is levels × Fock-cutoff.) +3. **mode** — if warm-starting: + `traj = load_traj("path/to/pulse.jld2")` as the initial guess (the warm-start + idiom in the project context). **Prefer the user's pulse bank:** if + `## Your recent problems` lists a card whose target matches, proactively offer + a warm start from that card's banked `pulse.jld2` (the path shown in the + card / KNOWLEDGE line) instead of asking for a path — a solved problem should + never be re-solved cold. Say what you're seeding from and its recorded fidelity. +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_ + 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, + experimental) or the Piccolissimo free-phase path when the Skill index lists it. + - **State preparation** (target = a STATE, not a gate): cat states, Fock states, + GKP states, arbitrary kets — e.g. a **cavity cat state**. This is NOT gate + synthesis: it optimizes a **`KetTrajectory`** toward the target state + (**ket infidelity**), never a unitary. Do NOT ask "which gate," do NOT record a + gate target, do NOT report unitary infidelity. Supported via **Piccolissimo** + (invoke `piccolissimo-authoring`) with the platform physics skill (`bosonic` + for a cavity). Name the problem for the target (e.g. `cat-state-transmon-cavity`) + — the strip slug follows the name, so a wrong name reads as a wrong problem. +5. **formulate** — the objective matches the problem TYPE: **gate synthesis** → + unitary infidelity under the amplitude bound `drive_max` (the vetted template); + **state preparation** → **ket infidelity** to the target state (a `KetTrajectory` + solve). 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` 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 + (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). + **Speak the user's environment.** If `## About this user` records an + environment, frame the send-to-device path in ITS terms — for `qick-lab`, + "this would compile to your QICK control code" (adapter: IntonatoQICK); for + `cloud-pasqal`, "this would submit to the cloud, emulator first"; for + `local-sim`, be explicit that hardware isn't wired yet. Read the environment + card from the vault for specifics. Don't offer a generic device stub when you + know exactly what they're patching into. 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..a8631096 --- /dev/null +++ b/packages/extension/scores/pulse-designer/templates/solve.jl @@ -0,0 +1,151 @@ +#!/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) + # Cooperative stop: the Run Inspector's Stop button drops a STOP file into the + # run dir (== cwd). Returning false from Ipopt's intermediate_callback halts + # the solve (User_Requested_Stop) at the next iteration; solve! returns + # normally, so the partial pulse.jld2/result.toml still get written below. + if isfile("STOP") + println("AMICODE_STOPPED"); flush(stdout) + return false + end + 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/scripts/build_exemplars.mjs b/packages/extension/scripts/build_exemplars.mjs new file mode 100644 index 00000000..270903d6 --- /dev/null +++ b/packages/extension/scripts/build_exemplars.mjs @@ -0,0 +1,95 @@ +#!/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/scripts/distill_batch.mjs b/packages/extension/scripts/distill_batch.mjs new file mode 100644 index 00000000..4c871660 --- /dev/null +++ b/packages/extension/scripts/distill_batch.mjs @@ -0,0 +1,225 @@ +#!/usr/bin/env node +// Batch retro-ingest (spec-20260705-002847 §5) — one-shot seeding of the +// user-memory substrate from existing history. Self-contained: builds the +// distiller OPENCODE_CONFIG_CONTENT inline (does not depend on the running +// extension's distiller.config.json) and runs jobs SEQUENTIALLY via +// `opencode run --agent distiller` (a one-shot batch needs no queue/lock). +// +// SAFETY (tonight): the opencode SERVER is alive, so the DB-archive step is +// SKIPPED — reads only. The distiller writes ONLY to /amicode/ with +// pathspec-scoped commits (DISTILLER.md rule 1). +// +// Usage: +// node scripts/distill_batch.mjs --dry-run # triage counts only +// node scripts/distill_batch.mjs --runs-only [--limit N]# distill runs w/ result.toml +// node scripts/distill_batch.mjs --sweeps [--limit N] # distill substantive sessions +// node scripts/distill_batch.mjs --all # runs + sweeps +import { execFileSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +const HOME = os.homedir(); +const EXT = path.resolve(path.dirname(new URL(import.meta.url).pathname), ".."); +const OPENCODE = path.join(EXT, "vendor", "opencode", "linux-x64", "opencode"); +const DISTILLER_MD = path.join(EXT, "DISTILLER.md"); +const DB = path.join(HOME, ".local", "share", "opencode", "opencode.db"); +const RUNS_ROOT = path.join(HOME, ".amico", "runs", "default"); +const PROBLEMS_ROOT = path.join(HOME, ".amico", "problems"); +const OPS = path.join(HOME, ".amico", "amicode"); +const MODEL = process.env.AMICODE_DISTILLER_MODEL || "opencode/big-pickle"; + +const args = process.argv.slice(2); +const has = (f) => args.includes(f); +const limit = args.includes("--limit") ? parseInt(args[args.indexOf("--limit") + 1], 10) : Infinity; + +function resolveVault() { + const root = path.join(HOME, ".amico", "vaults"); + for (const name of fs.readdirSync(root).sort()) { + try { + const t = fs.readFileSync(path.join(root, name, ".amico-vault.toml"), "utf8"); + if (/^\s*kind\s*=\s*"personal"\s*$/m.test(t)) return path.join(root, name); + } catch {} + } + throw new Error("no kind=personal vault under ~/.amico/vaults"); +} +const VAULT = resolveVault(); + +// Pre-create the distiller's working root so it never needs to explore the vault +// root (which it isn't granted). First run starts from an empty skeleton. +for (const d of ["", "problems", "pulses", "environment", "devices", "demos"]) { + fs.mkdirSync(path.join(VAULT, "amicode", d), { recursive: true }); +} +{ + const kn = path.join(VAULT, "amicode", "KNOWLEDGE.md"); + if (!fs.existsSync(kn)) fs.writeFileSync(kn, "# Amicode knowledge map\n\n"); +} + +function sql(q) { + return execFileSync("sqlite3", [`file:${DB}?mode=ro`, q], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }).trim(); +} + +function distillerConfig() { + return JSON.stringify({ + $schema: "https://opencode.ai/config.json", + instructions: [DISTILLER_MD], + 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.", + model: MODEL, + }, + }, + permission: { + bash: "allow", + edit: "allow", + external_directory: { + [`${VAULT}/amicode`]: "allow", + [`${VAULT}/amicode/**`]: "allow", + [`${VAULT}/.git/**`]: "allow", + [`${OPS}/**`]: "allow", + [`${PROBLEMS_ROOT}/**`]: "allow", + [`${RUNS_ROOT}/**`]: "allow", + [`${path.join(HOME, "harmoniqs", "demos")}/**`]: "allow", // demo-ingest reads + }, + }, + }); +} + +function runsWithResult() { + return fs + .readdirSync(RUNS_ROOT) + .filter((d) => d.startsWith("r") && fs.existsSync(path.join(RUNS_ROOT, d, "result.toml"))) + .sort(); +} + +function substantiveSessions() { + const q = `SELECT DISTINCT p.session_id FROM part p JOIN session s ON s.id=p.session_id + WHERE COALESCE(s.agent,'') != 'distiller' + AND (p.data LIKE '%amicode_pick_system%' OR p.data LIKE '%amicode_formulate%' + OR p.data LIKE '%amico-run --spec%' OR p.data LIKE '%amicode_solve%');`; + return sql(q).split("\n").filter(Boolean); +} + +function workspaceHygiene() { + // Flag (report, don't touch) workspaces whose recorded formulation target + // disagrees with the dir name (spec §5 step 4). + const flags = []; + for (const ws of fs.readdirSync(PROBLEMS_ROOT)) { + const ev = path.join(PROBLEMS_ROOT, ws, "events.jsonl"); + if (!fs.existsSync(ev)) continue; + let target = null; + for (const line of fs.readFileSync(ev, "utf8").split("\n").filter(Boolean)) { + try { + const e = JSON.parse(line); + 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, ""), + ) + ) + flags.push(`${ws} → recorded target "${target}"`); + } + return flags; +} + +function distill(job, label) { + process.stdout.write(` distilling ${label} … `); + try { + const out = execFileSync(OPENCODE, ["run", "--agent", "distiller", JSON.stringify(job)], { + env: { ...process.env, OPENCODE_CONFIG_CONTENT: distillerConfig() }, + encoding: "utf8", + timeout: 10 * 60 * 1000, + maxBuffer: 8 * 1024 * 1024, + }); + const last = out.trim().split("\n").filter(Boolean).pop() || "(no summary)"; + console.log("ok — " + last.slice(0, 160)); + return true; + } catch (e) { + console.log("FAILED — " + String(e.message).slice(-200)); + return false; + } +} + +// ── main ────────────────────────────────────────────────────────────────── +const runs = runsWithResult(); +const sessions = substantiveSessions(); +const hygiene = workspaceHygiene(); +const serverAlive = (() => { + try { + execFileSync("pgrep", ["-f", "opencode serve"]); + return true; + } catch { + return false; + } +})(); + +console.log(`vault: ${VAULT}`); +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"}`, +); + +if (has("--dry-run")) { + console.log("\n[dry-run] no distills spawned."); + process.exit(0); +} + +let ok = 0, + fail = 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++; +} +if (has("--demos-ingest")) { + const DEMOS = path.join(HOME, "harmoniqs", "demos"); + const dirs = fs.existsSync(DEMOS) + ? fs.readdirSync(DEMOS).filter((d) => fs.statSync(path.join(DEMOS, d)).isDirectory()) + : []; + 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++; +} +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++; +} + +// Summary report (spec §5 step 5) — stdout + a vault notes/ file. +const stamp = sql("SELECT strftime('%Y%m%dT%H%M%S','now');"); +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)"), + `- 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"}`, + `- distiller sessions created this batch are marked agent='distiller' and excluded from future triage; archive them in a no-server window.`, +].join("\n"); +console.log("\n" + report); +if (!has("--dry-run")) { + const notesDir = path.join(VAULT, "notes"); + fs.mkdirSync(notesDir, { recursive: true }); + fs.writeFileSync(path.join(notesDir, `amicode-batch-report-${stamp}.md`), report + "\n"); + console.log(`\nreport written to notes/amicode-batch-report-${stamp}.md`); +} diff --git a/packages/extension/scripts/fetch_opencode.mjs b/packages/extension/scripts/fetch_opencode.mjs index c0ae9aab..d3ce5781 100644 --- a/packages/extension/scripts/fetch_opencode.mjs +++ b/packages/extension/scripts/fetch_opencode.mjs @@ -1,104 +1,301 @@ #!/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' +// Vendoring of the opencode chat-server binary, pinned by opencode.lock.json +// (spec §2/§3). Two sources: +// release (default) — download the pinned release asset, verify sha256. +// local — build from a local clone of the fork at the pinned git +// ref (`ref`), stamp the ACTUAL binary sha. Replaces the +// hand-swap workflow (AMICODE-PATCHES.md) where the stamp +// was left lying at the manifest value. +// Importable module + CLI in one file. +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 { homedir } from "node:os"; +import { delimiter, 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 source = m.source ?? "release"; + if (source !== "release" && source !== "local") + throw new Error(`manifest: source must be "release" or "local", got ${JSON.stringify(m.source)}`); + if (source === "local" && !/^[0-9a-f]{40}$/.test(m.ref ?? "")) + throw new Error('manifest: source "local" requires ref (40-hex fork commit)'); + 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 + * 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}`; +} + +/** Local-clone resolution: explicit path (--local) > AMICODE_OPENCODE_SRC > + * sibling checkout next to this repo (/../opencode — the team layout, + * e.g. ~/harmoniqs/{amicode,opencode}). */ +export function resolveCloneDir(root = PKG_ROOT, flagPath) { + return flagPath ?? process.env.AMICODE_OPENCODE_SRC ?? join(root, "..", "..", "..", "opencode"); } -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()); +} + +/** 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 }); + } +} + +const git = (dir, ...args) => execFileSync("git", ["-C", dir, ...args], { encoding: "utf8" }).trim(); + +function resolveBun() { + if (process.env.AMICODE_BUN) return process.env.AMICODE_BUN; + try { + const p = execFileSync("which", ["bun"], { encoding: "utf8" }).trim(); + if (p !== "") return p; + } catch { + /* not on PATH */ } - if (!r.ok) throw new Error(`download failed: HTTP ${r.status} for ${url}`) - return Buffer.from(await r.arrayBuffer()) + const fallback = join(homedir(), ".bun", "bin", "bun"); + if (existsSync(fallback)) return fallback; + throw new Error("bun not found — install it (https://bun.sh) or set AMICODE_BUN=/path/to/bun"); } -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') +/** The fork's documented build recipe (its AMICODE-PATCHES.md §3): bun's dir must + * be on PATH (tree-sitter postinstall shims re-invoke `bun` by name), and + * OPENCODE_VERSION pins the version string so no release upload is attempted. + * OPENCODE_CHANNEL must NOT resolve to "latest": that compiles the embedded + * web UI with VITE_OPENCODE_CHANNEL="prod" (app/vite.js), which defaults + * settings.general.newLayoutDesigns OFF — hiding every amicode surface (home + * cards, v2 titlebar, draft flow) at runtime even though the code is compiled + * in. Any other channel maps to "dev" → new-layout default ON. */ +function defaultBuild(cloneDir, version) { + const bun = resolveBun(); + execFileSync(bun, ["run", "script/build.ts", "--single", "--skip-install"], { + cwd: join(cloneDir, "packages", "opencode"), + env: { + ...process.env, + OPENCODE_VERSION: version, + OPENCODE_CHANNEL: "dev", + PATH: `${dirname(bun)}${delimiter}${process.env.PATH ?? ""}`, + }, + stdio: ["ignore", "inherit", "inherit"], + }); +} + +/** Atomic install into vendor/opencode//: write in a temp dir on the same + * fs, rename, chmod, then stamp LAST (spec §3 step 5). */ +function installBinary(destDir, bytes, hash, sourceLine) { + const bin = join(destDir, "opencode"); + mkdirSync(destDir, { recursive: true }); + const work = mkdtempSync(join(destDir, ".unpack-")); + try { + writeFileSync(join(work, "opencode"), bytes); + renameSync(join(work, "opencode"), bin); + chmodSync(bin, 0o755); + writeFileSync(join(destDir, ".source"), sourceLine + "\n"); + writeFileSync(join(destDir, ".sha256"), hash + "\n"); + } finally { + rmSync(work, { recursive: true, force: true }); + } + return bin; +} - if (existsSync(bin) && existsSync(stamp) && readFileSync(stamp, 'utf8').trim() === want) { - return { skipped: true, path: bin } // offline repeat builds +function fetchFromLocal({ root, manifest, key, cloneDir, anyRef, noBuild, build }) { + const head = git(cloneDir, "rev-parse", "HEAD"); + const dirty = git(cloneDir, "status", "--porcelain") !== ""; + if (!anyRef) { + if (head !== manifest.ref) + throw new Error( + `clone ${cloneDir} is at ${head.slice(0, 10)} but the lock pins ${manifest.ref.slice(0, 10)} — ` + + `checkout the pinned ref, update opencode.lock.json, or pass --any-ref`, + ); + if (dirty) throw new Error(`clone ${cloneDir} has uncommitted changes — commit/stash them or pass --any-ref`); } + if (!noBuild) build(cloneDir, manifest.version); + // build.ts --single emits only the CURRENT platform's artifact. + const artifact = join(cloneDir, "packages", "opencode", "dist", `opencode-${key}`, "bin", "opencode"); + if (!existsSync(artifact)) { + throw new Error( + `built binary missing at ${artifact}` + + (noBuild ? " — rerun without --no-build" : " — local mode builds only the current platform"), + ); + } + const bytes = readFileSync(artifact); + const provenance = `local ${head}${dirty ? "+dirty" : ""}`; + const bin = installBinary(join(root, "vendor", "opencode", key), bytes, sha256(bytes), provenance); + return { skipped: false, path: bin, source: provenance }; +} - const bytes = await download(assetUrl(manifest, key)) - const got = sha256(bytes) +async function fetchFromRelease({ root, manifest, key, download }) { + 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 coords = releaseCoords(manifest); + const provenance = `release ${coords.repo}@${coords.tag}`; + + if (existsSync(bin) && existsSync(stamp) && readFileSync(stamp, "utf8").trim() === want) { + return { skipped: true, path: bin, source: provenance }; // offline repeat builds + } + + 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(join(destDir, ".source"), provenance + "\n"); + 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, source: provenance }; +} + +/** mode: "release" | "local" | undefined (undefined → manifest.source). + * An EXPLICIT mode:"local" with no clone is a hard error; manifest-driven local + * falls back to the pinned release when the clone is absent (CI has no clone). */ +export async function fetchOpencode({ + root = PKG_ROOT, + platform, + download = defaultDownload, + mode, + localDir, + anyRef = false, + noBuild = false, + build = defaultBuild, +} = {}) { + const manifest = loadManifest(root); + const key = resolvePlatform(manifest, platform); + const want = mode ?? manifest.source ?? "release"; + if (want === "local") { + const cloneDir = resolveCloneDir(root, localDir); + if (existsSync(join(cloneDir, ".git"))) { + return fetchFromLocal({ root, manifest, key, cloneDir, anyRef, noBuild, build }); + } + const hint = `clone harmoniqs/opencode there (or set AMICODE_OPENCODE_SRC / pass --local )`; + if (mode === "local") throw new Error(`no local clone at ${cloneDir} — ${hint}`); + console.warn( + `[fetch-opencode] WARNING: lock source=local but no clone at ${cloneDir} — ${hint}; falling back to the pinned release`, + ); } - return { skipped: false, path: bin } + return fetchFromRelease({ root, manifest, key, download }); } 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 flagValue = (name) => { + const i = argv.indexOf(name); + if (i < 0) return undefined; + const next = argv[i + 1]; + return next !== undefined && !next.startsWith("--") ? next : null; // null = flag present, no value + }; + const platform = flagValue("--platform") ?? 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 local = flagValue("--local"); + const r = await fetchOpencode({ + platform, + mode: local !== undefined ? "local" : argv.includes("--release") ? "release" : undefined, + localDir: local ?? undefined, + anyRef: argv.includes("--any-ref"), + noBuild: argv.includes("--no-build"), + }); + console.log( + r.skipped ? `[fetch-opencode] up to date: ${r.path}` : `[fetch-opencode] installed (${r.source}): ${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 new file mode 100644 index 00000000..6b396529 --- /dev/null +++ b/packages/extension/scripts/plugin_exercise.ts @@ -0,0 +1,105 @@ +// 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"); + +// 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", +]) { + 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); diff --git a/packages/extension/src/catalog_card_shell.ts b/packages/extension/src/catalog_card_shell.ts index 20c1f394..aa6f36e7 100644 --- a/packages/extension/src/catalog_card_shell.ts +++ b/packages/extension/src/catalog_card_shell.ts @@ -19,34 +19,65 @@ 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") vscode.window.showInformationMessage(`what-next → ${m.id} (stub)`); - }); - const uri = (...p: string[]) => panel.webview.asWebviewUri(vscode.Uri.joinPath(ctx.extensionUri, ...p)); - const nonce = Math.random().toString(36).slice(2); - panel.webview.html = ` + 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 = ` @@ -55,13 +86,19 @@ export function registerCatalogCard(ctx: vscode.ExtensionContext): void { `; - })); + }, + ), + ); } /** 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; @@ -94,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 fa4f26ed..ab937b8b 100644 --- a/packages/extension/src/catalog_card_webview.ts +++ b/packages/extension/src/catalog_card_webview.ts @@ -2,10 +2,17 @@ // (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 } } } +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 @@ -23,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, + ], ], }, }; @@ -48,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 6a4bb054..7f043f07 100644 --- a/packages/extension/src/chat_panel.ts +++ b/packages/extension/src/chat_panel.ts @@ -1,4 +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 @@ -7,18 +10,131 @@ import * as vscode from "vscode"; // existing panel forward or creates a fresh one. // ============================================================================ +// Commands the in-app palette (opencode "Amico" command group) may trigger via +// the iframe→parent→extension postMessage bridge. STRICT allowlist: the framed +// app renders LLM output, so we never executeCommand anything outside this set. +const BRIDGE_ALLOWED_COMMANDS: ReadonlySet = new Set([ + "amicode.restartServer", + "amicode.distillNow", + "amicode.stopRun", + "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) => { - // Reserved for future iframe → extension postMessage protocol. - // For now we don't depend on it — control flow goes through the - // local HTTP callback (Channel 2) instead. + // iframe → extension command bridge: the opencode "Amico" palette group + // posts {source:"amicode", kind:"command", command} to window.parent; + // 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" && + (msg as { source?: unknown }).source === "amicode" && + (msg as { kind?: unknown }).kind === "command" && + typeof (msg as { command?: unknown }).command === "string" && + BRIDGE_ALLOWED_COMMANDS.has((msg as { command: string }).command) + ) { + void vscode.commands.executeCommand((msg as { command: string }).command); + return; + } console.log("[amicode/chat] webview msg:", msg); }, null, @@ -31,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; @@ -52,13 +163,21 @@ export class ChatPanel { private renderHtml(opencodeUrl: URL): string { // CSP: allow the iframe to load opencode's localhost origin. The frame // itself is isolated, but VS Code's webview CSP needs to explicitly grant - // the localhost frame-src. + // the localhost frame-src. The nonce authorizes the one relay script below. + const nonce = randomBytes(16).toString("base64"); const csp = [ "default-src 'none'", "style-src 'unsafe-inline'", + `script-src 'nonce-${nonce}'`, `frame-src ${opencodeUrl.origin}`, "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 */ ` @@ -71,14 +190,43 @@ 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/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/extension.ts b/packages/extension/src/extension.ts index ab4cc0e7..f95e2a2a 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -9,12 +9,16 @@ 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"; import { RunsManager } from "./runs_manager"; import { stageDemoRun } from "./demo_replay"; +import { writeStopFile, savePulseTo, catalogPulsesDir, stopPlan, forceStop, runLogMtime } from "./run_controls"; +import { amicodeOpsDir } from "./substrate/vault_store"; +import { initDistillerTransport, triggerRunDistill, triggerSweep, type DistillerSetup } from "./substrate/distiller"; +import * as os from "node:os"; import { readTomlSafe } from "./run_dir_reader"; // ============================================================================ @@ -31,7 +35,13 @@ let statusBar: StatusBarManager | undefined; let sseClient: OpencodeEventClient | undefined; let runsManager: RunsManager | undefined; let opencodeReadyUrl: URL | undefined; +/** Set once the binary + vault are known; the watcher's onRunFinished closure + * 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(); export async function activate(ctx: vscode.ExtensionContext): Promise { const opencodeChannel = vscode.window.createOutputChannel("Amicode — opencode"); @@ -44,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. @@ -68,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, @@ -94,7 +108,16 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // runs/index (1.2, #57), so solves from prior dev-host sessions register and // a still-live run resumes; every concurrent run is tracked to completion. fs.mkdirSync(runsRoot, { recursive: true }); - runsManager = new RunsManager({ runsRoot, channel: runsChannel, statusBar }); + runsManager = new RunsManager({ + runsRoot, + channel: runsChannel, + statusBar, + // Distill trigger 1 (spec-20260705-002847 §4.1): every LIVE completion — + // including failures (failure lessons are first-class knowledge, §4.4). + onRunFinished: ({ runId }) => { + if (distillerSetup) triggerRunDistill(distillerSetup, runId); + }, + }); runsManager.start(); ctx.subscriptions.push(runsManager); @@ -117,12 +140,22 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // 3. opencode project bootstrap const amicoRunBinDir = resolveAmicoRunBinDir(ctx.extensionPath); + // Configured skill-index overrides (spec-20260704-113005 §3) — an empty/unset + // array falls through to the module defaults (undefined → the `??` default). + const cfgArr = (key: string): string[] | undefined => { + const v = vscode.workspace.getConfiguration("amicode").get(key, []); + return Array.isArray(v) && v.length ? v : undefined; + }; 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"), + // User-memory substrate (spec-20260705-002847): "" in the setting keeps the + // auto-resolve (kind=personal marker scan); a path pins the vault explicitly. + vaultDir: vscode.workspace.getConfiguration("amicode").get("vaultDir", "") || undefined, }); opencodeChannel.appendLine(`[boot] opencode project dir: ${opencodeProject.projectDir}`); opencodeChannel.appendLine(`[boot] AGENTS.md: ${opencodeProject.agentsPath}`); @@ -156,15 +189,22 @@ 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 / // 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 @@ -172,11 +212,52 @@ 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), + OPENCODE_CONFIG_CONTENT: buildOpencodeConfigContent( + opencodeProject.agentsPath, + opencodeProject.templatePath, + runsRoot, + undefined, + undefined, + 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, }); - ctx.subscriptions.push({ dispose: () => serverManager?.stop() }); + ctx.subscriptions.push({ dispose: () => void serverManager?.stop() }); + + // Distiller transport (spec-20260705-002847 §4): written once per + // activation so every spawner — the run-finished trigger here, the plugin's + // onboarding trigger, and the batch shell — produces identical headless + // distillers. Requires a resolved personal vault; without one the distiller + // stays disabled and the session is simply unpersonalized. + if (opencodeProject.vaultDir) { + try { + distillerSetup = { + binary, + distillerMdPath: path.resolve(ctx.extensionPath, "DISTILLER.md"), + vaultDir: opencodeProject.vaultDir, + opsDir: amicodeOpsDir(), + problemsRoot: path.join(os.homedir(), ".amico", "problems"), + runsRoot, + model: vscode.workspace.getConfiguration("amicode").get("distillerModel", "opencode/big-pickle"), + }; + initDistillerTransport(distillerSetup); + 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; + } + } else { + opencodeChannel.appendLine(`[boot] no personal vault resolved — distiller disabled, session unpersonalized`); + } // SSE event channel — opens once opencode is healthy. sseClient = new OpencodeEventClient({ channel: opencodeChannel, statusBar }); @@ -186,6 +267,11 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { opencodeReadyUrl = url; statusBar?.setServerReady(true); sseClient?.connect(url); + // Open the chat as soon as the server is up (amicode.chat.autoOpen, + // default on) — the chat IS the product's front door. + if (vscode.workspace.getConfiguration("amicode").get("chat.autoOpen", true)) { + ChatPanel.openOrReveal(ctx, url); + } // Surface ONE explicit LLM-provider signal at boot, read from opencode's // OWN resolution (its live /config/providers) — not a silent hang at the // chat box (Q129). Key-free; never logs a credential. @@ -212,7 +298,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 @@ -230,9 +318,166 @@ 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) => { + // 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(" · "), + 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", async () => { + const dir = runsManager?.getActiveRunDir(); + 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" + if (pendingStops.has(dir)) { + vscode.window.showInformationMessage(`Amicode: stop already in progress for ${label}.`); + return; + } + const plan = stopPlan(dir); + if (plan === "already-finished") { + vscode.window.showInformationMessage(`Amicode: run ${label} has already finished.`); + return; + } + // 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 */ + } + 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.`, + ); + 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 + const pick = await vscode.window.showWarningMessage( + `Amicode: run ${label} hasn't responded to stop.`, + "Force stop", + "Keep waiting", + ); + if (pick === "Force stop") { + await forceStop(dir); + vscode.window.showInformationMessage(`Amicode: run ${label} force-stopped and marked aborted.`); + } + }, 120_000); + 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; + } + // 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), + ); + } 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; + } + 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" }); + if (!choice) return; + try { + if (choice === "Save to catalog" && catalog) { + const name = `${path.basename(dir)}.jld2`; + savePulseTo(dir, path.join(catalog, name)); + vscode.window.showInformationMessage(`Amicode: saved pulse to catalog (${name}).`); + } else { + const uri = await vscode.window.showSaveDialog({ + filters: { JLD2: ["jld2"] }, + defaultUri: vscode.Uri.file(path.join(dir, "pulse.jld2")), + }); + if (uri) { + savePulseTo(dir, uri.fsPath); + vscode.window.showInformationMessage("Amicode: pulse saved."); + } + } + } catch (e) { + vscode.window.showErrorMessage(`Amicode: ${(e as Error).message}`); + } + }), + // Distill trigger 3 (manual): coarse idempotent sweep — safe to mash. + vscode.commands.registerCommand("amicode.distillNow", () => { + if (!distillerSetup) { + void vscode.window.showWarningMessage("Amicode: distiller disabled (no personal vault resolved)."); + return; + } + triggerSweep(distillerSetup, true); + void vscode.window.showInformationMessage("Amicode: distilling recent sessions in the background."); + }), vscode.commands.registerCommand("amicode.restartServer", async () => { opencodeChannel.appendLine(`[boot] restart requested`); - serverManager?.stop(); + await serverManager?.stop(); statusBar?.setServerReady(false); opencodeReadyUrl = undefined; try { @@ -266,7 +511,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); } @@ -280,9 +526,11 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { } export function deactivate(): void { + // Distill trigger 2 (session close): queue-only — a drain must not delay + // shutdown; the next activation or trigger drains the queue. + if (distillerSetup) triggerSweep(distillerSetup, false); sseClient?.dispose(); serverManager?.stop(); runsManager?.dispose(); statusBar?.dispose(); } - diff --git a/packages/extension/src/inspector_webview.ts b/packages/extension/src/inspector_webview.ts index 47ed85e5..1f9fdfa4 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/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/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/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 b6631082..c3bf0a93 100644 --- a/packages/extension/src/opencode_config.ts +++ b/packages/extension/src/opencode_config.ts @@ -1,6 +1,28 @@ 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, packageAllowlist } from "./scores/entitlements"; +import { buildRouterSection } from "./scores/router"; +import { compileScore, spliceIntoAgentsMd, compileChainedScore, chainManifest } from "./scores/compiler"; +import { + resolveLibrarySkills, + resolvePackageSkills, + buildSkillIndexSection, + stageOpencodeSkills, + type SkillIndexEntry, +} from "./scores/package_skills"; +import { + resolvePersonalVault, + defaultVaultsRoot, + readProfileMd, + readKnowledgeLines, + readDemoLines, + hasOnboardingCompleted, + onboardingDir, +} from "./substrate/vault_store"; +import { buildAboutUserSection, buildRecentProblemsSection, buildReferenceDemosSection } from "./substrate/user_splice"; // ============================================================================ // Prepare a per-session opencode project directory. @@ -68,29 +90,242 @@ 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. */ -const SCRATCH_DIR = "/tmp/amicode-work"; // matches AGENTS.md step 2/3 + * `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 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 + +/** 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", "problems"); +} + +/** Default location of the amicode_* opencode plugin: a sibling directory of + * both src/ (vitest) and dist/ (the bundled extension), so __dirname/.. works + * from either. */ +const DEFAULT_PLUGIN_PATH = path.resolve(__dirname, "..", "opencode-plugin", "amicode_tools.ts"); + +/** Default scores repertoire root — same sibling-of-src-and-dist trick as the + * plugin path. Holds SCORE.md manifests, score-local templates, memory hooks. */ +export const DEFAULT_SCORES_ROOT = path.resolve(__dirname, "..", "scores"); + +/** Skill-index roots (spec-20260704-113005 §3). Package skills are co-located + * in the workspace package repos; platform skills are the configured names in + * the central amico-plugin library. Overridable via settings (Task 6). */ +export const DEFAULT_SKILL_ROOTS = [path.join(os.homedir(), "harmoniqs", "packages")]; +export const DEFAULT_PLATFORM_SKILLS = ["atoms", "transmon", "fluxonium", "ions", "bosonic"]; +export const DEFAULT_LIBRARY_ROOTS = [path.join(os.homedir(), "harmoniqs", "amico-plugin", "skills")]; + +/** 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"), +}; + +/** The entitlement→package table ships with the scores repertoire (spec C put + * it in scores/entitlements.toml). NOT registry.toml — that file has no + * [packages] table (the §3 mis-wire this fixes: reading registry meant holding + * `issimo` never allowlisted Piccolissimo in production). */ +export function entitlementsTablePath(scoresRoot: string = DEFAULT_SCORES_ROOT): string { + return path.join(scoresRoot, "entitlements.toml"); +} + +/** 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, + scoresRoot: string = DEFAULT_SCORES_ROOT, + skills: SkillIndexEntry[] = [], +): void { + try { + const ents = readLocalEntitlements(entitlementsDir); + 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", + ]; + 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, + // Additive session record (spec §3): the dual-source skill index the + // agent was given. amico-run ignores unknown fields; schema_version stays 1. + skills, + }, + 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, runsRoot: string): string { +/** 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 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( + 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"; + } catch { + /* no auth.json — the free default below still works */ + } + // 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 + * 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, + runsRoot: string, + pluginPath: string = DEFAULT_PLUGIN_PATH, + scoresRoot: string = DEFAULT_SCORES_ROOT, + 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 + // skill's OWN directory only — NOT a library root (the ~50 process skills stay + // unreadable; the grants agree with the index guard). + const skillGrants: Record = {}; + for (const p of skillPaths) skillGrants[`${path.dirname(p)}/**`] = "allow"; + // Register the staged skills as opencode-native skills (spec §3 fix, + // 2026-07-04): an ABSOLUTE per-session dir holding ONLY the resolved set, so + // `atoms`/`piccolissimo-authoring`/… are invocable by name. Absolute path (not + // a `.opencode/skills` under cwd) sidesteps the session-cwd=workspace pollution + // and the worktree-walk; the dir holds only the guarded set (stageOpencodeSkills). + const skills = skillsStageDir ? { paths: [skillsStageDir] } : undefined; return JSON.stringify({ $schema: "https://opencode.ai/config.json", + ...(modelPin ? { model: modelPin } : {}), instructions: [agentsPath], + plugin: [pluginPath], + ...(skills ? { skills } : {}), + 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", 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 + [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 + // has no read/write split, so this is posture, documented in spec §10. + ...(vaultDir ? { [`${vaultDir}/amicode/**`]: "allow" } : {}), }, }, }); } - export interface OpencodeConfigOptions { /** Absolute path to packages/extension/AGENTS.md to substitute + write into the project dir. */ agentsSrc: string; @@ -100,6 +335,20 @@ 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; + /** Roots to search for co-located package skills (spec §3). Default: DEFAULT_SKILL_ROOTS. */ + skillRoots?: string[]; + /** Configured platform-skill names to index from the library (spec §3). Default: DEFAULT_PLATFORM_SKILLS. */ + platformSkills?: string[]; + /** Roots for the central platform-skill library (spec §3). Default: DEFAULT_LIBRARY_ROOTS. */ + skillLibraryRoots?: string[]; + /** Personal vault dir for the user-memory substrate (spec-20260705-002847). + * undefined → auto-resolve (kind=personal marker scan under ~/.amico/vaults); + * "" → personalization disabled; a path → used as-is. */ + vaultDir?: string; } export interface OpencodeProject { @@ -107,6 +356,14 @@ export interface OpencodeProject { agentsPath: string; /** The vetted template the agent reads — the bundled source (absolute), not a copy. */ templatePath: string; + /** Absolute SKILL.md paths indexed this session — thread into buildOpencodeConfigContent for grants. */ + skillPaths: string[]; + /** Absolute dir holding the staged opencode-native skills — thread into + * buildOpencodeConfigContent as `skills.paths`. "" if none staged. */ + skillsStageDir: string; + /** Resolved personal vault ("" when personalization is off) — thread into + * buildOpencodeConfigContent for the read grant. */ + vaultDir: string; } export function prepareOpencodeProject(opts: OpencodeConfigOptions): OpencodeProject { @@ -121,9 +378,137 @@ 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. + // User-memory substrate (spec-20260705-002847): resolve the personal vault + // ONCE, up front — the routing predicate (§3) and the splice (§6) both need + // it. undefined → auto-resolve (kind=personal marker scan); "" → off. + const vaultDir = opts.vaultDir !== undefined ? opts.vaultDir : resolvePersonalVault(defaultVaultsRoot(), ""); + + 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"); + const overture = visible.find((s) => s.manifest.id === "overture"); + // Routing predicate (spec §3): onboard (chain overture → pulse-designer) + // ONLY when there is a vault to remember into AND the user has neither a + // materialized profile NOR a completion marker (the second disjunct closes + // the ~2-min materialization window; an empty/whitespace PROFILE.md counts + // as absent via readProfileMd). No vault ⇒ never onboard (nowhere to + // materialize) — just run pulse-designer. + const shouldOnboard = + !!overture && + !!score0 && + vaultDir !== "" && + readProfileMd(vaultDir) === "" && + !hasOnboardingCompleted(onboardingDir()); + if (shouldOnboard && overture && score0) { + // Chained: ONE compiled section, ONE manifest (id `overture`, stages = + // overture ++ pulse-designer) so the score guard sees the whole flow. + finalContent = spliceIntoAgentsMd(filled, buildRouterSection(visible), compileChainedScore(overture, score0)); + const manifestJson = + JSON.stringify( + { manifest: chainManifest(overture, score0), score_dir: overture.dir, project_dir: projectDir }, + null, + 2, + ) + "\n"; + fs.writeFileSync(path.join(projectDir, "score_manifest.json"), manifestJson); + fs.mkdirSync(problemsRoot(), { recursive: true }); + fs.writeFileSync(path.join(problemsRoot(), "score_manifest.json"), manifestJson); + } else if (score0) { + finalContent = spliceIntoAgentsMd(filled, buildRouterSection(visible), compileScore(score0)); + // Manifest transport: the opencode plugin (Bun runtime, separate process tree) + // 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(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}`); + finalContent = filled; + } + // Package + platform skill index (spec-20260704-113005 §3) — deliberately + // OUTSIDE the score try/catch above: score-compile trouble must not drop + // entitled skills, nor vice versa. Platform (library, PUBLIC) entries first, + // then entitlement-gated package skills. Read on demand — the content is never + // baked into the prompt or the .vsix; only the lean index is spliced. + let skillEntries: SkillIndexEntry[] = []; + try { + const entsDir = opts.entitlementsDir ?? path.join(os.homedir(), ".amico", "amicode"); + 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, + ), + ...resolvePackageSkills(allow, opts.skillRoots ?? DEFAULT_SKILL_ROOTS), + ]; + const section = buildSkillIndexSection(skillEntries); + if (section) finalContent = finalContent + "\n\n" + section; + } catch (e) { + console.warn(`amicode: skill index failed (session continues without it): ${e}`); + } + + 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). + // The skill index rides along as an additive session record (spec §3). + writeAuthoringConfig( + opts.entitlementsDir ?? path.join(os.homedir(), ".amico", "amicode"), + opts.scoresRoot ?? DEFAULT_SCORES_ROOT, + skillEntries, + ); + + // Register the resolved skills as opencode-native skills: stage ONLY this set + // into an absolute per-session dir, pointed at by config `skills.paths` (see + // buildOpencodeConfigContent). Makes them invocable by name (the agent's + // instinct — observed 2026-07-04); the guard holds because we stage the + // resolved set, never a whole library root. + const skillsStageDir = stageOpencodeSkills(path.join(projectDir, "skills"), skillEntries); + + // User-memory splice (spec-20260705-002847 §6): About-this-user + Your-recent- + // problems, appended to the compiled content — own try/catch, personalization + // trouble must never brick the boot. `vaultDir` was resolved up front (above). + // When we routed to the overture (no profile yet) these read empty and add + // nothing — correct: there is no memory to splice on the very first session. + if (vaultDir) { + try { + const about = buildAboutUserSection(readProfileMd(vaultDir)); + const recent = buildRecentProblemsSection(readKnowledgeLines(vaultDir)); + const demos = buildReferenceDemosSection(readDemoLines(vaultDir)); // L1 §3 + for (const section of [about, recent, demos]) { + if (section) finalContent = finalContent + "\n\n" + section; + } + } catch (e) { + console.warn(`amicode: user-memory splice failed (session continues unpersonalized): ${e}`); + } + } + + 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). - return { projectDir, agentsPath, templatePath: opts.templateSrc }; + return { + projectDir, + agentsPath, + templatePath: opts.templateSrc, + skillPaths: skillEntries.map((e) => e.path), + skillsStageDir, + vaultDir, + }; } diff --git a/packages/extension/src/run_controls.ts b/packages/extension/src/run_controls.ts new file mode 100644 index 00000000..1fada267 --- /dev/null +++ b/packages/extension/src/run_controls.ts @@ -0,0 +1,196 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { execFileSync } from "node:child_process"; + +// ============================================================================ +// Run controls — file-op helpers behind the Run Inspector's Stop / Save pulse +// buttons. Pure enough to unit-test; VSCode wiring lives in extension.ts. +// ============================================================================ + +/** Request a cooperative stop: the solve template's per-iter callback polls for + * this file (in its cwd == the run dir) and returns false to halt Ipopt. */ +export function writeStopFile(runDir: string): void { + fs.writeFileSync(path.join(runDir, "STOP"), ""); +} + +// ---------------------------------------------------------------------------- +// Stop escalation — cooperative stop only works while a solver is alive to poll +// the STOP file. A wedged run (OOM-killed Julia, dead orchestrator) never +// consumes it and never writes FINISHED, so it sits "stalled" forever with a +// Stop button that does nothing. These helpers make Stop always terminate: +// plan → (kill any live solver) → force-write the terminal FINISHED sentinel. +// ---------------------------------------------------------------------------- + +/** Same threshold as the stalled displays (runs_manager.liveStatus, the fork's + * isStalled): a FINISHED-less run whose log has been silent this long is dead. */ +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; + } +} + +/** What stopping this run requires right now: nothing (already terminal), the + * cooperative STOP file, or the hard kill-and-finalize path. A run with no + * run.log yet is judged by run-dir age (never-started zombie vs. warming up). */ +export function stopPlan(runDir: string, now = Date.now()): "already-finished" | "cooperative" | "force" { + if (fs.existsSync(path.join(runDir, "FINISHED"))) return "already-finished"; + const logMtime = runLogMtime(runDir); + if (logMtime !== undefined) return now - logMtime > STALL_AFTER_MS ? "force" : "cooperative"; + try { + return now - fs.statSync(runDir).mtimeMs > STALL_AFTER_MS ? "force" : "cooperative"; + } catch { + return "force"; // run dir itself gone-ish — nothing to cooperate with + } +} + +/** The run's solve script from run.toml (regex, not a TOML parse — one known + * key on its own line, written by amico-run). The value is written with + * JSON.stringify escaping, so decode it the same way — an escaped `\` or `"` + * in the path would otherwise never match ps argv. Undefined if unreadable. */ +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); + } + } catch { + return undefined; + } +} + +/** lsof lives at /usr/sbin on macOS and /usr/bin on Linux (both supported + * targets); a GUI-spawned extension host can't count on either being in PATH. + * A wrong path here silently disables the kill path (findRunPids proves + * nothing → returns []), so probe explicitly. */ +function lsofPath(): string { + for (const p of ["/usr/sbin/lsof", "/usr/bin/lsof"]) if (fs.existsSync(p)) return p; + return "lsof"; // last resort: PATH lookup +} + +function realpathOr(p: string): string { + try { + return fs.realpathSync(p); + } catch { + return path.resolve(p); + } +} + +/** PIDs belonging to THIS run: command line references the run's solve script + * (or the run dir itself) AND the process cwd is the run dir. The two-key + * match is the safety property — sibling runs of the same problem share the + * script path but never the cwd, and nothing unrelated lives in a run dir. + * NEVER kill on a bare pattern match (a broad pkill once took out the user's + * main editor). Exec is injectable for tests. */ +export function findRunPids( + runDir: string, + scriptPath: string | undefined, + exec: (cmd: string, args: string[]) => string = (cmd, args) => + execFileSync(cmd, args, { encoding: "utf8", maxBuffer: 8 * 1024 * 1024 }), +): number[] { + let psOut = ""; + 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); + if (!m) continue; + const args = m[2]; + if ((scriptPath && args.includes(scriptPath)) || args.includes(runDir)) candidates.push(Number(m[1])); + } + return candidates.filter((pid) => { + if (pid === process.pid) return false; + try { + // lsof -Fn prints the cwd as a line starting with "n". It reports the + // PHYSICAL path, so realpath our side too — a symlinked runs root + // (/tmp → /private/tmp on macOS) must not defeat the ownership proof. + const realRunDir = realpathOr(runDir); + const out = exec(lsofPath(), ["-a", "-p", String(pid), "-d", "cwd", "-Fn"]); + return out.split("\n").some((l) => l.startsWith("n") && realpathOr(path.resolve(l.slice(1))) === realRunDir); + } catch { + return false; // can't prove it's ours → don't kill it + } + }); +} + +/** Force-write the terminal FINISHED sentinel (run-dir contract sub-shape: + * status + exit_code only, additionalProperties false). Atomic via rename so + * no reader ever sees a torn FINISHED. A breadcrumb goes to run.log for + * humans; FINISHED itself must stay schema-clean. */ +export function forceFinalize(runDir: string): void { + const tmp = path.join(runDir, ".FINISHED.tmp"); + 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 */ + } +} + +/** The hard path: TERM any live solver process provably tied to this run dir, + * give it a beat, KILL survivors, then finalize the run dir as aborted so + * every contract reader (extension + fork endpoints) converges. Safe on a + * 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 */ + } + } + 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 */ + } + } + } + } + // 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 + // the TERM window — that verdict wins; only finalize if nobody else did. + // 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 */ + } +} + +/** Copy the run's pulse.jld2 to an absolute destination path. */ +export function savePulseTo(runDir: string, dest: string): void { + const src = path.join(runDir, "pulse.jld2"); + if (!fs.existsSync(src)) throw new Error("no pulse.jld2 in the run dir yet"); + fs.copyFileSync(src, dest); +} + +/** The team-vault catalog pulses dir if the mount is present, else undefined + * (so the Save-pulse quick-pick hides the catalog option when unmounted). */ +export function catalogPulsesDir(home = process.env.HOME ?? ""): string | undefined { + const d = path.join(home, ".amico", "vaults", "armonissima", "catalog", "pulses"); + return fs.existsSync(d) ? d : undefined; +} diff --git a/packages/extension/src/run_dir_reader.ts b/packages/extension/src/run_dir_reader.ts index 9055a445..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,30 +153,81 @@ 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; + } } -/** Terminal state of a run dir, read + validated in ONE place (review #70: the - * FINISHED→status→result.toml→fidelity orchestration used to live both here - * and in RunsManager.readTerminal — a contract change had to be edited in two - * places or finished-at-discovery diverged from live-completed). +/** spec C promote gate: rendering is tier-blind, PROMOTION is not. A `free`-tier + * run (solvespec.json tier === "free") can only be promoted once its re-rollout + * verification.toml records agree === true — the known optimizer-vs-rollout + * divergence must never be promoted on the optimizer's number. Non-free runs + * (or runs with no solvespec) are always eligible. "pending_verification" means + * the harness hasn't written verification.toml yet (the FINISHED-before-verify + * race) — the caller must NOT promote AND must re-check when it lands, never + * mark the run permanently un-promotable. */ +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 + if (spec?.tier !== "free") return "eligible"; + const verification = readTomlSafe(path.join(runDir, "verification.toml")); + if (!verification) return "pending_verification"; + return verification.agree === true ? "eligible" : "suppressed"; +} + +/** True iff the solve halted via the cooperative stop-file. The solver prints + * AMICODE_STOPPED only when its Ipopt intermediate callback actually returned + * false, so key on the marker — NOT the STOP file's presence, which only proves + * the request was made and can race a genuine convergence. Shared by both + * completion paths (ingestRunDir here + file_watcher.onFinished). */ +export function detectStopped(runDir: string): boolean { + try { + return fs.readFileSync(path.join(runDir, "run.log"), "utf8").includes("AMICODE_STOPPED"); + } catch { + return false; + } +} + +/** Terminal state of a run dir, read + validated in ONE place (review #70 — + * the #84 funnel; RunsManager.readTerminal and ingestRunDir both delegate). + * + * ONE-SPINE MIRROR: the opencode fork's run-status/run-series endpoints keep + * a documented mirror of these semantics (harmoniqs/opencode, + * packages/opencode/src/server/amicode/run-terminal.ts). If you change the + * terminal semantics here — status field authority, torn-FINISHED retry, the + * AMICODE_STOPPED relabel, fidelity-only-from-result.toml — change them there + * in the same change-set. Also mirrored (same rule): the 10-min stall + * threshold (STALL_AFTER_MS here in run_controls.ts / runs_manager.ts vs the + * fork's problems.ts) and the display vocabulary (the fork maps + * completed→"finished"; extension surfaces render RunStatus directly). + * Folds the cooperative-stop relabel in: a user-stop exits 0 → FINISHED says + * "completed"; relabel to "stopped" (AMICODE_STOPPED marker) so no consumer + * reads it as a genuine convergence and promote is skipped by construction. * * Returns undefined while FINISHED is absent OR present-but-torn/invalid * (mid-write) — callers retry on their next pass. A present-but-invalid - * result.toml is NAMED via `onInvalidResult` (S4: say why, never silently - * drop fidelity); the default keeps this reader vscode-free via console.warn. */ + * result.toml is NAMED via `onInvalidResult` (S4). */ export function readTerminalState( runDir: string, onInvalidResult: (why: string) => void = (why) => console.warn(`[amico] ${why}`), ): { status: RunStatus; fidelity?: number } | undefined { const finished = readTomlSafe(path.join(runDir, "FINISHED")); if (!finished || !validateFinished(finished).ok) return undefined; - const status = finished.status as RunStatus; + const rawStatus = finished.status as RunStatus; + const status: RunStatus = rawStatus === "completed" && detectStopped(runDir) ? "stopped" : rawStatus; let fidelity: number | undefined; if (status === "completed") { const result = readTomlSafe(path.join(runDir, "result.toml")); @@ -175,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"); @@ -192,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); @@ -202,12 +289,15 @@ export function ingestRunDir(runDir: string, sink: RunSink, promoteThreshold = 0 } // FINISHED is the authoritative terminal signal — single orchestration point - // (readTerminalState) shared with the manager's finished-at-discovery path. + // (readTerminalState: status incl. stopped-relabel + fidelity) shared with + // the manager's finished-at-discovery path. const t = readTerminalState(runDir); if (!t) return logBytes; - sink.run({ runId, runDir, status: t.status, fidelity: t.fidelity }); + sink.run({ runId, runDir, ...t }); if (t.status === "completed" && t.fidelity !== undefined && t.fidelity >= promoteThreshold) { - sink.promote({ runId, runDir, fidelity: t.fidelity }); + 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}`); } return logBytes; } diff --git a/packages/extension/src/run_inspector.ts b/packages/extension/src/run_inspector.ts index bd85a02a..91495f5b 100644 --- a/packages/extension/src/run_inspector.ts +++ b/packages/extension/src/run_inspector.ts @@ -20,6 +20,14 @@ import type { PulseEvent, PulseMeta, PulseRecord } from "./run_dir_reader"; 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 +} + let INSPECTOR: InspectorView | undefined; /** Everything replayable about one run's pane. Kept current whether or not the @@ -31,8 +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) pulseTimer?: NodeJS.Timeout; pendingPulse?: PulseRecord; } @@ -66,12 +75,27 @@ class InspectorView implements vscode.WebviewViewProvider { localResourceRoots: inspectorResourceRootDirs(this.ctx.extensionUri.fsPath).map((d) => vscode.Uri.file(d)), }; view.webview.html = this.renderHtml(view.webview); - view.onDidDispose(() => { this.view = undefined; this.clearAllTimers(); }); + + // Control row (Stop / Save pulse / Open dir): the view posts + // {type:"control", action}; forward to the matching command, which resolves + // 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 ?? ""]; + if (cmd) void vscode.commands.executeCommand(cmd); + }); + 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 - // pane the order mirrors the live stream: runlabel → warming → pulsemeta → - // pulse → iteration → completed (terminal state stays the last word). + // pane the order mirrors the live stream: runlabel → timing → warming → + // pulsemeta → pulse → iteration → completed (terminal stays the last word). for (const p of this.panes.values()) this.replayPane(view, p); // Then pick the visible pane. activate is idempotent and last, so it wins // regardless of pane-replay order above. @@ -81,11 +105,18 @@ class InspectorView implements vscode.WebviewViewProvider { private replayPane(view: vscode.WebviewView, p: PaneBuffer): void { const rid = p.runId; if (p.runLabel !== undefined) view.webview.postMessage({ type: "runlabel", runId: rid, text: p.runLabel }); + if (p.timing) view.webview.postMessage({ type: "timing", runId: rid, ...p.timing }); if (p.warming) view.webview.postMessage({ type: "warming", runId: rid }); 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) -------- @@ -93,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() }); } @@ -118,7 +156,11 @@ class InspectorView implements vscode.WebviewViewProvider { if (p.completion || p.iterRecord || p.pulseRecord) return; p.warming = true; if (!this.view) { - vscode.commands.executeCommand("amicode.runInspector.focus").then(undefined, () => undefined); + // Buffered in the pane regardless (shows when the user opens the panel); + // only steal focus when the auto-open setting is enabled (his UX gate). + if (autoOpenEnabled()) { + vscode.commands.executeCommand("amicode.runInspector.focus").then(undefined, () => undefined); + } return; } this.view.webview.postMessage({ type: "warming", runId }); @@ -140,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; @@ -160,33 +205,44 @@ class InspectorView implements vscode.WebviewViewProvider { if (this.view) this.view.webview.postMessage({ type: "runlabel", runId, text: label }); } + /** Timing for the elapsed/rate/ETA strip (ported from the single-run host — + * now runId-keyed + pane-buffered like every other message). */ + postTiming(runId: string, t: TimingInfo): void { + const p = this.paneFor(runId); + p.timing = { ...p.timing, ...t }; + if (this.view) this.view.webview.postMessage({ type: "timing", runId, ...p.timing }); + } + /** 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 }); } reveal(): void { - // Force materialize the view via its auto-registered .focus command. - // Unconditional — without an existing view, this is what creates one. - vscode.commands.executeCommand("amicode.runInspector.focus") - .then(undefined, () => undefined); + // Auto-reveal only when opted in (amicode.inspector.autoOpen). Off by + // 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); } // -------- 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 @@ -204,7 +260,8 @@ class InspectorView implements vscode.WebviewViewProvider { + style-src ${webview.cspSource} 'unsafe-inline'; + font-src ${webview.cspSource};"> @@ -215,6 +272,13 @@ class InspectorView implements vscode.WebviewViewProvider { } } +/** Whether a starting solve should auto-reveal the panel. Default off — the + * status-bar item / "Amicode: Open Run Inspector" are the on-demand entry + * points. The explicit open command bypasses reveal(), so it always works. */ +export function autoOpenEnabled(): boolean { + return vscode.workspace.getConfiguration("amicode").get("inspector.autoOpen", false); +} + function newNonce(): string { let s = ""; const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; diff --git a/packages/extension/src/run_timing.ts b/packages/extension/src/run_timing.ts new file mode 100644 index 00000000..0f53020c --- /dev/null +++ b/packages/extension/src/run_timing.ts @@ -0,0 +1,30 @@ +// Pure timing helpers for the Run Inspector elapsed/rate/ETA strip. No I/O, no +// DOM — unit-tested in node. The caller supplies file contents / timestamps. + +/** Parse `max_iter = N` from a solve script's text (for ETA). Undefined if absent. */ +export function parseMaxIter(scriptText: string): number | undefined { + const m = /^\s*max_iter\s*=\s*(\d+)/m.exec(scriptText); + return m ? Number(m[1]) : undefined; +} + +/** Seconds remaining ≈ (maxIter − iter) / rate. Undefined without a max or a + * positive rate (so the caller omits the ETA term rather than showing garbage). */ +export function computeEta(o: { iter: number; maxIter?: number; ratePerSec: number }): number | undefined { + if (!o.maxIter || o.ratePerSec <= 0) return undefined; + return Math.max(0, o.maxIter - o.iter) / o.ratePerSec; +} + +/** Compact m/s formatting: 9 → "9s", 134 → "2m14s". */ +export function formatElapsed(seconds: number): string { + const s = Math.max(0, Math.floor(seconds)); + return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s`; +} + +/** Iterations/sec from a window of iteration arrival timestamps (ms). Undefined + * with fewer than two samples or a zero span. */ +export function ratePerSec(timestampsMs: number[]): number | undefined { + if (timestampsMs.length < 2) return undefined; + const span = timestampsMs[timestampsMs.length - 1] - timestampsMs[0]; + if (span <= 0) return undefined; + return ((timestampsMs.length - 1) / span) * 1000; +} diff --git a/packages/extension/src/runs_manager.ts b/packages/extension/src/runs_manager.ts index 0a5b2e42..fd2fad87 100644 --- a/packages/extension/src/runs_manager.ts +++ b/packages/extension/src/runs_manager.ts @@ -4,12 +4,24 @@ import * as vscode from "vscode"; import { getInspector } from "./run_inspector"; import { LogTailer } from "./log_tailer"; import { parseIndexLine, RunRegistry, type RunRecord } from "./run_registry"; +import { parseMaxIter } from "./run_timing"; import type { StatusBarManager } from "./status_bar"; import type { RunStatus } from "./types"; import { - AMICODE_ITER_RE, ingestRunDir, readTerminalState, 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"; // ============================================================================ // RunsManager (1.2, #57) — the multi-run evolution of β's RunsRootWatcher. @@ -50,6 +62,10 @@ export interface RunsManagerOptions { channel: vscode.OutputChannel; statusBar?: StatusBarManager; promoteThreshold?: number; + /** Live-only run-completion hook (spec-20260705-002847 §4.1 trigger 1) — + * fired at most once per run, from LIVE completions only (never the boot + * replay, which would re-trigger distills for historical runs). */ + onRunFinished?: (info: { runId: string; runDir: string; status: string }) => void; } /** The #56 Scheduler's lifecycle surface (structural — see amico-run scheduler.ts). */ @@ -76,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; @@ -99,6 +122,10 @@ export class RunsManager implements vscode.Disposable { * solve starting must never yank the view off a run the user deliberately * opened (review #70; the seam 1.3's selection UI builds on). */ private pinned = false; + /** True during start()'s synchronous index replay — runs discovered at BOOT + * are registered/selected for state, but never reveal the inspector or show + * warming focus (only a run that starts while the user works should). */ + private booting = false; private schedulerDispose?: () => void; /** Promote-once + never-on-replay: runs finished at DISCOVERY are pre-marked * so only a fresh live completion prompts (ports β's finishedAtSwitch). */ @@ -122,7 +149,9 @@ export class RunsManager implements vscode.Disposable { if (e) this.registerRun(e.runId, path.join(this.opts.runsRoot, e.runId), e.createdAt, e.scriptPath); }, }); - this.indexTailer.start(); + this.booting = true; + 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(); }); @@ -130,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 @@ -143,13 +174,38 @@ export class RunsManager implements vscode.Disposable { this.checkFinished(p); p.tailer?.poke(); } - } catch { /* transient fs race — next tick retries */ } + // Stall re-check for the SELECTED run: routeIter only fires when a line + // arrives — which by definition means "not stalled" — so a run that + // 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, + }); + } + } 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; @@ -169,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}` : ""}`, + ); }); } @@ -193,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, @@ -203,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, - status: r.phase === "finished" ? (r.status ?? "completed") : "running", - latestIter: r.latestIter, fidelity: r.fidelity, + runId, + outputDir: r.runDir, + startedAt: 0, + status: r.phase === "finished" ? (r.status ?? "completed") : this.liveStatus(r.runDir), + 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 @@ -238,6 +302,27 @@ export class RunsManager implements vscode.Disposable { return this.selected; } + /** The selected run's dir — the target for the Run Inspector's Stop / Save / + * Open controls (ported from the single-run watcher's activeRunDir). */ + getActiveRunDir(): string | undefined { + 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 { @@ -258,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; } @@ -273,6 +366,24 @@ export class RunsManager implements vscode.Disposable { const p = new RunPipeline(runId, runDir); this.pipelines.set(runId, p); + // Timing base for the pane's elapsed/rate/ETA strip: created_at → live + // elapsed; max_iter (parsed from the run's actual script) → ETA. Best-effort. + { + 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, + }); + } + // Auto-follow BEFORE the replay (β latest-follow parity: a newly REGISTERED // live run is by definition the newest start) — unless an explicit selection // is pinned. The single ingest below fans the run's history into ITS pane @@ -283,7 +394,7 @@ export class RunsManager implements vscode.Disposable { if (follow && this.selected !== runId) { this.selected = runId; const ins = getInspector(); - ins?.reveal(); + if (!this.booting) ins?.reveal(); // boot replay must not steal focus ins?.setRunLabel(runId, runId); ins?.activate(runId); } @@ -292,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 @@ -312,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); }, @@ -321,7 +443,7 @@ export class RunsManager implements vscode.Disposable { // Fresh/live run with no data yet → Julia warming up (post-replay, β order). // Disk-checked: a torn FINISHED (fall-through above) must not read "warming". - if (follow && !fs.existsSync(path.join(runDir, "FINISHED"))) { + if (follow && !this.booting && !fs.existsSync(path.join(runDir, "FINISHED"))) { getInspector()?.setWarmingUp(runId); } } @@ -335,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), }; } @@ -352,11 +477,28 @@ export class RunsManager implements vscode.Disposable { iter: (r: IterRecord) => { this.registry.noteIter(rid, r.iter); getInspector()?.postIterationRecord(rid, r); - this.opts.statusBar?.setRun({ runId: rid, outputDir: rec.runDir, startedAt: 0, status: "running", latestIter: r.iter }); + // 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, + }); + } }, 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); @@ -366,6 +508,27 @@ 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 + * bar forever. Mirrors the fork's isStalled (problems.ts, one-spine). + * 2s TTL cache: a boot replay delivers thousands of iter lines back-to-back + * and must not pay one statSync per line. */ + private readonly liveStatusCache = new Map(); + private liveStatus(runDir: string): "running" | "stalled" { + const now = Date.now(); + const hit = this.liveStatusCache.get(runDir); + if (hit && now - hit.at < 2000) return hit.val; + 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 */ + } + this.liveStatusCache.set(runDir, { at: now, val }); + return val; + } + private routeIter(p: RunPipeline, rec: IterRecord): void { p.dedup.noteIter(rec.iter); this.registry.noteIter(p.runId, rec.iter); @@ -375,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: "running", latestIter: rec.iter }); + this.opts.statusBar?.setRun({ + runId: p.runId, + outputDir: p.runDir, + startedAt: 0, + status: this.liveStatus(p.runDir), + latestIter: rec.iter, + }); } } @@ -389,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 }); } @@ -405,18 +574,33 @@ 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. getInspector()?.postCompletion(c.runId, c.status, c.fidelity); + // Freeze the elapsed strip at the recorded wall time (now − created_at + // would overshoot for a run that finished before the panel opened). + const result = readTomlSafe(path.join(rec.runDir, "result.toml")); + const wallSeconds = typeof result?.wall_seconds === "number" ? result.wall_seconds : undefined; + 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 }); @@ -439,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/scores/compiler.ts b/packages/extension/src/scores/compiler.ts new file mode 100644 index 00000000..b7a2d513 --- /dev/null +++ b/packages/extension/src/scores/compiler.ts @@ -0,0 +1,124 @@ +import * as path from "node:path"; +import { Score } from "./loader"; +import { ScoreManifest, Stage } from "./schema"; + +// 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. + +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', + "plain text. A stage marked *(optional)* may be skipped. A stage with a gate must", + "not be entered until the gate's checks pass.", +]; + +/** Render one score's stages as numbered markdown, resolving template paths + * against THAT score's dir. `start` offsets the numbering for chained scores. */ +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` : "", + ] + .filter(Boolean) + .join(" "); + lines.push(`${start + 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(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)`); + } + }); + return lines; +} + +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, + "", + "### Stages (in order)", + "", + ...renderStages(m.stages, score.dir, 0), + ]; + lines.push("", "---", "", score.body.trim(), ""); + return lines.join("\n"); +} + +/** Chain an onboarding score into a tail score (spec-20260705-002847 §3 stage 6): + * ONE compiled section, so the boot-time score0 mechanism and the stage guard + * work unmodified. Overture stages (no `emits`) are guard-transparent; the tail + * (pulse-designer) stages follow, numbered continuously, with template paths + * resolved against the tail's own dir. Both bodies are included, the tail body + * after an explicit handoff marker. */ +export function compileChainedScore(head: Score, tail: Score): string { + const lines: string[] = [ + `## Pulse-designer interview`, + "", + `> Compiled from score \`${head.manifest.id}\` v${head.manifest.version} chained into ` + + `\`${tail.manifest.id}\` v${tail.manifest.version} — first onboard the user (session zero), ` + + `then continue straight into pulse design in the SAME session. Sources of truth are the two ` + + `\`SCORE.md\` files; do not edit this section by hand.`, + "", + ...INTERVIEW_CONTRACT, + "", + "### Stages (in order)", + "", + ...renderStages(head.manifest.stages, head.dir, 0), + ...renderStages(tail.manifest.stages, tail.dir, head.manifest.stages.length), + "", + "---", + "", + head.body.trim(), + "", + "---", + "", + "## After onboarding — continue into pulse design", + "", + "Once you have recorded `onboarding_completed` at the handoff stage, do NOT stop:", + "flow directly into the pulse-design interview below, in this same session, using the", + "user's just-recorded profile and environment to skip questions they've already", + "answered.", + "", + tail.body.trim(), + "", + ]; + return lines.join("\n"); +} + +/** The merged manifest for the score_manifest.json transport (the guard reads + * `.manifest.stages`). Identity is the head score (id `overture`); stages are + * head ++ tail so the guard knows the full flow. */ +export function chainManifest(head: Score, tail: Score): ScoreManifest { + return { ...head.manifest, stages: [...head.manifest.stages, ...tail.manifest.stages] }; +} + +// Replace the "## Pulse-designer interview" section (through the next h2) with the +// compiled content, prefixed by the router section. If the heading is missing the +// compiled content is appended — the injection must never lose content. +export function spliceIntoAgentsMd(agentsMd: string, routerSection: string, compiledScore: string): string { + const block = `${routerSection}\n\n${compiledScore}`; + const start = agentsMd.indexOf("## Pulse-designer interview"); + if (start === -1) return `${agentsMd}\n\n${block}`; + const rest = agentsMd.slice(start + 1); + const nextH2 = rest.search(/\n## /); + const end = nextH2 === -1 ? agentsMd.length : start + 1 + nextH2 + 1; + return agentsMd.slice(0, start) + block + "\n" + agentsMd.slice(end); +} diff --git a/packages/extension/src/scores/entitlements.ts b/packages/extension/src/scores/entitlements.ts new file mode 100644 index 00000000..0adba2ea --- /dev/null +++ b/packages/extension/src/scores/entitlements.ts @@ -0,0 +1,74 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { parse as parseToml } from "smol-toml"; +import { Score } from "./loader"; + +// D3 *interface* — the access-code redemption service (separate plan) implements +// EntitlementProvider later; the extension never needs to know which one it got. +export interface EntitlementResult { + entitlements: string[]; + error?: "invalid_code" | "expired_code"; +} + +export interface EntitlementProvider { + resolve(): Promise; +} + +// v1 local stub: reads /entitlements.toml — `codes = [...]` (+ optional +// `expired = [...]`). Missing file = public-only, silently. Malformed = named error +// with public fallback: an entitlement failure must never dead-end the session. +// Sync core so the (synchronous) session-prep path can use it; the async +// EntitlementProvider interface is what the future redemption service implements. +export function readLocalEntitlements(configDir: string): EntitlementResult { + const file = path.join(configDir, "entitlements.toml"); + if (!fs.existsSync(file)) return { entitlements: [] }; + let parsed: { codes?: string[]; expired?: string[] }; + try { + parsed = parseToml(fs.readFileSync(file, "utf8")) as typeof parsed; + } catch { + return { entitlements: [], error: "invalid_code" }; + } + const result: EntitlementResult = { entitlements: parsed.codes ?? [] }; + if ((parsed.expired ?? []).length > 0) result.error = "expired_code"; + return result; +} + +export class LocalEntitlementProvider implements EntitlementProvider { + constructor(private readonly configDir: string) {} + + async resolve(): Promise { + return readLocalEntitlements(this.configDir); + } +} + +// Empty/absent entitlements = public score, always visible (spec §5). +export function filterRepertoire(scores: Score[], ents: string[]): Score[] { + return scores.filter( + (s) => (s.manifest.entitlements ?? []).length === 0 || s.manifest.entitlements!.some((e) => ents.includes(e)), + ); +} + +// 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/src/scores/interview_state.ts b/packages/extension/src/scores/interview_state.ts new file mode 100644 index 00000000..5222f18b --- /dev/null +++ b/packages/extension/src/scores/interview_state.ts @@ -0,0 +1,56 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; + +// Session-scoped interview state, [score] slice (spec §6; interview-UX §4 allows +// JSON or TOML — JSON here matches the plugin's established system.json sidecar +// pattern, since the opencode plugin deliberately carries no TOML parser). +// completed_stages + gates are the additive extension the stage guard reads. +// score_version pins the score version the session started on: revising a score +// never disturbs an in-flight session (spec §8 / success criterion 8). + +export interface GateRecord { + result: "pass" | "fail" | "override"; + ts: string; + override_reason: string; // "" when not an override — never null (state-file house rule) +} + +export interface ScoreState { + score_id: string; + score_version: number; + stage_cursor: string; + completed_stages: string[]; + answers: Record; + entity_refs: string[]; + gates: Record; +} + +const FILE = "interview_state.json"; + +export function newState(scoreId: string, scoreVersion: number): ScoreState { + return { + score_id: scoreId, + score_version: scoreVersion, + stage_cursor: "", + completed_stages: [], + answers: {}, + entity_refs: [], + gates: {}, + }; +} + +export function loadState(dir: string): ScoreState | undefined { + const file = path.join(dir, FILE); + if (!fs.existsSync(file)) return undefined; + try { + return JSON.parse(fs.readFileSync(file, "utf8")) as ScoreState; + } catch { + return undefined; + } +} + +export function saveState(dir: string, state: ScoreState): void { + const file = path.join(dir, FILE); + const tmp = file + ".tmp"; + fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + "\n"); + fs.renameSync(tmp, file); +} diff --git a/packages/extension/src/scores/lint.ts b/packages/extension/src/scores/lint.ts new file mode 100644 index 00000000..b36673c3 --- /dev/null +++ b/packages/extension/src/scores/lint.ts @@ -0,0 +1,27 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { RepertoireLoad } from "./loader"; + +// Repertoire-wide contract lint — spec §3. Entity/gate/version/schema_version rules are +// already enforced by validateScoreManifest at load time; this covers the cross-file rules. +export function lintRepertoire(load: RepertoireLoad, memoryRoot: string, knownEntitlements: string[]): string[] { + const errs: string[] = []; + for (const e of load.errors) errs.push(`${e.path}: ${e.errors.join("; ")}`); + const ids = new Set(load.scores.map((s) => s.manifest.id)); + for (const score of load.scores) { + const label = score.manifest.id; + for (const stage of score.manifest.stages) { + if (stage.template && !fs.existsSync(path.join(score.dir, stage.template))) + errs.push(`${label}: stage ${stage.id}: template does not resolve: ${stage.template}`); + for (const q of stage.questions ?? []) + for (const hook of q.memory_hooks ?? []) + if (!fs.existsSync(path.join(memoryRoot, `${hook}.md`))) + errs.push(`${label}: stage ${stage.id}: question ${q.id}: memory hook does not resolve: ${hook}`); + } + const from = score.manifest.derived_from; + if (from && !ids.has(from)) errs.push(`${label}: derived_from names an unknown score id: ${from}`); + for (const ent of score.manifest.entitlements ?? []) + if (!knownEntitlements.includes(ent)) errs.push(`${label}: unregistered entitlement id: ${ent}`); + } + return errs; +} diff --git a/packages/extension/src/scores/loader.ts b/packages/extension/src/scores/loader.ts new file mode 100644 index 00000000..049d8dd0 --- /dev/null +++ b/packages/extension/src/scores/loader.ts @@ -0,0 +1,42 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { parse as parseYaml } from "yaml"; +import { ScoreManifest, validateScoreManifest } from "./schema"; + +export interface Score { + manifest: ScoreManifest; + body: string; + dir: string; +} + +export interface RepertoireLoad { + scores: Score[]; + errors: { path: string; errors: string[] }[]; +} + +export function parseScoreMd(content: string, sourcePath = ""): { manifest: ScoreManifest; body: string } { + const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/); + if (!m) throw new Error(`${sourcePath}: missing --- frontmatter block`); + const manifest = parseYaml(m[1]) as ScoreManifest; + const errs = validateScoreManifest(manifest); + if (errs.length) throw new Error(`${sourcePath}: invalid score manifest:\n ${errs.join("\n ")}`); + return { manifest, body: m[2] ?? "" }; +} + +// A broken score must never take down the repertoire — it is reported, not thrown. +export function loadRepertoire(root: string): RepertoireLoad { + const out: RepertoireLoad = { scores: [], errors: [] }; + if (!fs.existsSync(root)) return out; + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory() || entry.name === "memory") continue; + const scorePath = path.join(root, entry.name, "SCORE.md"); + if (!fs.existsSync(scorePath)) continue; + try { + const { manifest, body } = parseScoreMd(fs.readFileSync(scorePath, "utf8"), scorePath); + out.scores.push({ manifest, body, dir: path.join(root, entry.name) }); + } catch (e) { + out.errors.push({ path: scorePath, errors: [String(e)] }); + } + } + return out; +} diff --git a/packages/extension/src/scores/package_skills.ts b/packages/extension/src/scores/package_skills.ts new file mode 100644 index 00000000..db5c88d6 --- /dev/null +++ b/packages/extension/src/scores/package_skills.ts @@ -0,0 +1,146 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { parse as parseYaml } from "yaml"; // same parser as scores/loader.ts + +// Dual-source skill index (spec-20260704-113005 §1/§3). Two skill TYPES: +// - PACKAGE skills: co-located at packages/

.jl/skills//SKILL.md, +// discovered ONLY for entitlement-allowlisted packages (gated). +// - PLATFORM skills: cross-package physics refs in the central amico-plugin +// library, discovered by an explicit CONFIGURED NAME LIST (public), never a +// whole-dir scan — the library holds ~50 process skills that must not leak. +// Content is read on demand by the agent — never baked into the prompt or the +// .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) + name: string; + description: string; + path: string; // absolute SKILL.md path +} + +function expandHome(p: string): string { + if (p === "~") return process.env.HOME ?? p; + if (p.startsWith("~/")) return path.join(process.env.HOME ?? "", p.slice(2)); + return p; +} + +/** Parse a SKILL.md's frontmatter; throw on anything malformed (caller skips). */ +function readFrontmatter(skillPath: string): { name: string; description: string } { + const raw = fs.readFileSync(skillPath, "utf8"); + const m = raw.match(/^---\n([\s\S]*?)\n---/); + if (!m) throw new Error("missing frontmatter"); + const fm = parseYaml(m[1]) as { name?: string; description?: string }; + if (typeof fm.name !== "string" || typeof fm.description !== "string") + throw new Error("frontmatter needs name + description"); + return { name: fm.name, description: fm.description }; +} + +/** Package skills for allowlisted packages (gated). First root containing + * `

.jl/skills` wins. Missing repo / no skills dir → silently skipped. */ +export function resolvePackageSkills(allowlist: string[], roots: string[]): SkillIndexEntry[] { + const out: SkillIndexEntry[] = []; + 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; + } + }); + if (!skillsDir) continue; // no repo / no skills — silently skipped (spec §9) + let names: string[] = []; + 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; + try { + const fm = readFrontmatter(skillPath); + out.push({ source: "package", package: pkg, name: fm.name, description: fm.description, path: skillPath }); + } catch (e) { + console.warn(`amicode: skipping malformed skill ${skillPath}: ${e}`); // never dead-end (spec §9) + } + } + } + return out; +} + +/** Platform skills from the central library (spec §3, Rev 2). PUBLIC by + * construction — NO entitlement input. Only the CONFIGURED names are looked + * up: the library dir also holds ~50 process skills that must never leak into + * the Amicode prompt (explicit list is the guard; marker-based discovery is a + * recorded follow-up). First root containing `/SKILL.md` wins. */ +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)); + if (!skillPath) continue; // configured-but-absent — silently skipped + try { + const fm = readFrontmatter(skillPath); + out.push({ source: "library", name: fm.name, description: fm.description, path: skillPath }); + } catch (e) { + console.warn(`amicode: skipping malformed library skill ${skillPath}: ${e}`); + } + } + return out; +} + +/** Stage the resolved (guarded) skill set as opencode-native skills for this + * session: copy each SKILL.md to `//SKILL.md` so opencode's + * loader — pointed HERE via config `skills.paths` (an absolute dir) — registers + * exactly this set and no more. We must NOT point `skills.paths` at a library + * root: opencode scans it recursively for `**​/SKILL.md`, which would leak the + * ~50 process skills (the exact guard from spec §3). Folder name = frontmatter + * `name`, satisfying opencode's name-matches-folder rule; content is copied + * verbatim (opencode ignores the extra `agents:` field — verified 2026-07-04). + * Returns the stage root, or "" if nothing was staged (→ no `skills.paths`). */ +export function stageOpencodeSkills(stageRoot: string, entries: SkillIndexEntry[]): string { + if (entries.length === 0) return ""; + let staged = 0; + for (const e of entries) { + try { + const dir = path.join(stageRoot, e.name); + fs.mkdirSync(dir, { recursive: true }); + fs.copyFileSync(e.path, path.join(dir, "SKILL.md")); + staged++; + } catch (err) { + console.warn(`amicode: could not stage skill ${e.name} for opencode: ${err}`); // never dead-end (spec §9) + } + } + return staged > 0 ? stageRoot : ""; +} + +/** Splice one merged index into the prompt — platform entries FIRST (spec §3), + * then package entries. Empty index → empty string (no section at all). + * The skills are registered as opencode-native skills (stageOpencodeSkills + + * config `skills.paths`), so the agent INVOKES them by name — it must not try to + * read a file path (observed 2026-07-04: the agent guessed `atoms` as a skill; + * now it IS one). The index adds the usage guidance opencode's auto-listing + * lacks (physics-reference framing + the §6 verification contract). */ +export function buildSkillIndexSection(entries: SkillIndexEntry[]): string { + if (entries.length === 0) return ""; // no section at all (spec §3) + const platform = entries.filter((e) => e.source === "library"); + const pkg = entries.filter((e) => e.source === "package"); + const lines = [ + "## Skill index", // registered opencode skills (platform + package) + "", + "The following are registered as opencode **skills** for this session.", + // Single line on purpose: the invoke-before-authoring instruction is asserted as one regex. + "**Invoke a skill by its name to load its full reference BEFORE authoring any script on its platform or importing its package** —", + "it carries construction patterns, integrator selection, and the verification", + "contract your script must emit.", + "", + ...platform.map( + (e) => + `- **${e.name}** (platform reference) — ${e.description}\n - Use as physics reference — inline the constants; authored scripts stay self-contained (no \`include\` of demo-repo files).`, + ), + ...pkg.map((e) => `- **${e.name}** (package: ${e.package}) — ${e.description}`), + "", + ]; + return lines.join("\n"); +} diff --git a/packages/extension/src/scores/router.ts b/packages/extension/src/scores/router.ts new file mode 100644 index 00000000..acc98fcc --- /dev/null +++ b/packages/extension/src/scores/router.ts @@ -0,0 +1,43 @@ +import { Score } from "./loader"; + +// The onset router — a meta question-tree over the visible repertoire (spec §5). +// Pure: the caller filters by entitlement first. Score #0 (pulse-designer) renders +// as the fixed "Start from a system" option, never as an application entry card. +const SYSTEM_FIRST_SCORE = "pulse-designer"; + +export function buildRouterSection(visible: Score[]): string { + const cards = visible.filter((s) => s.manifest.id !== SYSTEM_FIRST_SCORE); + const lines: string[] = [ + "## Onset router", + "", + "When a session opens without a specific request, after your one-line Amico", + 'intro ask exactly one question — "What do you want to do today?" — via', + "`amicode_ask` when available, with these options:", + "", + ]; + if (cards.length > 0) { + lines.push("**Start from an application** — offer these entry cards:", ""); + for (const s of cards) { + const m = s.manifest; + const badge = m.device ? (m.device.qpu_runnable ? "QPU-runnable" : "emulator-only") : ""; + const bits = [m.outcome, m.duration_estimate, badge].filter(Boolean).join(" · "); + lines.push(`- \`${m.id}\` — **${m.name}**: ${bits}`); + } + lines.push(""); + } + lines.push( + `**Start from a system** — run the \`${SYSTEM_FIRST_SCORE}\` score (the platform-first interview below).`, + "", + "**Bring your own problem** — the user has papers, notes, or a graph file;", + "extract candidate entities, confirm each one before recording, then join the", + "best-matching score mid-path. If nothing usable is found, say so and offer", + "the other options — never a dead end. If candidates match multiple scores", + "equally, ask; never route by silent heuristic.", + "", + "**Resume where you left off** — read the session's interview state and", + "continue from its stage cursor.", + "", + "**Just explore** — free-form; no interview rail.", + ); + return lines.join("\n"); +} diff --git a/packages/extension/src/scores/schema.ts b/packages/extension/src/scores/schema.ts new file mode 100644 index 00000000..94130ff9 --- /dev/null +++ b/packages/extension/src/scores/schema.ts @@ -0,0 +1,88 @@ +// Score manifest schema — spec §3 (spec-20260703-025314-amicode-scores-front-of-chain). +// Additive policy (spec §8): unknown fields are ignored; validation only rejects what is +// present-and-wrong or required-and-missing, so older runtimes tolerate newer scores. +export const KNOWN_ENTITIES = [ + "circuit", + "system", + "formulation", + "pulse", + "run", + "device_session", + "knowledge", +] as const; +export const GATE_CLASSES = ["light", "heavy"] as const; +export const SUPPORTED_SCHEMA_VERSIONS = [1] as const; + +export interface Question { + id: string; + prompt: string; + choices?: string[]; + default?: string; + skip_if?: string; + memory_hooks?: string[]; + rationale_ref?: string; + autonomy?: string; +} + +export interface Stage { + id: string; + emits?: string[]; + questions?: Question[]; + executor?: string; + template?: string; + backend?: string; + gate?: (typeof GATE_CLASSES)[number]; + optional?: boolean; +} + +export interface ScoreManifest { + type: "score"; + schema_version: number; + id: string; + version: number; + derived_from: string | null; + name: string; + outcome: string; + audience: string[]; + duration_estimate?: string; + device?: { backend: string; qpu_runnable: boolean; emulators?: string[] }; + entitlements?: string[]; + stages: Stage[]; +} + +export function validateScoreManifest(raw: unknown): string[] { + const errs: string[] = []; + const m = raw as Partial; + if (m?.type !== "score") errs.push(`type must be "score"`); + if (!SUPPORTED_SCHEMA_VERSIONS.includes(m?.schema_version as 1)) + errs.push(`unsupported schema_version: ${m?.schema_version}`); + if (typeof m?.id !== "string" || !m.id) errs.push("id is required"); + if (!Number.isInteger(m?.version) || (m!.version as number) < 1) + errs.push(`version must be a positive integer, got ${m?.version}`); + if (typeof m?.name !== "string" || !m.name) errs.push("name is required"); + if (typeof m?.outcome !== "string" || !m.outcome) errs.push("outcome is required"); + if (!Array.isArray(m?.stages) || m!.stages!.length === 0) { + errs.push("stages must be a non-empty list"); + return errs; + } + const seen = new Set(); + for (const s of m.stages!) { + if (!s.id) { + errs.push("every stage needs an id"); + continue; + } + if (seen.has(s.id)) errs.push(`duplicate stage id: ${s.id}`); + seen.add(s.id); + for (const e of s.emits ?? []) + if (!(KNOWN_ENTITIES as readonly string[]).includes(e)) errs.push(`stage ${s.id}: unknown entity in emits: ${e}`); + if (s.gate && !(GATE_CLASSES as readonly string[]).includes(s.gate)) + errs.push(`stage ${s.id}: unknown gate class: ${s.gate}`); + for (const q of s.questions ?? []) { + if (!q.id) errs.push(`stage ${s.id}: question missing id`); + if (!q.prompt) errs.push(`stage ${s.id}: question ${q.id ?? "?"} missing prompt`); + if (q.default && q.choices && !q.choices.includes(q.default)) + errs.push(`stage ${s.id}: question ${q.id}: default not among choices`); + } + } + return errs; +} diff --git a/packages/extension/src/scores/usage.ts b/packages/extension/src/scores/usage.ts new file mode 100644 index 00000000..cb9bf17d --- /dev/null +++ b/packages/extension/src/scores/usage.ts @@ -0,0 +1,93 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; + +// Usage capture (spec §8: "usage is the design input"). v1 captures, does not learn: +// append-only JSONL per session, timestamps supplied by the caller. This is the +// decision→outcome substrate the learned-traversal work consumes later. + +export type UsageEvent = + | { kind: "session_started"; ts: string; score_id: string; score_version: number } + | { kind: "stage_entered"; ts: string; stage: string } + | { kind: "stage_completed"; ts: string; stage: string } + | { kind: "question_answered"; ts: string; stage: string; question_id: string; default_taken: boolean } + | { kind: "off_path"; ts: string; from_stage: string } + | { kind: "gate"; ts: string; gate: string; result: "pass" | "fail" | "override"; override_reason?: string } + | { kind: "resumed"; ts: string; stage: string }; + +const FILE = "usage.jsonl"; + +export function appendUsage(dir: string, event: UsageEvent): void { + fs.appendFileSync(path.join(dir, FILE), JSON.stringify(event) + "\n"); +} + +export function readUsage(dir: string): UsageEvent[] { + const file = path.join(dir, FILE); + if (!fs.existsSync(file)) return []; + const events: UsageEvent[] = []; + for (const line of fs.readFileSync(file, "utf8").split("\n")) { + if (!line.trim()) continue; + try { + events.push(JSON.parse(line) as UsageEvent); + } catch { + // torn trailing write — tolerate, the funnel skeleton survives + } + } + return events; +} + +export interface Traversal { + score_id: string; + score_version: number; + funnel: { stage: string; entered: boolean; completed: boolean }[]; + off_path_count: number; + defaults_taken: number; + questions_answered: number; + gates: { gate: string; result: string }[]; +} + +export function reconstructTraversal(events: UsageEvent[]): Traversal { + const t: Traversal = { + score_id: "", + score_version: 0, + funnel: [], + off_path_count: 0, + defaults_taken: 0, + questions_answered: 0, + gates: [], + }; + const byStage = new Map(); + for (const e of events) { + switch (e.kind) { + case "session_started": + t.score_id = e.score_id; + t.score_version = e.score_version; + break; + case "stage_entered": { + if (!byStage.has(e.stage)) { + const row = { stage: e.stage, entered: true, completed: false }; + byStage.set(e.stage, row); + t.funnel.push(row); + } + break; + } + case "stage_completed": { + const row = byStage.get(e.stage); + if (row) row.completed = true; + break; + } + case "question_answered": + t.questions_answered += 1; + if (e.default_taken) t.defaults_taken += 1; + break; + case "off_path": + t.off_path_count += 1; + break; + case "gate": + t.gates.push({ gate: e.gate, result: e.result }); + break; + case "resumed": + break; + } + } + return t; +} diff --git a/packages/extension/src/server_manager.ts b/packages/extension/src/server_manager.ts index 960d4d06..2b805251 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 { @@ -38,15 +40,21 @@ 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) { 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 +86,32 @@ 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(); + } + }); } } @@ -134,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/status_bar.ts b/packages/extension/src/status_bar.ts index b8445df1..4cb1efb1 100644 --- a/packages/extension/src/status_bar.ts +++ b/packages/extension/src/status_bar.ts @@ -6,6 +6,38 @@ import type { RunState } from "./types"; // Updated by the rest of the extension via setServerState / setRunState. // ============================================================================ +/** Pure label/tooltip for the status-bar item — unit-tested without VSCode. */ +export function statusBarLabel(serverReady: boolean, run?: RunState): { text: string; tooltip: string } { + 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 "completed": { + const f = run.fidelity; + 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" }; + } +} + export class StatusBarManager { private readonly item: vscode.StatusBarItem; private serverReady = false; @@ -13,7 +45,8 @@ export class StatusBarManager { constructor() { this.item = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100); - this.item.command = "amicode.openChat"; + // A run-state item → clicking opens the run view (the Run Inspector). + this.item.command = "amicode.openInspector"; this.item.show(); this.render(); } @@ -33,33 +66,8 @@ export class StatusBarManager { } private render(): void { - if (!this.serverReady) { - this.item.text = "$(loading~spin) Amicode (booting)"; - this.item.tooltip = "Spawning opencode server…"; - return; - } - if (this.run && this.run.status === "running") { - this.item.text = `$(gear~spin) Amicode · iter ${this.run.latestIter ?? "—"}`; - this.item.tooltip = `Solve running in ${this.run.outputDir}`; - return; - } - if (this.run && this.run.status === "completed") { - const f = this.run.fidelity; - this.item.text = `$(check) Amicode · F=${f !== undefined ? f.toFixed(4) : "—"}`; - this.item.tooltip = `Last solve completed in ${this.run.outputDir}`; - return; - } - if (this.run && this.run.status === "failed") { - this.item.text = "$(error) Amicode · solve failed"; - this.item.tooltip = `Solve failed in ${this.run.outputDir} — see run.log`; - return; - } - if (this.run && this.run.status === "aborted") { - this.item.text = "$(circle-slash) Amicode · aborted"; - this.item.tooltip = `Solve aborted in ${this.run.outputDir}`; - return; - } - this.item.text = "$(comment-discussion) Amicode"; - this.item.tooltip = "Open Amicode chat"; + const { text, tooltip } = statusBarLabel(this.serverReady, this.run); + this.item.text = text; + this.item.tooltip = tooltip; } } diff --git a/packages/extension/src/substrate/distiller.ts b/packages/extension/src/substrate/distiller.ts new file mode 100644 index 00000000..9ec604fa --- /dev/null +++ b/packages/extension/src/substrate/distiller.ts @@ -0,0 +1,93 @@ +/** Extension-side distiller integration (spec-20260705-002847 §4). + * + * The distiller is a headless LLM agent: `opencode run --agent distiller + * ` with its own OPENCODE_CONFIG_CONTENT (built here). The + * deterministic parts — queue, single global lock, drain, spawn — live in the + * shared module opencode-plugin/distill_queue.ts so the Bun-side plugin and + * the batch script produce IDENTICAL distiller processes via the + * distiller.config.json transport this module writes at activation. */ +import * as path from "node:path"; +import * as os from "node:os"; +import { + enqueueJob, + enqueueAndDrain, + writeDistillerConfig, + defaultClock, + type DistillJob, +} from "../../opencode-plugin/distill_queue"; + +export interface DistillerSetup { + /** Absolute path of the (vendored) opencode binary. */ + binary: string; + /** Absolute path of the bundled DISTILLER.md instruction file. */ + distillerMdPath: string; + vaultDir: string; + opsDir: string; + problemsRoot: string; + runsRoot: string; + /** providerID/modelID — pinned so distillation survives chat-provider rate limits. */ + model: string; +} + +export function buildDistillerConfigContent(s: DistillerSetup): Record { + return { + $schema: "https://opencode.ai/config.json", + instructions: [s.distillerMdPath], + agent: { + distiller: { + description: "Amico's background memory distiller (headless; no subagents)", + prompt: + "You are Amico's distiller. Follow the distiller instructions exactly. " + + "Your input message is one JSON job object. Work silently; never spawn " + + "subagents; finish with a one-line summary.", + model: s.model, + }, + }, + permission: { + bash: "allow", + edit: "allow", + external_directory: { + [`${s.vaultDir}/amicode`]: "allow", // the working root (dir listing) + [`${s.vaultDir}/amicode/**`]: "allow", // the ONLY place it writes + [`${s.vaultDir}/.git/**`]: "allow", // pathspec-scoped commits (DISTILLER.md rule 1) + [`${s.opsDir}/**`]: "allow", // onboarding stream + queue state + [`${s.problemsRoot}/**`]: "allow", // events.jsonl reads + [`${s.runsRoot}/**`]: "allow", // result.toml / run.toml / pulse.jld2 reads + [`${path.join(os.homedir(), "harmoniqs", "demos")}/**`]: "allow", // demo-ingest (L1 §3) + }, + }, + }; +} + +/** Written once per activation; every spawner (extension, plugin trigger-4, + * batch shell) reads this file so headless distillers are identical. */ +export function initDistillerTransport(s: DistillerSetup): void { + writeDistillerConfig(s.opsDir, { + binary: s.binary, + config: buildDistillerConfigContent(s), + job_defaults: { vault: s.vaultDir, ops: s.opsDir, runs_root: s.runsRoot }, + }); +} + +function baseJob(s: DistillerSetup): Pick & { vault: string; ops: string; runs_root: string } { + return { vault: s.vaultDir, ops: s.opsDir, runs_root: s.runsRoot }; +} + +/** Trigger 1 (run finished): fire-and-forget — never blocks the extension. */ +export function triggerRunDistill(s: DistillerSetup, runId: string): void { + void enqueueAndDrain(s.opsDir, { kind: "run", run_id: runId, ...baseJob(s) }, defaultClock()).catch(() => {}); +} + +/** Triggers 2+3 (session close / manual): coarse idempotent sweep. */ +export function triggerSweep(s: DistillerSetup, drain: boolean): void { + if (drain) { + void enqueueAndDrain(s.opsDir, { kind: "sweep", ...baseJob(s) }, defaultClock()).catch(() => {}); + } else { + // deactivate path: just queue — the next activation/trigger drains. + try { + enqueueJob(s.opsDir, { kind: "sweep", ...baseJob(s) }); + } catch { + /* queueing must never break deactivate */ + } + } +} diff --git a/packages/extension/src/substrate/user_splice.ts b/packages/extension/src/substrate/user_splice.ts new file mode 100644 index 00000000..069a3a46 --- /dev/null +++ b/packages/extension/src/substrate/user_splice.ts @@ -0,0 +1,44 @@ +/** The personalized splice (spec-20260705-002847 §6): two lean sections built + * from the vault's user-memory files. Both are ≤~3 KB by construction (profile + * capped at ~30 lines by convention, knowledge lines capped at 50 by the + * reader); the agent reads full cards on demand from the granted vault path. */ + +export function buildAboutUserSection(profileMd: string): string { + if (!profileMd) return ""; + return [ + "## About this user", + "", + profileMd.trim(), + "", + "Greet and recommend with this context. Anchor the hardware stage on the", + "user's environment card (read it from the vault path above when you reach", + "that stage). Never re-ask what the profile already answers.", + ].join("\n"); +} + +export function buildReferenceDemosSection(demoLines: string[]): string { + if (demoLines.length === 0) return ""; + return [ + "## Reference demos", + "", + ...demoLines, + "", + "Curated demos we've built — use them as PRECEDENT (medium confidence) when", + "the user's target matches one and there's no own-precedent card. Read the", + "demo card on demand for its params, and cite it in your recommendation.", + ].join("\n"); +} + +export function buildRecentProblemsSection(knowledgeLines: string[]): string { + if (knowledgeLines.length === 0) return ""; + return [ + "## Your recent problems", + "", + ...knowledgeLines, + "", + "Before recommending parameters, check whether the user's target matches one", + "of these cards (read the card file on demand for details). If a pulse exists", + "in the bank, offer a warm start from its `pulse.jld2` path. If a prior", + "attempt failed, surface its lesson before re-authoring.", + ].join("\n"); +} diff --git a/packages/extension/src/substrate/vault_store.ts b/packages/extension/src/substrate/vault_store.ts new file mode 100644 index 00000000..429b236e --- /dev/null +++ b/packages/extension/src/substrate/vault_store.ts @@ -0,0 +1,100 @@ +/** Vault resolution + user-memory readers (spec-20260705-002847 §2, §3 routing). + * + * The personal vault is the first mount under the vaults root whose + * `.amico-vault.toml` marker declares `kind = "personal"`. Everything here is + * read-only and failure-tolerant: a missing vault, file, or stream simply + * yields the empty value and the session proceeds unpersonalized. */ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +export const KNOWLEDGE_LINE_CAP = 50; + +export function defaultVaultsRoot(): string { + return path.join(os.homedir(), ".amico", "vaults"); +} + +/** Ops-side amicode state (queue, lock, onboarding stream, distiller config). + * NOT the vault — operational, per charter/19. */ +export function amicodeOpsDir(): string { + return process.env.AMICODE_OPS_DIR ?? path.join(os.homedir(), ".amico", "amicode"); +} + +export function onboardingDir(opsDir: string = amicodeOpsDir()): string { + return path.join(opsDir, "onboarding"); +} + +export function resolvePersonalVault(vaultsRoot: string, override: string): string { + if (override) return override; + let entries: string[]; + try { + entries = fs.readdirSync(vaultsRoot).sort(); + } catch { + return ""; + } + for (const name of entries) { + const marker = path.join(vaultsRoot, name, ".amico-vault.toml"); + try { + const text = fs.readFileSync(marker, "utf8"); + if (/^\s*kind\s*=\s*"personal"\s*$/m.test(text)) return path.join(vaultsRoot, name); + } catch { + continue; + } + } + return ""; +} + +/** Non-empty PROFILE.md content, or "" (whitespace-only counts as absent — §3). */ +export function readProfileMd(vaultDir: string): string { + try { + const text = fs.readFileSync(path.join(vaultDir, "amicode", "PROFILE.md"), "utf8"); + return text.trim() === "" ? "" : text; + } catch { + return ""; + } +} + +/** List-item lines from an amicode index file, capped. */ +function readIndexLines(vaultDir: string, file: string, cap: number): string[] { + let text: string; + try { + text = fs.readFileSync(path.join(vaultDir, "amicode", file), "utf8"); + } catch { + return []; + } + return text + .split("\n") + .filter((l) => l.startsWith("- ")) + .slice(0, cap); +} + +/** KNOWLEDGE.md list-item lines, capped (§2.3). */ +export function readKnowledgeLines(vaultDir: string, cap: number = KNOWLEDGE_LINE_CAP): string[] { + return readIndexLines(vaultDir, "KNOWLEDGE.md", cap); +} + +/** DEMOS.md list-item lines (L1 §3) — separate index so reference demos never + * age against KNOWLEDGE.md's problem cap. Capped tighter (splice budget ≤~2KB). */ +export function readDemoLines(vaultDir: string, cap = 30): string[] { + return readIndexLines(vaultDir, "DEMOS.md", cap); +} + +/** Second disjunct of the routing predicate (§3): completed marker in the + * onboarding stream. Malformed lines are skipped. */ +export function hasOnboardingCompleted(onboardingStreamDir: string): boolean { + let text: string; + try { + text = fs.readFileSync(path.join(onboardingStreamDir, "events.jsonl"), "utf8"); + } catch { + return false; + } + for (const line of text.split("\n")) { + if (!line.trim()) continue; + try { + if (JSON.parse(line).entity === "onboarding_completed") return true; + } catch { + continue; + } + } + return false; +} 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.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..fa3c1606 --- /dev/null +++ b/packages/extension/templates/skeleton_free.jl @@ -0,0 +1,145 @@ +#!/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)) + # get_drift/get_drives return the bare Hamiltonian MATRICES (sys.H_drift / + # sys.H_drives hold internal DriftTerm/LinearDrive wrappers). drive_bounds is + # stored as (lo,hi) tuples; the harness only needs magnitudes to reconstruct. + JLD2.jldopen("system_verify.jld2", "w") do f + f["schema"] = 1 + f["H_drift"] = Matrix{ComplexF64}(get_drift(sys)) + f["H_drives"] = [Matrix{ComplexF64}(H) for H in get_drives(sys)] + f["goal_kind"] = "unitary" + f["goal"] = U_goal_full + f["subspace"] = subspace_idx + f["drive_bounds"] = [Float64(b[2]) for b in 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) + # Cooperative stop: the Run Inspector's Stop button drops a STOP file into the + # run dir (== cwd). Returning false from Ipopt's intermediate_callback halts + # the solve (User_Requested_Stop) at the next iteration; solve! returns + # normally, so the partial pulse.jld2/result.toml still get written below. + if isfile("STOP") + println("AMICODE_STOPPED"); flush(stdout) + return false + end + 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/templates/solve_rydberg_cz.jl b/packages/extension/templates/solve_rydberg_cz.jl new file mode 100644 index 00000000..e04ed1a4 --- /dev/null +++ b/packages/extension/templates/solve_rydberg_cz.jl @@ -0,0 +1,195 @@ +#!/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) + # Cooperative stop: the Run Inspector's Stop button drops a STOP file into the + # run dir (== cwd). Returning false from Ipopt's intermediate_callback halts + # the solve (User_Requested_Stop) at the next iteration; solve! returns + # normally, so the partial pulse.jld2/result.toml still get written below. + if isfile("STOP") + println("AMICODE_STOPPED"); flush(stdout) + return false + end + 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/templates/solve_template.jl b/packages/extension/templates/solve_template.jl index c971dac2..a8631096 100644 --- a/packages/extension/templates/solve_template.jl +++ b/packages/extension/templates/solve_template.jl @@ -50,6 +50,14 @@ struct PulseEmitCallback <: AbstractIntermediateCallback traj::Any # prob.trajectory — synced from the primal, then read end function (cb::PulseEmitCallback)(primal, iter) + # Cooperative stop: the Run Inspector's Stop button drops a STOP file into the + # run dir (== cwd). Returning false from Ipopt's intermediate_callback halts + # the solve (User_Requested_Stop) at the next iteration; solve! returns + # normally, so the partial pulse.jld2/result.toml still get written below. + if isfile("STOP") + println("AMICODE_STOPPED"); flush(stdout) + return false + end ok = cb.inner(primal, iter) try traj = cb.traj diff --git a/packages/extension/test/__mocks__/vscode.ts b/packages/extension/test/__mocks__/vscode.ts index 2500724f..0507e05b 100644 --- a/packages/extension/test/__mocks__/vscode.ts +++ b/packages/extension/test/__mocks__/vscode.ts @@ -18,9 +18,16 @@ export const window = { onDidReceiveMessage: () => ({ 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/agents_md.test.ts b/packages/extension/test/agents_md.test.ts index be17d45b..5adfc850 100644 --- a/packages/extension/test/agents_md.test.ts +++ b/packages/extension/test/agents_md.test.ts @@ -1,45 +1,147 @@ -import { describe, it, expect } from 'vitest' -import { readFileSync } from 'node:fs' -import { join } from 'node:path' +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; -const AGENTS = readFileSync(join(__dirname, '..', 'AGENTS.md'), 'utf8') +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