From 56bc324164661e83cb115dea6630f6aec249496d Mon Sep 17 00:00:00 2001 From: aaron Date: Sat, 8 Aug 2026 22:10:23 -0400 Subject: [PATCH 1/2] feat: plan-first default agent for all Amicode sessions Every new session opens on opencode's read-only plan agent. Execution (pulse-designer interview, solves) starts only when the user switches agents in the composer. buildOpencodeConfigContent injects `default_agent: "plan"` into the OPENCODE_CONFIG_CONTENT every Amicode server boots with. The blob deep-merges over the user's global config, so this is the product default for all users. Per-message `agent` selections (e2e tests, distiller's --agent) are unaffected. Verification: 867 passed / 3 skipped / 0 failed full fast suite; vendored opencode 1.17.3 accepts the field via the real-binary injection+merge test. Refs: #300 --- AGENTS.md | 4 ++++ packages/extension/src/opencode_config.ts | 9 +++++++++ packages/extension/test/opencode_config.test.ts | 4 ++++ 3 files changed, 17 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index ae38cb3a..0b16ff94 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,6 +86,10 @@ 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 + pulse-designer/build to execute. - 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..74cfcaa0 100644 --- a/packages/extension/src/opencode_config.ts +++ b/packages/extension/src/opencode_config.ts @@ -116,6 +116,8 @@ export function resolveJuliaProject(configValue: string): string { * - `agent: {"pulse-designer": …}` — the interview agent; its prompt defers * to the "Pulse-designer interview" section of the injected AGENTS.md so * the interview script lives in ONE place. + * - `default_agent: "plan"` — plan-first posture: new sessions open on + * opencode's built-in read-only plan agent, not straight into execution. * - 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 +419,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], diff --git a/packages/extension/test/opencode_config.test.ts b/packages/extension/test/opencode_config.test.ts index 0e1c1fee..39d93662 100644 --- a/packages/extension/test/opencode_config.test.ts +++ b/packages/extension/test/opencode_config.test.ts @@ -98,6 +98,10 @@ describe("buildOpencodeConfigContent", () => { expect(pd.prompt).toContain("amicode_"); // record stages via the tool pack expect(pd.prompt).toContain("solve workflow"); // launches stay on the bash workflow }); + it("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 pulse-designer/build to execute + }); it("grants external_directory on the problems root (default + $AMICODE_PROBLEMS_DIR override)", () => { const defGrant = join(homedir(), ".amico", "problems") + "/**"; const cfg = JSON.parse(buildOpencodeConfigContent("/abs/AGENTS.md", TPL, "/home/u/.amico/runs/default")); From da19513be4d1606545b202acf40e010bd678094c Mon Sep 17 00:00:00 2001 From: aaron Date: Sun, 16 Aug 2026 19:23:11 -0400 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20retire=20the=20pulse-designer=20age?= =?UTF-8?q?nt=20shell=20=E2=80=94=20picker=20is=20plan/build=20only=20(#38?= =?UTF-8?q?9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pulse-designer agent was a shell: a four-line prompt deferring to the compiled AGENTS.md interview section, with permissions that were always the config-root block. The interview content is agent-agnostic (visible to every agent via the injected instructions), so the entry is removed and the picker becomes opencode's native plan/build — the roles-not-modes posture (#368). e2e per-message agent selections move to build; the GET /agent assertion now pins the retirement. --- AGENTS.md | 4 +++- packages/extension/src/opencode_config.ts | 22 +++++++++---------- packages/extension/src/scores/compiler.ts | 4 ++-- .../extension/test/opencode_config.test.ts | 18 ++++++--------- .../extension/test/slow/interview_e2e.test.ts | 8 +++---- .../extension/test/slow/scores_e2e.test.ts | 2 +- 6 files changed, 27 insertions(+), 31 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0b16ff94..36573640 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,7 +89,9 @@ macOS note: the vendored binary is unsigned — if Gatekeeper blocks it: - **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 - pulse-designer/build to execute. + 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 74cfcaa0..0de4d636 100644 --- a/packages/extension/src/opencode_config.ts +++ b/packages/extension/src/opencode_config.ts @@ -113,11 +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 @@ -433,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 39d93662..c5e54a13 100644 --- a/packages/extension/test/opencode_config.test.ts +++ b/packages/extension/test/opencode_config.test.ts @@ -89,18 +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 pulse-designer/build to execute + 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") + "/**"; @@ -242,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 {