agent-dealer 1.2.4 → 1.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/bundle/server/dist/adapters/agent-deck-bind.js +33 -3
  2. package/bundle/server/dist/adapters/agent-deck-bind.test.js +60 -2
  3. package/bundle/server/dist/adapters/agent-health.js +28 -6
  4. package/bundle/server/dist/adapters/agent-health.test.js +66 -11
  5. package/bundle/server/dist/adapters/git-worktree.js +187 -18
  6. package/bundle/server/dist/adapters/git-worktree.test.js +293 -0
  7. package/bundle/server/dist/adapters/github.js +110 -12
  8. package/bundle/server/dist/adapters/github.test.js +274 -3
  9. package/bundle/server/dist/adapters/muse-capability.js +356 -0
  10. package/bundle/server/dist/adapters/muse-capability.test.js +464 -0
  11. package/bundle/server/dist/capacity/claude-local-cache.js +266 -81
  12. package/bundle/server/dist/capacity/claude-local-cache.test.js +335 -36
  13. package/bundle/server/dist/capacity/muse-host.js +92 -32
  14. package/bundle/server/dist/capacity/muse-host.test.js +130 -4
  15. package/bundle/server/dist/coordinator/admission.test.js +85 -0
  16. package/bundle/server/dist/coordinator/args.js +4 -3
  17. package/bundle/server/dist/coordinator/checkpoint.js +5 -3
  18. package/bundle/server/dist/coordinator/checkpoint.test.js +23 -0
  19. package/bundle/server/dist/coordinator/commands.js +100 -12
  20. package/bundle/server/dist/coordinator/developer-effect.js +128 -16
  21. package/bundle/server/dist/coordinator/developer-effect.test.js +291 -12
  22. package/bundle/server/dist/coordinator/human-resolution.js +33 -1
  23. package/bundle/server/dist/coordinator/muse-developer.integration.test.js +291 -55
  24. package/bundle/server/dist/coordinator/muse-spawn.js +72 -164
  25. package/bundle/server/dist/coordinator/projection.js +1 -0
  26. package/bundle/server/dist/coordinator/prompts.js +25 -6
  27. package/bundle/server/dist/coordinator/prompts.test.js +36 -8
  28. package/bundle/server/dist/coordinator/routing.js +1 -0
  29. package/bundle/server/dist/coordinator/routing.test.js +20 -0
  30. package/bundle/server/dist/coordinator/spawn.js +9 -1
  31. package/bundle/server/dist/coordinator/worktree-cwd-guard.js +48 -0
  32. package/bundle/server/dist/coordinator/worktree-cwd-guard.test.js +83 -0
  33. package/bundle/server/dist/coordinator/worktree-owner-liveness.js +6 -1
  34. package/bundle/server/dist/coordinator/worktree-owner-liveness.test.js +2 -1
  35. package/bundle/server/dist/repository/human-actions.js +13 -0
  36. package/bundle/server/dist/routes/human-actions.js +10 -1
  37. package/bundle/server/dist/routes/human-actions.test.js +117 -1
  38. package/bundle/server/dist/routes/index.js +12 -5
  39. package/bundle/server/dist/routes/runtime-capacity.test.js +3 -2
  40. package/bundle/server/dist/routes/version.js +30 -0
  41. package/bundle/server/dist/routes/version.test.js +21 -0
  42. package/bundle/server/dist/runners/muse-config-core.js +63 -16
  43. package/bundle/server/dist/runners/muse-config.js +1 -1
  44. package/bundle/server/dist/runners/muse-config.test.js +135 -29
  45. package/bundle/server/dist/runners/spawn-cli.js +17 -3
  46. package/bundle/server/dist/runners/spawn-cli.test.js +59 -0
  47. package/bundle/server/package.json +2 -2
  48. package/bundle/server/static-ui/assets/{index-CYqRh_-S.css → index-BII-LgB8.css} +1 -1
  49. package/bundle/server/static-ui/assets/index-CoDzZcsz.js +60 -0
  50. package/bundle/server/static-ui/index.html +2 -2
  51. package/bundle/shared/dist/agents.d.ts +15 -15
  52. package/bundle/shared/dist/agents.js +6 -0
  53. package/bundle/shared/dist/index.d.ts +7 -7
  54. package/bundle/shared/package.json +1 -1
  55. package/dist/doctor.d.ts +7 -3
  56. package/dist/doctor.js +21 -13
  57. package/dist/doctor.test.js +9 -8
  58. package/dist/managed/index.d.ts +1 -1
  59. package/dist/managed/index.js +1 -1
  60. package/dist/managed/updater.d.ts +16 -6
  61. package/dist/managed/updater.js +28 -11
  62. package/dist/managed-update-restart.test.d.ts +1 -0
  63. package/dist/managed-update-restart.test.js +227 -0
  64. package/dist/ports.d.ts +6 -0
  65. package/dist/ports.js +10 -0
  66. package/dist/runtime-state.d.ts +10 -0
  67. package/dist/runtime-state.js +26 -0
  68. package/dist/start.js +67 -2
  69. package/dist/status.js +30 -2
  70. package/dist/update-check.js +3 -6
  71. package/package.json +1 -1
  72. package/bundle/server/static-ui/assets/index-UO4lHZw4.js +0 -60
@@ -1,45 +1,29 @@
1
1
  // packages/server/src/coordinator/muse-spawn.ts
2
2
  //
