diff --git a/AGENTS.md b/AGENTS.md index ae38cb3a..36573640 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,6 +86,12 @@ macOS note: the vendored binary is unsigned — if Gatekeeper blocks it: - 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 `. +- **New sessions open on opencode's read-only `plan` agent** (plan-first posture for all + users): `buildOpencodeConfigContent` injects `default_agent: "plan"`. The composer + agent-picker (or an explicit per-message `agent`, as in the e2e tests) switches to + build to execute. The picker is plan/build only — the pulse-designer agent shell is + retired (#389); the interview content lives in the compiled AGENTS.md score section, + visible to every agent. - Never commit to `main`; branch + PR. ## Changing opencode (the vendored fork) diff --git a/packages/extension/src/opencode_config.ts b/packages/extension/src/opencode_config.ts index 729b5988..0de4d636 100644 --- a/packages/extension/src/opencode_config.ts +++ b/packages/extension/src/opencode_config.ts @@ -113,9 +113,13 @@ export function resolveJuliaProject(configValue: string): string { * 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. + * - `default_agent: "plan"` — plan-first posture: new sessions open on + * opencode's built-in read-only plan agent, not straight into execution. + * The picker stays plan/build ONLY (roles-not-modes, #368): the interview + * content lives in the compiled AGENTS.md score section (visible to every + * agent), and the pulse-designer agent entry is RETIRED (#389) — it was + * a four-line prompt shell over the config-root permission block, doing + * nothing build did not already do. * - 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 @@ -417,6 +421,13 @@ export function buildOpencodeConfigContent( const skills = skillsStageDir ? { paths: [skillsStageDir] } : undefined; return JSON.stringify({ $schema: "https://opencode.ai/config.json", + // Plan-first posture (product default for ALL users): every new Amicode + // session opens on opencode's built-in, read-only `plan` agent; execution + // (the pulse-designer interview, solves) starts only when the user switches + // agents in the composer. Like everything in this blob, it deep-merges OVER + // the user's global config — an explicit per-message `agent` (the e2e tests, + // the distiller's --agent) is unaffected. + default_agent: "plan", ...(modelPin ? { model: modelPin } : {}), instructions: [agentsPath], plugin: [pluginPath], @@ -424,15 +435,11 @@ export function buildOpencodeConfigContent( // Enable AI-SDK span generation ONLY behind the telemetry gate — deep-merges // into cfg.experimental alongside any user keys (see telemetryOpen above). ...(telemetryOpen ? { experimental: { openTelemetry: true } } : {}), - 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.", - }, - }, + // No `agent` overrides: the picker is opencode's native plan/build only + // (#389 — the pulse-designer agent entry is retired; its prompt was a + // shell deferring to the compiled AGENTS.md interview section, and its + // permission grants were always the config-root block above). The + // interview runs from ANY agent via the injected instructions. permission: { bash: "allow", edit: "allow", diff --git a/packages/extension/src/scores/compiler.ts b/packages/extension/src/scores/compiler.ts index de0d5b58..935be536 100644 --- a/packages/extension/src/scores/compiler.ts +++ b/packages/extension/src/scores/compiler.ts @@ -4,8 +4,8 @@ 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. +// compatibility: spliced section lookups (and the hardcoded fallback section in +// AGENTS.md) match on it by name. Pure and deterministic: same score → same string. const INTERVIEW_CONTRACT = [ "**Interview contract:** ONE question at a time — never batch. Ask, wait, record,", diff --git a/packages/extension/test/opencode_config.test.ts b/packages/extension/test/opencode_config.test.ts index 0e1c1fee..c5e54a13 100644 --- a/packages/extension/test/opencode_config.test.ts +++ b/packages/extension/test/opencode_config.test.ts @@ -89,14 +89,13 @@ describe("buildOpencodeConfigContent", () => { ); expect(withStage.skills).toEqual({ paths: ["/tmp/proj/skills"] }); // absolute per-session dir (guarded set), never a library root }); - it("declares the pulse-designer agent whose prompt defers to the AGENTS.md interview", () => { + it("retires the pulse-designer agent shell — the picker is plan/build only (#389)", () => { const cfg = JSON.parse(buildOpencodeConfigContent("/abs/AGENTS.md", TPL, "/home/u/.amico/runs/default")); - const pd = cfg.agent["pulse-designer"]; - expect(pd.description).toBe("Guided quantum pulse design interview"); - expect(pd.prompt).toContain("one question at a time"); // the interview protocol - expect(pd.prompt).toContain("'Pulse-designer interview'"); // script lives in AGENTS.md, not here - expect(pd.prompt).toContain("amicode_"); // record stages via the tool pack - expect(pd.prompt).toContain("solve workflow"); // launches stay on the bash workflow + expect(cfg.agent ?? {}).toEqual({}); // no custom agents: the interview lives in AGENTS.md, agent-agnostic + }); + it("pins default_agent to plan (plan-first posture for new sessions)", () => { + const cfg = JSON.parse(buildOpencodeConfigContent("/abs/AGENTS.md", TPL, "/home/u/.amico/runs/default")); + expect(cfg.default_agent).toBe("plan"); // read-only open; the user switches to build to execute }); it("grants external_directory on the problems root (default + $AMICODE_PROBLEMS_DIR override)", () => { const defGrant = join(homedir(), ".amico", "problems") + "/**"; @@ -238,15 +237,16 @@ describe.skipIf(!existsSync(OC_BIN))("opencode config injection + merge (1.17.3) // the user's global config SURVIVED the deep-merge: expect(cfg.model).toBe("anthropic/claude-sonnet-4-6"); // provider/model preserved (Q129 needs this) expect(cfg.permission.doom_loop).toBe("deny"); // user permission key preserved (#22) - // L0 pulse-designer registration survived resolution against the REAL binary. + // L0 registration survived resolution against the REAL binary. // NOTE: `debug config` IMPORTS listed plugins before printing JSON to stdout // (verified on 1.17.3) — so JSON.parse(out) succeeding above doubles as a // regression guard that amicode_tools.ts loads cleanly AND never writes to // stdout at module scope (its load line must stay on stderr). expect(cfg.plugin).toHaveLength(1); expect(cfg.plugin[0].endsWith(join("opencode-plugin", "amicode_tools.ts"))).toBe(true); - expect(cfg.agent["pulse-designer"].description).toBe("Guided quantum pulse design interview"); - expect(cfg.agent["pulse-designer"].prompt).toContain("one question at a time"); + // #389: the pulse-designer agent shell is retired; default is plan. + expect(cfg.agent?.["pulse-designer"]).toBeUndefined(); + expect(cfg.default_agent).toBe("plan"); }); }); diff --git a/packages/extension/test/slow/interview_e2e.test.ts b/packages/extension/test/slow/interview_e2e.test.ts index 9ce19e0c..32c5cdb7 100644 --- a/packages/extension/test/slow/interview_e2e.test.ts +++ b/packages/extension/test/slow/interview_e2e.test.ts @@ -121,10 +121,10 @@ afterAll(() => { }); describe.skipIf(!existsSync(OC_BIN))("L0 registration against the real binary (creds-free)", () => { - it("A: pulse-designer appears in GET /agent", { timeout: 60_000 }, async () => { + it("A: the pulse-designer agent shell is retired — picker is plan/build", { timeout: 60_000 }, async () => { const s = await serve({ hermetic: true, port: 14310 }); const agents = (await (await fetch(s.url + "/agent")).json()) as Array<{ name: string }>; - expect(agents.map((a) => a.name)).toContain("pulse-designer"); + expect(agents.map((a) => a.name)).not.toContain("pulse-designer"); // retired (#389): picker is plan/build only }); it.skipIf(!existsSync(PLUGIN))("B: amicode_tools plugin loads on session creation", { timeout: 60_000 }, async () => { @@ -152,7 +152,7 @@ describe.skipIf(!existsSync(OC_BIN) || !hasCreds())("live interview turns (creds const r = await fetch(`${s.url}/session/${ses.id}/message`, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ agent: "pulse-designer", parts: [{ type: "text", text }] }), + body: JSON.stringify({ agent: "build", parts: [{ type: "text", text }] }), }); expect(r.ok, `message POST ${r.status}`).toBe(true); const msg = (await r.json()) as { @@ -208,7 +208,7 @@ describe.skipIf(!existsSync(OC_BIN) || !hasCreds())("live interview turns (creds const r = await fetch(`${s.url}/session/${ses.id}/message`, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ agent: "pulse-designer", parts: [{ type: "text", text }] }), + body: JSON.stringify({ agent: "build", parts: [{ type: "text", text }] }), }); expect(r.ok, `message POST ${r.status}`).toBe(true); const msg = (await r.json()) as { diff --git a/packages/extension/test/slow/scores_e2e.test.ts b/packages/extension/test/slow/scores_e2e.test.ts index d76c6bdf..d04d5568 100644 --- a/packages/extension/test/slow/scores_e2e.test.ts +++ b/packages/extension/test/slow/scores_e2e.test.ts @@ -117,7 +117,7 @@ describe.skipIf(!existsSync(OC_BIN) || !hasCreds())("scores runtime live e2e (cr const r = await fetch(`${s.url}/session/${ses.id}/message`, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ agent: "pulse-designer", parts: [{ type: "text", text }] }), + body: JSON.stringify({ agent: "build", parts: [{ type: "text", text }] }), }); expect(r.ok, `message POST ${r.status}`).toBe(true); const msg = (await r.json()) as {