Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -86,6 +86,12 @@ macOS note: the vendored binary is unsigned — if Gatekeeper blocks it:
- Run artifacts land in `~/.amico/runs/default/<runId>/` (contract: `run.toml`, `AMICODE_ITER`
lines, `iter_*.png`, `result.toml`, `pulse.jld2`, `FINISHED`). Validate files with
`packages/schema/launcher/amico-validate <file>`.
- **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)
Expand Down
31 changes: 19 additions & 12 deletions packages/extension/src/opencode_config.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -417,22 +421,25 @@ 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],
...(skills ? { skills } : {}),
// 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",
Expand Down
4 changes: 2 additions & 2 deletions packages/extension/src/scores/compiler.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,",
Expand Down
20 changes: 10 additions & 10 deletions packages/extension/test/opencode_config.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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") + "/**";
Expand DownExpand Up@@ -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");
});
});

Expand Down
8 changes: 4 additions & 4 deletions packages/extension/test/slow/interview_e2e.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 () => {
Expand DownExpand Up@@ -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 {
Expand DownExpand Up@@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion packages/extension/test/slow/scores_e2e.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 {
Expand Down
Loading