3
- // NOT-181: the native Muse Code developer spawn. Wires the already-tested pieces — binary
4
- // resolution (cli-env.ts), argv (runners/muse-code-args.ts), JSONL/session-log parsing
5
- // (runners/muse-code-jsonl.ts) — into the existing developer-effect contract without changing
6
- // either side:
3
+ // NOT-278: the native Muse Code developer spawn. Every deck-enabled Muse developer turn runs the
4
+ // isolated `muse exec` lane: one exact, fail-closed per-attempt home built by
5
+ // `prepareMuseAttempt` (runners/muse-config.ts) — settings.json with exactly one required
6
+ // `agent-deck` server (deck/workspace headers), filtered env, stdin-only API key — verified
7
+ // immediately before spawn and removed afterwards on every outcome.
7
8
  //
8
- // 1. a per-attempt config + data dir under the worktree (`.dealer-muse/<attempt>/`, kept out of
9
- // git via info/exclude): `settings.json` with no MCP servers, a symlinked `auth.json`;
10
- // 2. `spawnCli` with `XDG_CONFIG_HOME`/`XDG_DATA_HOME` pointed at it;
11
- // 3. the on-disk session log (usage + confirmed model exist only there) read while the dir still
12
- // exists, then the dir is removed on success *and* failure;
13
- // 4. the raw stdout archived next to the log, and the log itself rewritten as Claude/Cursor-shaped
14
- // events, so every existing log reader (usage, result text, auth/usage-cap classification,
15
- // activity strip) works unchanged.
9
+ // The shared Muse serve host cannot carry per-session MCP configuration or deck/workspace
10
+ // headers (`session/start` accepts only command, workspace, model, and approval mode), so the
11
+ // serve lane is never used here. Fresh 5H/1W observation from the serve host is deliberately
12
+ // suspended while deck-enabled work is forced to exec; capacity stays honest N/A/stale with
13
+ // last-good rows preserved. Per-session Agent Deck support in the serve protocol is a non-goal.
16
14
  //
17
15
  // Muse cannot disable `cron_*` (NOT-177). The tool activity is checked here after the fact and
18
16
  // surfaced as `cronCalls`; the effect turns that into a `muse_cron_used` escalation. It detects,
19
17
  // it does not prevent.
20
- import { randomUUID } from "node:crypto";
21
18
  import fs from "node:fs";
22
19
  import path from "node:path";
23
20
  import { MUSE_CODE_CONTRIBUTOR_MODEL } from "@agent-dealer/shared";
24
21
  import { resolveMuseAuthFile, resolveMuseBin } from "../cli-env.js";
25
- import { ensureWorktreeExcluded } from "../adapters/worktree-exclude.js";
26
- import { buildMuseDeveloperInvocation } from "../runners/muse-code-args.js";
27
- import { buildMuseDeveloperSettings } from "../runners/muse-code-settings.js";
28
- import { normalizeMuseRun, parseMuseRun, } from "../runners/muse-code-jsonl.js";
29
- import { runMuseServeTurn } from "../runners/muse-serve-session.js";
22
+ import { getAgentDeckMcpUrl } from "../adapters/agent-deck.js";
23
+ import { getWorkerMcpConfigDir } from "../paths.js";
24
+ import { prepareMuseAttempt, } from "../runners/muse-config.js";
25
+ import { parseMuseRun, } from "../runners/muse-code-jsonl.js";
30
26
  import { spawnCli } from "../runners/spawn-cli.js";
31
- /**
32
- * NOT-270: execution lane selection. Default is the server-owned serve host
33
- * (real turns through it are the NOT-269 precondition for 5H/1W capacity).
34
- * `AGENT_DEALER_MUSE_RUNNER=exec` forces the legacy `muse exec` subprocess
35
- * lane — the operator escape hatch if the serve lane ever misbehaves in
36
- * production (capacity then reads honest N/A until serve runs again).
37
- */
38
- function museServeLaneEnabled() {
39
- return process.env.AGENT_DEALER_MUSE_RUNNER !== "exec";
40
- }
41
- /** Worktree-relative home of every per-attempt Muse config/data dir. */
42
- export const MUSE_ATTEMPT_ROOT = ".dealer-muse";
43
27
  /**
44
28
  * Step cap when the profile sets no `maxTurns`. High on purpose (the PoC's value): the developer
45
29
  * wall-clock timeout is the real limit, the cap only stops a runaway loop.
@@ -81,32 +65,24 @@ function readSessionLog(dataDir, museSessionId) {
81
65
  };
82
66
  return visit(root, 0);
83
67
  }
84
- function prepareAttemptDirs(worktreePath) {
85
- ensureWorktreeExcluded(worktreePath, `/${MUSE_ATTEMPT_ROOT}/`);
86
- const root = path.join(worktreePath, MUSE_ATTEMPT_ROOT, randomUUID());
87
- const configHome = path.join(root, "config");
88
- const dataHome = path.join(root, "data");
89
- fs.mkdirSync(path.join(configHome, "muse"), { recursive: true, mode: 0o700 });
90
- fs.mkdirSync(dataHome, { recursive: true, mode: 0o700 });
91
- // mkdir's mode is masked by the umask; the dirs hold a login symlink and the session log.
92
- fs.chmodSync(root, 0o700);
93
- fs.chmodSync(configHome, 0o700);
94
- fs.chmodSync(dataHome, 0o700);
95
- fs.writeFileSync(path.join(configHome, "muse", "settings.json"), JSON.stringify(buildMuseDeveloperSettings(), null, 2) + "\n", { mode: 0o600 });
96
- // Linked, never read or copied. Absent when the operator authenticates with META_API_KEY.
97
- const auth = resolveMuseAuthFile();
98
- if (fs.existsSync(auth))
99
- fs.symlinkSync(auth, path.join(configHome, "muse", "auth.json"));
100
- return { root, configHome, dataHome };
101
- }
102
- function removeAttemptDirs(worktreePath, root) {
103
- fs.rmSync(root, { recursive: true, force: true });
68
+ /**
69
+ * NOT-278: the attempt's credential. A saved login is linked, never read or copied; otherwise
70
+ * the API key travels on stdin only (`--api-key-stdin`, written once by the launcher and never
71
+ * logged). Missing credentials fail before spawn — nothing is written and no process starts.
72
+ */
73
+ function resolveMuseCredential() {
74
+ const authFile = resolveMuseAuthFile();
104
75
  try {
105
- fs.rmdirSync(path.join(worktreePath, MUSE_ATTEMPT_ROOT)); // only when no sibling attempt remains
76
+ if (fs.existsSync(authFile))
77
+ return { kind: "auth-file", path: authFile };
106
78
  }
107
79
  catch {
108
- // not empty or already gone
80
+ // An unreadable auth path is the same as a missing one: fall through to the API key.
109
81
  }
82
+ const apiKey = process.env.META_API_KEY;
83
+ if (apiKey !== undefined && apiKey.trim() !== "")
84
+ return { kind: "api-key", apiKey };
85
+ throw new Error("Muse Code auth required: run `muse login` or set META_API_KEY");
110
86
  }
111
87
  function splitSpawnLog(raw) {
112
88
  const idx = raw.indexOf(STDERR_MARKER);
@@ -123,11 +99,9 @@ function splitSpawnLog(raw) {
123
99
  * safe point is contractual, not opportunistic — a recent Agents-page poll
124
100
  * must never skip it. This creates no model turn of its own (the capacity
125
101
  * client only ever sends the read-only handshake/`usage/read` allowlist)
126
- * and never affects the session result. Serve-lane turns observed the host,
127
- * so this hook is the final read that populates 5H/1W; after an exec-lane
128
- * fallback the host stays unobserved by construction (the NOT-269
129
- * structural precondition) and the read stays honest N/A with last-good
130
- * rows preserved.
102
+ * and never affects the session result. Exec-lane turns leave the host
103
+ * unobserved by construction (the NOT-269 structural precondition), so the
104
+ * read stays honest N/A with last-good rows preserved.
131
105
  */
132
106
  function refreshMuseCapacityAfterSession() {
133
107
  void (async () => {
@@ -143,129 +117,63 @@ function refreshMuseCapacityAfterSession() {
143
117
  })();
144
118
  }
145
119
  /**
146
- * NOT-270: run the real developer turn through the server-owned serve host.
147
- * Returns null when no turn was admitted (host unavailable, session/start
148
- * or turn/start rejected) — the caller then runs the legacy `muse exec`
149
- * lane. Never returns null after admission: an admitted turn is real work
150
- * and its verdict is reported honestly, never retried on the other lane.
120
+ * NOT-278: run the deck-enabled developer turn as one isolated `muse exec` attempt. Fails before
121
+ * spawn without a frozen profile deck (the effect already maps that to `deck_failure`; this is
122
+ * the defense in depth that keeps a deckless launch from ever reaching a child process).
123
+ *
124
+ * The attempt is built with `prepareMuseAttempt({ role: "developer", ... })` on a Dealer-owned
125
+ * base directory outside the worktree and outside OS temp (the worker MCP config root), with
126
+ * `agentDeck.url` set to the configured Agent Deck MCP endpoint ending in `/mcp` (overridable
127
+ * for deckless infra callers), `deckId` to the frozen profile value, and `workspace` to the
128
+ * real worktree path. Immediately before spawn the exact cwd/argv/env/stdin about to be
129
+ * launched is verified; the child receives exactly
130
+ * `attempt.env` (no `process.env` merge) and `attempt.stdin`, so ambient `META_API_KEY`,
131
+ * `MUSE_*`, `CODEX_HOME`, and unrelated variables can never reach it.
151
132
  *
152
- * The serve lane needs no per-attempt XDG config/data dirs: the host runs
153
- * under its own server-owned home (`prepareMuseServeHome` in
154
- * capacity/muse-host.ts — same worker settings as the exec lane's
155
- * per-attempt settings.json plus a symlink to the ambient login, so runner
156
- * and capacity can never diverge on identity or posture). `onSpawn` is
157
- * deliberately not called: there is no per-attempt pid to persist, and
158
- * recovery must never treat the shared host as an attempt process.
133
+ * Settings, data, and the auth link are removed in `finally` on success, Muse failure,
134
+ * timeout, abort, and spawn error — operator credentials are never touched.
159
135
  */
160
- async function runMuseServeLane(input, model, onAdmitted) {
161
- const { getMuseCapacityHost } = await import("../capacity/muse-host.js");
162
- // Only this call may fall back: it resolves `admitted:false` (or throws)
163
- // strictly before any model work starts. Everything below runs after
164
- // admission — the caller's no-double-execution rule (via onAdmitted)
165
- // keeps those errors loud instead of falling back to exec.
166
- const turn = await runMuseServeTurn({
167
- host: getMuseCapacityHost(),
168
- prompt: input.prompt,
169
- model,
170
- cwd: input.cwd,
171
- timeoutMs: input.timeoutMs,
172
- signal: input.signal,
173
- onAdmitted: () => onAdmitted(),
174
- });
175
- if (!turn.admitted)
176
- return null;
177
- const cronCalls = turn.tools
178
- .map((t) => t.name)
179
- .filter((n) => n !== null && CRON_TOOL_RE.test(n));
180
- const summary = {
181
- museSessionId: turn.sessionId ?? randomUUID(),
182
- confirmedModel: turn.confirmedModel,
183
- usage: turn.usage,
184
- failure: turn.failure,
185
- cronCalls,
186
- runCount: 1,
187
- rawLogPath: null,
188
- };
189
- // Same normalized evidence both lanes write: the raw folded history, then
190
- // the Claude/Cursor-shaped stream every existing log reader consumes.
191
- const rawLogPath = `${input.logPath.replace(/\.ndjson$/, "")}.muse-raw.jsonl`;
192
- try {
193
- fs.writeFileSync(rawLogPath, `${turn.viewItems.map((i) => JSON.stringify(i)).join("\n")}\n`);
194
- summary.rawLogPath = rawLogPath;
195
- }
196
- catch {
197
- // evidence copy is best-effort; the normalized log below still carries the outcome
198
- }
199
- const events = normalizeMuseRun({
200
- sessionId: summary.museSessionId,
201
- confirmedModel: turn.confirmedModel,
202
- tools: turn.tools,
203
- finalText: turn.finalText,
204
- usage: turn.usage,
205
- failure: turn.failure,
206
- }).map((e) => e.type === "result" ? { ...e, muse: { ...summary, usage: turn.usage } } : e);
207
- fs.writeFileSync(input.logPath, `${events.map((e) => JSON.stringify(e)).join("\n")}\n`);
208
- // The real session above is the observation opportunity: kick the
209
- // best-effort capacity refresh (no synthetic turn, never throws, never
210
- // blocks this result).
211
- refreshMuseCapacityAfterSession();
212
- // A failed session must not read as success. 124 mirrors spawn-cli's
213
- // timeout exit so timeout handling downstream sees the same signal.
214
- const exitCode = turn.timedOut ? 124 : turn.failure ? 1 : turn.terminal === "completed" ? 0 : 1;
215
- return {
216
- exitCode,
217
- transcript: turn.finalText ?? "",
218
- logPath: input.logPath,
219
- timedOut: turn.timedOut,
220
- muse: summary,
221
- };
222
- }
223
136
  export async function runMuseDeveloperSession(input) {
224
- const model = input.model ?? MUSE_CODE_CONTRIBUTOR_MODEL;
225
- const museSessionId = randomUUID();
226
- // Serve-first: real turns through the owned host observe it (NOT-269
227
- // precondition for 5H/1W). Pre-admission failure falls back to exec; an
228
- // admitted turn never falls back — its errors propagate loudly instead of
229
- // executing the work a second time on the exec lane.
230
- // `AGENT_DEALER_MUSE_RUNNER=exec` skips the serve lane entirely
231
- // (operator escape hatch).
232
- if (museServeLaneEnabled()) {
233
- let admitted = false;
234
- try {
235
- const served = await runMuseServeLane(input, model, () => {
236
- admitted = true;
237
- });
238
- if (served)
239
- return served;
240
- }
241
- catch (err) {
242
- if (admitted)
243
- throw err;
244
- // Pre-admission serve-lane error: fall through to the exec lane below.
245
- }
137
+ const deckId = typeof input.deckId === "string" && input.deckId.trim() !== "" ? input.deckId : null;
138
+ if (!deckId) {
139
+ throw new Error("Muse developer session requires a frozen profile deckId — workers never start without one");
246
140
  }
247
- const invocation = buildMuseDeveloperInvocation({
248
- model,
249
- maxModelSteps: input.maxModelSteps ?? DEFAULT_MUSE_MAX_MODEL_STEPS,
250
- sessionId: museSessionId,
141
+ const model = input.model ?? MUSE_CODE_CONTRIBUTOR_MODEL;
142
+ const mcpUrl = typeof input.agentDeckUrl === "string" && input.agentDeckUrl.trim() !== ""
143
+ ? input.agentDeckUrl
144
+ : `${getAgentDeckMcpUrl().replace(/\/mcp\/?$/, "")}/mcp`;
145
+ // prepareMuseAttempt validates everything (role controls, deck identity, credential shape,
146
+ // unsafe paths) and writes the per-attempt dir; it removes its own dir when setup fails.
147
+ const attempt = prepareMuseAttempt({
148
+ role: "developer",
149
+ worktreePath: input.cwd,
150
+ baseDir: getWorkerMcpConfigDir(),
151
+ agentDeck: { url: mcpUrl, deckId, workspace: input.cwd },
152
+ credential: resolveMuseCredential(),
153
+ sessionId: input.sessionId,
251
154
  prompt: input.prompt,
155
+ maxModelSteps: input.maxModelSteps ?? DEFAULT_MUSE_MAX_MODEL_STEPS,
252
156
  });
253
157
  const { logPath } = input;
254
- const dirs = prepareAttemptDirs(input.cwd);
255
158
  let spawned;
256
159
  let sessionLog;
257
160
  try {
258
- spawned = await spawnCli(input.sessionId, resolveMuseBin(), invocation.args, input.cwd, {
161
+ // The exact launch about to happen — a tampered settings file or a differing
162
+ // cwd/argv/env/stdin fails here, before any process exists.
163
+ attempt.verify({ cwd: attempt.cwd, argv: [...attempt.argv], env: { ...attempt.env }, stdin: attempt.stdin });
164
+ spawned = await spawnCli(input.sessionId, resolveMuseBin(), [...attempt.argv], attempt.cwd, {
259
165
  logPath,
260
166
  timeoutMs: input.timeoutMs,
261
- env: { ...invocation.env, XDG_CONFIG_HOME: dirs.configHome, XDG_DATA_HOME: dirs.dataHome },
167
+ env: { ...attempt.env },
168
+ exactEnv: true,
169
+ stdin: attempt.stdin,
262
170
  signal: input.signal,
263
171
  onSpawn: input.onSpawn,
264
172
  });
265
- sessionLog = readSessionLog(dirs.dataHome, museSessionId);
173
+ sessionLog = readSessionLog(attempt.env.XDG_DATA_HOME, input.sessionId);
266
174
  }
267
175
  finally {
268
- removeAttemptDirs(input.cwd, dirs.root);
176
+ attempt.cleanup();
269
177
  }
270
178
  const rawLog = fs.existsSync(logPath) ? fs.readFileSync(logPath, "utf8") : spawned.transcript;
271
179
  const { stdout, stderr } = splitSpawnLog(rawLog);
@@ -278,7 +186,7 @@ export async function runMuseDeveloperSession(input) {
278
186
  });
279
187
  const cronCalls = run.tools.map((t) => t.name).filter((n) => n !== null && CRON_TOOL_RE.test(n));
280
188
  const summary = {
281
- museSessionId,
189
+ museSessionId: input.sessionId,
282
190
  confirmedModel: run.confirmedModel,
283
191
  usage: run.usage,
284
192
  failure: run.failure,
@@ -60,6 +60,7 @@ export function projectDeveloperRoute(route, currentStatus, round) {
60
60
  ...("pushDivergence" in route && route.pushDivergence
61
61
  ? { pushDivergence: route.pushDivergence }
62
62
  : {}),
63
+ ...(route.blockerFingerprint ? { blockerFingerprint: route.blockerFingerprint } : {}),
63
64
  },
64
65
  advance: "none",
65
66
  };
@@ -9,6 +9,19 @@ function guidanceSection(guidance) {
9
9
  ...guidance.flatMap((g) => [g.trim(), ``]),
10
10
  ];
11
11
  }
12
+ /** NOT-272: a resolved product_scope_decision's human note, verbatim. Empty/absent
13
+ * renders nothing so noteless resolves produce byte-for-byte the prompt they always did. */
14
+ function scopeDecisionSection(note) {
15
+ if (!note?.trim())
16
+ return [];
17
+ return [
18
+ `## Human decision`,
19
+ `A human resolved the product-scope question that was holding this work. This is the decision — follow it. Do not re-derive it, do not re-escalate the same question, and do not write this decision into source comments; it is recorded on the human action.`,
20
+ ``,
21
+ note.trim(),
22
+ ``,
23
+ ];
24
+ }
12
25
  /**
13
26
  * An agent with a deckId is launched with that deck already equipped in its MCP session
14
27
  * (NOT-106). `bind_workspace` confirms the equipped deck for this session cwd — it does
@@ -23,16 +36,18 @@ function guidanceSection(guidance) {
23
36
  *
24
37
  * Workers never start without a deckId (fail-closed at admission/effect).
25
38
  */
26
- function noAgentDeckSection() {
39
+ /**
40
+ * NOT-278: Muse cannot mechanically disable `cron_*`, so its developer prompt carries this
41
+ * prohibition on top of the standard deck bootstrap. A session that calls one is failed as
42
+ * `muse_cron_used` and escalated to the operator.
43
+ */
44
+ function museCronProhibitionSection() {
27
45
  return [
28
- `This session has no Agent Deck and no MCP servers. The Task and Acceptance criteria above are the complete brief — if they only reference a ticket id you cannot look it up, so say so in your conclusion instead of guessing.`,
29
46
  `Never call \`cron_create\`, \`cron_list\` or \`cron_delete\` and never schedule anything: a session that does is failed and escalated to the operator.`,
30
47
  ``,
31
48
  ];
32
49
  }
33
- function agentDeckSection(worktreePath, deckId, noAgentDeck) {
34
- if (noAgentDeck)
35
- return noAgentDeckSection();
50
+ function agentDeckSection(worktreePath, deckId) {
36
51
  if (!deckId || !worktreePath) {
37
52
  return [
38
53
  `This session is misconfigured: Agent Deck is required but missing. Stop and report the bootstrap failure — do not improvise without the deck.`,
@@ -63,6 +78,8 @@ export function buildDeveloperPrompt(input) {
63
78
  parts.push(...formatVerificationReceiptSection(input.priorVerificationReceipt));
64
79
  }
65
80
  }
81
+ // NOT-272: the scope decision leads — the developer reads it before the task itself.
82
+ parts.push(...scopeDecisionSection(input.scopeDecisionNote));
66
83
  parts.push(`## Task`, input.taskSnapshot.title, input.taskSnapshot.description, ``, `## Acceptance criteria`, input.taskSnapshot.acceptanceCriteria, ``);
67
84
  if (input.findings?.length) {
68
85
  parts.push(`## Findings to address`);
@@ -73,7 +90,9 @@ export function buildDeveloperPrompt(input) {
73
90
  parts.push(``);
74
91
  }
75
92
  parts.push(...guidanceSection(input.guidance));
76
- parts.push(...agentDeckSection(input.worktreePath, input.deckId, input.noAgentDeck));
93
+ parts.push(...agentDeckSection(input.worktreePath, input.deckId));
94
+ if (input.museDeveloper)
95
+ parts.push(...museCronProhibitionSection());
77
96
  parts.push(`## Required`,
78
97
  // NOT-115: encourage incremental commits so a mid-session death leaves less
79
98
  // uncommitted work for dirty_worktree escalation — soft mitigation only.
@@ -133,6 +133,26 @@ test("no guidance section when there is none", () => {
133
133
  const prompt = buildDeveloperPrompt({ taskSnapshot, round: 1 });
134
134
  assert.doesNotMatch(prompt, /## Guidance from the team/);
135
135
  });
136
+ // NOT-272: a product_scope_decision note reaches the very next developer round verbatim
137
+ // under its own heading, positioned before the task so it is read before acting.
138
+ test("NOT-272: scope decision note renders verbatim under its own heading before the task", () => {
139
+ const note = "The muse_code runner migration IS in scope for this ticket — proceed with it.";
140
+ const prompt = buildDeveloperPrompt({ taskSnapshot, round: 2, scopeDecisionNote: note });
141
+ assert.match(prompt, /## Human decision/);
142
+ assert.ok(prompt.includes(note), "note text must appear verbatim");
143
+ assert.ok(prompt.indexOf("## Human decision") < prompt.indexOf("## Task"), "Human decision section must precede the task");
144
+ assert.match(prompt, /do not re-escalate the same question/i);
145
+ });
146
+ // NOT-272: resolving without a note changes nothing — absent, empty, and blank notes all
147
+ // produce byte-for-byte the prompt built before this change.
148
+ test("NOT-272: no scope decision note produces byte-for-byte the same prompt as before", () => {
149
+ const base = { taskSnapshot, round: 2, findings: [{ fingerprint: "f1", severity: "blocking", title: "Bug", rationale: "It breaks", file: "a.ts", line: 10, status: "open", firstRound: 1, lastRound: 1, issueId: "i" }], guidance: ["Use the new logging util instead."] };
150
+ const without = buildDeveloperPrompt(base);
151
+ assert.doesNotMatch(without, /## Human decision/);
152
+ assert.equal(buildDeveloperPrompt({ ...base, scopeDecisionNote: undefined }), without);
153
+ assert.equal(buildDeveloperPrompt({ ...base, scopeDecisionNote: "" }), without);
154
+ assert.equal(buildDeveloperPrompt({ ...base, scopeDecisionNote: " \n " }), without);
155
+ });
136
156
  test("guidance since the last session is surfaced in the reviewer prompt", () => {
137
157
  const prompt = buildReviewerPrompt({
138
158
  taskSnapshot,
@@ -210,17 +230,25 @@ test("reviewer prompt deck section requires bind_workspace first, matching the d
210
230
  assert.match(prompt, /do not web-fetch Linear/);
211
231
  assert.doesNotMatch(prompt, /get_playbook\(/);
212
232
  });
213
- // NOT-181: a Muse Code worker has no Agent Deck, so it must not be told the session is
214
- // misconfigured (the deckless default) nor to bind a deck; it is told never to touch cron_*.
215
- test("NOT-181: noAgentDeck prompt states there is no deck/MCP and forbids cron_* instead of the misconfigured stop", () => {
216
- const prompt = buildDeveloperPrompt({ taskSnapshot, round: 1, noAgentDeck: true, deckId: null, worktreePath: "/wt" });
217
- assert.match(prompt, /no Agent Deck and no MCP servers/);
233
+ // NOT-278: a Muse Code developer gets the standard Agent Deck bootstrap gate plus the
234
+ // Muse-specific cron_* prohibition (detected post-run as muse_cron_used).
235
+ test("NOT-278: museDeveloper prompt carries the standard deck bootstrap and forbids cron_*", () => {
236
+ const prompt = buildDeveloperPrompt({ taskSnapshot, round: 1, deckId: "deck-1", worktreePath: "/wt", museDeveloper: true });
237
+ assert.match(prompt, /bind_workspace\(\{ deckId: "deck-1", workspaceRoot: "\/wt" \}\)/);
238
+ assert.match(prompt, /bootstrap is a hard gate/i);
239
+ assert.match(prompt, /do not improvise without the deck/i);
240
+ assert.match(prompt, /call_service_tool/);
218
241
  assert.match(prompt, /cron_create/);
219
- assert.doesNotMatch(prompt, /misconfigured/);
220
- assert.doesNotMatch(prompt, /bind_workspace/);
242
+ assert.match(prompt, /cron_list/);
243
+ assert.match(prompt, /cron_delete/);
221
244
  assert.match(prompt, /do NOT push and do NOT open a pull request/);
222
245
  });
223
- test("NOT-181: without noAgentDeck a deckless prompt still fails closed", () => {
246
+ test("NOT-278: a non-Muse developer prompt has the deck bootstrap but no cron prohibition", () => {
247
+ const prompt = buildDeveloperPrompt({ taskSnapshot, round: 1, deckId: "deck-1", worktreePath: "/wt" });
248
+ assert.match(prompt, /bind_workspace/);
249
+ assert.doesNotMatch(prompt, /cron_create/);
250
+ });
251
+ test("a deckless developer prompt still fails closed", () => {
224
252
  const prompt = buildDeveloperPrompt({ taskSnapshot, round: 1, deckId: null });
225
253
  assert.match(prompt, /misconfigured: Agent Deck is required but missing/);
226
254
  });
@@ -72,6 +72,7 @@ export function routeDeveloperOutcome(outcome, limits) {
72
72
  next: "human_action",
73
73
  actionType: "policy_escalation",
74
74
  reason: `${outcome.reason} Recovery:\n${outcome.recoveryCommands.join("\n")}`,
75
+ ...(outcome.fingerprint ? { blockerFingerprint: outcome.fingerprint } : {}),
75
76
  };
76
77
  case "muse_cron_used":
77
78
  // Spends no budget and never retries: a policy breach the operator must look at, like a
@@ -99,6 +99,26 @@ test("worktree conflict always escalates immediately — never retried, regardle
99
99
  assert.match(reason, /old-session-developer/);
100
100
  assert.match(reason, /git status/);
101
101
  });
102
+ test("NOT-280: worktree_conflict carries its blocker fingerprint through routing and projection to the action effect", async () => {
103
+ const { projectDeveloperRoute } = await import("./projection.js");
104
+ const outcome = {
105
+ kind: "worktree_conflict",
106
+ path: "/data/worktrees/old-session-developer",
107
+ reason: "Automatic salvage failed: git commit failed: hook rejected",
108
+ recoveryCommands: ["git status"],
109
+ fingerprint: "fp-1",
110
+ };
111
+ const result = routeDeveloperOutcome(outcome, REVIEW_ROUNDS_LEFT);
112
+ assert.equal(result.next, "human_action");
113
+ assert.equal(result.blockerFingerprint, "fp-1");
114
+ const { effect, advance } = projectDeveloperRoute(result, "developing", 1);
115
+ assert.equal(advance, "none", "a worktree blocker never spends any budget");
116
+ assert.equal(effect.kind, "human_action");
117
+ assert.equal(effect.blockerFingerprint, "fp-1");
118
+ const { fingerprint: _omit, ...legacy } = outcome;
119
+ const plain = routeDeveloperOutcome(legacy, REVIEW_ROUNDS_LEFT);
120
+ assert.equal("blockerFingerprint" in plain, false);
121
+ });
102
122
  test("NOT-127: live_owner retries on the infra budget — never escalates as worktree_conflict", () => {
103
123
  const outcome = {
104
124
  kind: "live_owner",
@@ -17,6 +17,7 @@ import { resolveClaudeBin, resolveCodexBin, resolveCursorBin, resolveMuseBin } f
17
17
  import { spawnCli } from "../runners/spawn-cli.js";
18
18
  import { buildDeveloperArgs, buildReviewerArgs } from "./args.js";
19
19
  import { assertReviewerReadOnly } from "./permissions.js";
20
+ import { assertWorktreeCwd } from "./worktree-cwd-guard.js";
20
21
  import { extractResultTranscript } from "./usage.js";
21
22
  import { runMuseDeveloperSession } from "./muse-spawn.js";
22
23
  export function developerSessionLogPath(sessionId) {
@@ -32,8 +33,12 @@ const BIN_FOR = {
32
33
  muse_code: resolveMuseBin,
33
34
  };
34
35
  export const realDeveloperSpawn = async (input) => {
36
+ // NOT-273: never spawn a worker outside a managed worktree — throws before
37
+ // any child process exists (the effect reports it as "could not start").
38
+ assertWorktreeCwd(input.cwd);
35
39
  if (input.runtime === "muse_code") {
36
- // Own argv, per-attempt XDG dirs and result parsing; deck/MCP are never wired for Muse.
40
+ // NOT-278: isolated deck-configured exec attempt (prepareMuseAttempt); the shared serve
41
+ // lane cannot carry per-session deck/workspace identity, so it is never used here.
37
42
  return runMuseDeveloperSession({
38
43
  ...input,
39
44
  maxModelSteps: input.maxModelSteps ?? undefined,
@@ -51,6 +56,9 @@ export const realDeveloperSpawn = async (input) => {
51
56
  * loosens a reviewer's tools can never silently reach a live spawn.
52
57
  */
53
58
  export const realReviewerSpawn = async (input) => {
59
+ // NOT-273: same worktree-cwd guard as the developer path — throws before
60
+ // any child process exists.
61
+ assertWorktreeCwd(input.cwd);
54
62
  const args = buildReviewerArgs(input.runtime, input.prompt, input.model ?? undefined, input.policy, input.mcpConfigPath, input.effort);
55
63
  assertReviewerReadOnly(args, { mcpConfigPath: input.mcpConfigPath, mcpEnv: input.mcpEnv });
56
64
  const logPath = input.logPath ?? reviewerSessionLogPath(input.sessionId);
@@ -0,0 +1,48 @@
1
+ // packages/server/src/coordinator/worktree-cwd-guard.ts
2
+ //
3
+ // NOT-273: defensive guard on the developer/reviewer spawn path. The worktree
4
+ // resolution (`resolveDeveloperWorktree` / `roleWorktreePathForResolution`) is
5
+ // correct today, but nothing asserts the resolved cwd before a child process
6
+ // is spawned — a regression there would run agent CLIs against the wrong
7
+ // directory (in the worst case the primary repo checkout itself, where a
8
+ // worker's `git add . && git commit` lands real commits on the checkout).
9
+ // Every real developer/reviewer spawn calls `assertWorktreeCwd` first and
10
+ // throws instead of spawning when the cwd is not a recognized
11
+ // coordinator-managed worktree path.
12
+ import path from "node:path";
13
+ /** Basename shape the worktree manager itself uses: `<sessionId>-<role>`. */
14
+ const ROLE_SUFFIX_RE = /-(developer|reviewer)$/;
15
+ /**
16
+ * Parent directory names under which coordinator-managed role worktrees live:
17
+ * - legacy layout: `<repo>/.agent-dealer-worktrees/<sessionId>-<role>`
18
+ * - managed layout (NOT-149): `<executionRoot>/worktrees/<identity>/<sessionId>-<role>`
19
+ * - dev-mode equivalent: `<dataDir>/worktrees/...`
20
+ */
21
+ const WORKTREE_PARENT_NAMES = new Set([".agent-dealer-worktrees", "worktrees"]);
22
+ /**
23
+ * True when `cwd` looks like a coordinator-managed role worktree path: its
24
+ * basename carries the `-developer`/`-reviewer` role suffix AND one of its
25
+ * parent directories is a recognized worktrees root. In particular the
26
+ * primary repo root itself never matches (no role suffix, no worktrees
27
+ * parent), nor does the ambient `process.cwd()` of a dev checkout.
28
+ */
29
+ export function isManagedWorktreeCwd(cwd) {
30
+ if (typeof cwd !== "string" || cwd.length === 0)
31
+ return false;
32
+ const normalized = path.normalize(cwd);
33
+ if (!ROLE_SUFFIX_RE.test(path.basename(normalized)))
34
+ return false;
35
+ const segments = normalized.split(path.sep).filter(Boolean);
36
+ return segments.slice(0, -1).some((segment) => WORKTREE_PARENT_NAMES.has(segment));
37
+ }
38
+ /**
39
+ * Throw instead of spawning when `cwd` is not a recognized managed-worktree
40
+ * path. Called at the top of `realDeveloperSpawn`/`realReviewerSpawn`, before
41
+ * any child process exists — the effects turn this pre-spawn throw into a
42
+ * `session_failed` "could not start" outcome, never a run against the repo.
43
+ */
44
+ export function assertWorktreeCwd(cwd) {
45
+ if (!isManagedWorktreeCwd(cwd)) {
46
+ throw new Error(`Refusing to spawn a worker outside a managed worktree (NOT-273): ${cwd}`);
47
+ }
48
+ }
@@ -0,0 +1,83 @@
1
+ // packages/server/src/coordinator/worktree-cwd-guard.test.ts
2
+ //
3
+ // NOT-273: the developer/reviewer spawn path refuses to spawn outside a
4
+ // recognized coordinator-managed worktree — in particular the primary repo
5
+ // root itself — before any child process exists.
6
+ import { test } from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import fs from "node:fs";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+ const { assertWorktreeCwd, isManagedWorktreeCwd } = await import("./worktree-cwd-guard.js");
12
+ const { realDeveloperSpawn, realReviewerSpawn } = await import("./spawn.js");
13
+ const SESSION_ID = "00000000-0000-4000-8000-000000000001";
14
+ test("real worktree-shaped paths pass the guard", () => {
15
+ // Legacy layout: <repo>/.agent-dealer-worktrees/<sessionId>-<role>.
16
+ assert.equal(isManagedWorktreeCwd(`/repo/.agent-dealer-worktrees/${SESSION_ID}-developer`), true);
17
+ assert.equal(isManagedWorktreeCwd(`/repo/.agent-dealer-worktrees/${SESSION_ID}-reviewer`), true);
18
+ // Managed layout (NOT-149): <executionRoot>/worktrees/<identity>/<sessionId>-<role>.
19
+ assert.equal(isManagedWorktreeCwd(`/data/execution/worktrees/github.com/acme/app/${SESSION_ID}-developer`), true);
20
+ assert.equal(isManagedWorktreeCwd(`/data/execution/worktrees/github.com/acme/app/${SESSION_ID}-reviewer`), true);
21
+ // Dev-mode equivalent under a dev data dir.
22
+ assert.equal(isManagedWorktreeCwd(path.join(os.homedir(), ".agent-dealer-dev", "worktrees", "github.com", "acme", "app", `${SESSION_ID}-developer`)), true);
23
+ assert.doesNotThrow(() => assertWorktreeCwd(`/repo/.agent-dealer-worktrees/${SESSION_ID}-developer`));
24
+ });
25
+ test("the primary repo root and other non-worktree paths are rejected", () => {
26
+ const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-guard-repo-"));
27
+ // A plain checkout dir (what the ambient process cwd is in a normal dev
28
+ // checkout). Deliberately synthetic, never `process.cwd()` itself: the
29
+ // runner's own cwd is environment-dependent and may itself be
30
+ // worktree-shaped (e.g. a `<uuid>-developer` dir under a `worktrees`
31
+ // parent), which the guard correctly accepts per the naming convention.
32
+ const checkoutDir = path.join(repoRoot, "checkout");
33
+ fs.mkdirSync(checkoutDir);
34
+ try {
35
+ assert.equal(isManagedWorktreeCwd(repoRoot), false, "primary repo root itself");
36
+ assert.equal(isManagedWorktreeCwd(checkoutDir), false, "ambient-checkout-shaped cwd");
37
+ assert.equal(isManagedWorktreeCwd(os.tmpdir()), false, "bare temp dir");
38
+ assert.equal(isManagedWorktreeCwd(path.join(os.tmpdir(), `${SESSION_ID}-developer`)), false, "role suffix without a worktrees parent");
39
+ assert.equal(isManagedWorktreeCwd("/repo/.agent-dealer-worktrees"), false, "worktrees root without a session dir");
40
+ assert.equal(isManagedWorktreeCwd(""), false, "empty cwd");
41
+ assert.throws(() => assertWorktreeCwd(repoRoot), /managed worktree/);
42
+ assert.throws(() => assertWorktreeCwd(checkoutDir), /managed worktree/);
43
+ }
44
+ finally {
45
+ fs.rmSync(repoRoot, { recursive: true, force: true });
46
+ }
47
+ });
48
+ test("realDeveloperSpawn refuses the primary repo root before any process is spawned", async () => {
49
+ const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-guard-dev-"));
50
+ try {
51
+ await assert.rejects(realDeveloperSpawn({
52
+ sessionId: SESSION_ID,
53
+ runtime: "claude_code",
54
+ policy: { worktreeWrite: true },
55
+ model: null,
56
+ prompt: "Implement it",
57
+ cwd: repoRoot,
58
+ timeoutMs: 1000,
59
+ }), /managed worktree/, "repo-root cwd throws instead of spawning");
60
+ }
61
+ finally {
62
+ fs.rmSync(repoRoot, { recursive: true, force: true });
63
+ }
64
+ });
65
+ test("realReviewerSpawn refuses the primary repo root before any process is spawned", async () => {
66
+ // Synthetic repo-root stand-in, never `process.cwd()`: the runner's own
67
+ // cwd is environment-dependent and may itself be worktree-shaped.
68
+ const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-guard-rev-"));
69
+ try {
70
+ await assert.rejects(realReviewerSpawn({
71
+ sessionId: SESSION_ID,
72
+ runtime: "claude_code",
73
+ policy: { worktreeWrite: false },
74
+ model: null,
75
+ prompt: "Review it",
76
+ cwd: repoRoot,
77
+ timeoutMs: 1000,
78
+ }), /managed worktree/, "repo-root cwd throws instead of spawning");
79
+ }
80
+ finally {
81
+ fs.rmSync(repoRoot, { recursive: true, force: true });
82
+ }
83
+ });