muse-crew 0.17.1 → 0.17.3

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 (71) hide show
  1. package/API.md +11 -0
  2. package/docs/decisions/composition-machinery.md +180 -0
  3. package/docs/decisions/publish-path.md +6 -0
  4. package/docs/decisions/workflow-core.md +5 -4
  5. package/lib/AGENTS.md +2 -1
  6. package/lib/bugfix/phases/build.js +168 -0
  7. package/lib/bugfix/phases/capture.js +170 -0
  8. package/lib/bugfix/phases/integrate.js +165 -0
  9. package/lib/bugfix/phases/map.js +129 -0
  10. package/lib/bugfix/phases/publish.js +592 -0
  11. package/lib/bugfix/phases/qa.js +356 -0
  12. package/lib/bugfix/phases/reproduce.js +254 -0
  13. package/lib/bugfix/phases/review.js +292 -0
  14. package/lib/bugfix/phases/triage.js +69 -0
  15. package/lib/chore/CONTRACT.md +181 -0
  16. package/lib/chore/DISPOSITION.md +98 -0
  17. package/lib/chore/extract.js +204 -0
  18. package/lib/chore/phase-lib.js +885 -0
  19. package/lib/chore/phases/build.js +110 -0
  20. package/lib/chore/phases/capture.js +82 -0
  21. package/lib/chore/phases/integrate.js +109 -0
  22. package/lib/chore/phases/map.js +81 -0
  23. package/lib/chore/phases/publish.js +543 -0
  24. package/lib/chore/phases/review.js +255 -0
  25. package/lib/chore/phases/triage.js +57 -0
  26. package/lib/chore/prompts/evidence-gatherer.js +41 -0
  27. package/lib/chore/prompts/evidence-gatherer.schema.json +1 -0
  28. package/lib/chore/prompts/tool-check.js +15 -0
  29. package/lib/chore/prompts/trailers.js +56 -0
  30. package/lib/chore/prompts/verdict-reask.js +28 -0
  31. package/lib/chore/prompts/verdict-reask.schema.json +1 -0
  32. package/lib/chore/prompts/work-agent.js +52 -0
  33. package/lib/chore/prompts/work-agent.schema.json +1 -0
  34. package/lib/chore/spawn-keys.js +44 -0
  35. package/lib/chore/spawn-vocab.js +87 -0
  36. package/lib/chore-run.js +538 -0
  37. package/lib/chore-tick.js +289 -0
  38. package/lib/compare-dispatch-shadow.js +35 -10
  39. package/lib/crew-api.js +273 -0
  40. package/lib/crew-dispatch-worker.js +27 -7
  41. package/lib/crew-release.sh +7 -2
  42. package/lib/extract.js +252 -0
  43. package/lib/prompts/tool-check.js +18 -0
  44. package/lib/prompts/trailers.js +59 -0
  45. package/lib/prompts/verdict-reask.js +31 -0
  46. package/lib/prompts/verdict-reask.schema.json +1 -0
  47. package/lib/prompts/work-agent.js +56 -0
  48. package/lib/prompts/work-agent.schema.json +1 -0
  49. package/lib/reap-spawns.js +407 -0
  50. package/lib/schema.sql +12 -1
  51. package/lib/spawn-keys.js +47 -0
  52. package/lib/spawn-step.js +572 -0
  53. package/lib/standard/phases/build.js +120 -0
  54. package/lib/standard/phases/capture.js +163 -0
  55. package/lib/standard/phases/integrate.js +172 -0
  56. package/lib/standard/phases/map.js +119 -0
  57. package/lib/standard/phases/publish.js +565 -0
  58. package/lib/standard/phases/qa.js +399 -0
  59. package/lib/standard/phases/review.js +281 -0
  60. package/lib/standard/phases/triage.js +64 -0
  61. package/lib/test-detached-integrate.sh +47 -0
  62. package/lib/workflow-driver.js +605 -0
  63. package/lib/workflow-lib.js +1012 -0
  64. package/lib/workflow-spec.js +187 -0
  65. package/lib/worktree-lifecycle.sh +55 -3
  66. package/package.json +1 -1
  67. package/seed/cron-body-template.md +61 -9
  68. package/workflows/bugfix.js +17 -17
  69. package/workflows/chore.js +16 -16
  70. package/workflows/docs.js +14 -11
  71. package/workflows/standard.js +16 -16
@@ -0,0 +1,289 @@
1
+ #!/usr/bin/env node
2
+ // lib/chore-tick.js — the tick worker's ferry for worker-layer Chore claims
3
+ // (sandbox exit, cutover piece, 2026-09-26).
4
+ //
5
+ // The tick worker invokes this once per worker-chore claim per tick:
6
+ // node lib/chore-tick.js --crew-home H --task-id T --tick-seq N [options]
7
+ // node lib/chore-tick.js --crew-home H --task-id T --tick-seq N --session S [options]
8
+ //
9
+ // It sequences the driver and the spawn bridge in-process (no shell), so
10
+ // the NEED_SPAWN request JSON never crosses a shell-quoting boundary.
11
+ // The one act it cannot do — spawning the agent — stays with the tick
12
+ // worker (an LLM runtime capability), via the "spawn" action.
13
+ //
14
+ // Stdout carries EXACTLY ONE JSON line; all logs go to stderr.
15
+ // Actions:
16
+ // {"action":"spawn","spawn":{prompt,cwd,env,timeout_ms,session_path,key,attempt_n,row_id},...}
17
+ // — spawn-step pre succeeded. The tick worker spawns ONE subagent
18
+ // with the packet, records child+outcome into the session file,
19
+ // then re-invokes this script with --session.
20
+ // {"action":"wait","reason":..,...}
21
+ // — a spawn is in flight (or the frame budget ran out). Stop; the
22
+ // next tick re-invokes. Never a failure.
23
+ // {"action":"done","outcome":..,...}
24
+ // — the driver reached a terminal outcome. The driver already wrote
25
+ // the task state.
26
+ // {"action":"error","reason":..}
27
+ // — transport failure (unparseable driver frame). Log and stop; the
28
+ // next tick retries. Exit code is 1.
29
+ //
30
+ // With --session S: runs `spawn-step.js post --session S` first (settles
31
+ // the spawn the tick worker just completed), then continues driving.
32
+ // Every post outcome is safe to drive through: closed, already_closed,
33
+ // kill_unverified, and post errors all resolve via the driver's next
34
+ // derivation (advance, or WAIT on the still-running row).
35
+ //
36
+ // A spawn-step pre refusal (DUPLICATE_RUNNING, ATTEMPTS_EXHAUSTED,
37
+ // TRANSPORT_BUDGET_EXHAUSTED, BOUNDS_REJECTED) is NOT an error: the
38
+ // ledger now reflects the refusal and the driver's next derivation
39
+ // resolves it (WAIT or DONE/failed). It counts against the frame budget.
40
+ //
41
+ // Frame budget (--max-frames, default 20): bounds the in-tick drive loop.
42
+ // Exhaustion emits action "wait" (never "done"/"failed") — the next tick
43
+ // resumes. Budgets end the tick's participation, never the task.
44
+ //
45
+ // Concurrency: the driver is safe to re-invoke any time (STANDBY→WAIT on
46
+ // a running spawn; claim-as-gate STAND_DOWN on a lost race). The tick
47
+ // worker must not invoke this twice concurrently for the same task.
48
+ //
49
+ // No wall-clock reads and no randomness in decision paths (G3).
50
+
51
+ import { execFileSync } from "node:child_process";
52
+ import { dirname, join } from "node:path";
53
+ import { fileURLToPath } from "node:url";
54
+
55
+ const LIB_DIR = dirname(fileURLToPath(import.meta.url));
56
+ const CHORE_RUN = join(LIB_DIR, "chore-run.js");
57
+ const SPAWN_STEP = join(LIB_DIR, "spawn-step.js");
58
+
59
+ const DEFAULT_MAX_FRAMES = 20;
60
+
61
+ // Permanent spawn-pre refusal codes (2026-09-26): the refusal fires before
62
+ // any spawn-ledger row is opened, so the ledger cannot reflect it and the
63
+ // next derivation would emit an identical NEED_SPAWN forever. On these
64
+ // codes the ferry re-drives the driver ONCE with --pre-refusal so the
65
+ // driver terminates (PARK for BOUNDS_REJECTED, FAILED for the budget
66
+ // codes) through the normal closeout. DUPLICATE_RUNNING is the legitimate
67
+ // re-drive case and keeps the old behavior. Must match
68
+ // TERMINAL_REFUSAL_CODES in lib/chore-run.js.
69
+ const TERMINAL_REFUSAL_CODES = [
70
+ "BOUNDS_REJECTED",
71
+ "ATTEMPTS_EXHAUSTED",
72
+ "TRANSPORT_BUDGET_EXHAUSTED",
73
+ ];
74
+
75
+ const USAGE = [
76
+ "Usage: node lib/chore-tick.js --crew-home <dir> --task-id <id> --tick-seq <n> [options]",
77
+ "",
78
+ "Tick ferry for one worker-layer Chore claim: drives chore-run.js and the",
79
+ "spawn bridge, emitting exactly one JSON action on stdout (spawn | wait |",
80
+ "done | error). The tick worker performs the agent spawn on \"spawn\" and",
81
+ "re-invokes with --session; on \"wait\"/\"done\" it stops for this tick.",
82
+ "",
83
+ "Required:",
84
+ " --crew-home <dir> crew home directory",
85
+ " --task-id <id> task to drive",
86
+ " --tick-seq <n> owning tick sequence (positive integer)",
87
+ "",
88
+ "Options:",
89
+ " --session <path> settle this spawn session first (spawn-step post), then drive",
90
+ " --max-frames <n> drive-loop bound before yielding wait (default 20)",
91
+ " --project-id <id> expected project (forwarded to the driver)",
92
+ " --visual-protocol visual protocol available (forwarded)",
93
+ " --no-visual-protocol visual protocol unavailable (forwarded)",
94
+ " --resolved-workflow <w> workflow name (forwarded; defaults to the task's)",
95
+ " --workflow-was-null the dispatcher did not specify a workflow",
96
+ " --help print this usage",
97
+ ].join("\n");
98
+
99
+ // --help is answered on stdout with exit 0 BEFORE required-argument parsing
100
+ // (lib/AGENTS.md shebang⇔CLI contract).
101
+ const RAW_ARGS = process.argv.slice(2);
102
+ if (RAW_ARGS.indexOf("--help") !== -1 || RAW_ARGS.indexOf("-h") !== -1) {
103
+ process.stdout.write(USAGE + "\n");
104
+ process.exit(0);
105
+ }
106
+
107
+ function log(message) {
108
+ process.stderr.write("[chore-tick] " + String(message) + "\n");
109
+ }
110
+
111
+ function printJson(obj) {
112
+ process.stdout.write(JSON.stringify(obj) + "\n");
113
+ }
114
+
115
+ function errText(e) {
116
+ return (e && e.message) || String(e);
117
+ }
118
+
119
+ function parseArgs(argv) {
120
+ const o = {
121
+ crewHome: null, taskId: null, tickSeq: null, session: null,
122
+ maxFrames: DEFAULT_MAX_FRAMES, projectId: null, visualProtocol: null,
123
+ resolvedWorkflow: null, workflowWasNull: false, preRefusal: null,
124
+ };
125
+ for (let i = 0; i < argv.length; i++) {
126
+ const a = argv[i];
127
+ if (a === "--crew-home") o.crewHome = argv[++i];
128
+ else if (a === "--task-id") o.taskId = argv[++i];
129
+ else if (a === "--tick-seq") o.tickSeq = argv[++i];
130
+ else if (a === "--session") o.session = argv[++i];
131
+ else if (a === "--max-frames") o.maxFrames = argv[++i];
132
+ else if (a === "--project-id") o.projectId = argv[++i];
133
+ else if (a === "--visual-protocol") o.visualProtocol = true;
134
+ else if (a === "--no-visual-protocol") o.visualProtocol = false;
135
+ else if (a === "--resolved-workflow") o.resolvedWorkflow = argv[++i];
136
+ else if (a === "--workflow-was-null") o.workflowWasNull = true;
137
+ else throw new Error("unknown argument: " + a);
138
+ }
139
+ if (!o.crewHome) throw new Error("--crew-home is required");
140
+ if (!o.taskId) throw new Error("--task-id is required");
141
+ if (!/^[1-9][0-9]*$/.test(String(o.tickSeq || ""))) {
142
+ throw new Error("--tick-seq must be a positive integer");
143
+ }
144
+ if (!/^[1-9][0-9]*$/.test(String(o.maxFrames))) {
145
+ throw new Error("--max-frames must be a positive integer");
146
+ }
147
+ o.maxFrames = parseInt(o.maxFrames, 10);
148
+ return o;
149
+ }
150
+
151
+ // Run a node script with argv (no shell). Returns {ok, stdout} — ok
152
+ // reflects the exit code, but callers parse stdout regardless: both
153
+ // chore-run.js and spawn-step.js print structured JSON on failure paths.
154
+ function runNode(script, args) {
155
+ try {
156
+ const stdout = execFileSync("node", [script].concat(args), {
157
+ encoding: "utf8",
158
+ maxBuffer: 64 * 1024 * 1024,
159
+ });
160
+ return { ok: true, stdout: stdout };
161
+ } catch (e) {
162
+ return {
163
+ ok: false,
164
+ stdout: (e.stdout || "").toString(),
165
+ stderr: (e.stderr || "").toString(),
166
+ code: e.status,
167
+ };
168
+ }
169
+ }
170
+
171
+ function parseJsonLine(text) {
172
+ const line = String(text || "").trim().split("\n").pop() || "";
173
+ if (!line) return { ok: false, reason: "empty stdout" };
174
+ try {
175
+ return { ok: true, value: JSON.parse(line) };
176
+ } catch (e) {
177
+ return { ok: false, reason: "unparseable stdout: " + errText(e) };
178
+ }
179
+ }
180
+
181
+ function choreRunArgv(o) {
182
+ const a = ["--crew-home", o.crewHome, "--task-id", o.taskId, "--tick-seq", String(o.tickSeq)];
183
+ if (o.projectId) a.push("--project-id", o.projectId);
184
+ if (o.visualProtocol === true) a.push("--visual-protocol");
185
+ if (o.visualProtocol === false) a.push("--no-visual-protocol");
186
+ if (o.resolvedWorkflow) a.push("--resolved-workflow", o.resolvedWorkflow);
187
+ if (o.workflowWasNull) a.push("--workflow-was-null");
188
+ if (o.preRefusal) a.push("--pre-refusal", o.preRefusal);
189
+ return a;
190
+ }
191
+
192
+ // One driver invocation. Returns {frame} or {error}. A parseable frame is
193
+ // used even when the driver exited non-zero (its fail-closed contract
194
+ // still emits the one JSON frame on catastrophic failure).
195
+ function driveOnce(o) {
196
+ const r = runNode(CHORE_RUN, choreRunArgv(o));
197
+ const p = parseJsonLine(r.stdout);
198
+ if (!p.ok) {
199
+ return { error: "chore-run produced no parseable frame (" + p.reason + ")" +
200
+ (r.stderr ? "; stderr: " + r.stderr.slice(0, 300) : "") };
201
+ }
202
+ const frame = p.value;
203
+ if (!frame || (frame.type !== "NEED_SPAWN" && frame.type !== "WAIT" && frame.type !== "DONE")) {
204
+ return { error: "chore-run emitted unknown frame type: " + JSON.stringify(frame).slice(0, 200) };
205
+ }
206
+ return { frame: frame };
207
+ }
208
+
209
+ // Settle a completed spawn session, then let the driver re-derive.
210
+ // Every post outcome is safe to drive through.
211
+ function settleSession(o) {
212
+ const r = runNode(SPAWN_STEP, ["post", "--session", o.session]);
213
+ const p = parseJsonLine(r.stdout);
214
+ if (p.ok && p.value) {
215
+ const v = p.value;
216
+ log("post: closed=" + v.closed + (v.reason ? " reason=" + v.reason : "") +
217
+ (v.row_id ? " row=" + v.row_id : "") + (v.outcome ? " outcome=" + v.outcome : ""));
218
+ } else {
219
+ log("post produced no parseable result; driving anyway (" + p.reason + ")" +
220
+ (r.stderr ? "; stderr: " + r.stderr.slice(0, 200) : ""));
221
+ }
222
+ }
223
+
224
+ function main() {
225
+ let o;
226
+ try {
227
+ o = parseArgs(RAW_ARGS);
228
+ } catch (e) {
229
+ process.stderr.write(USAGE + "\n\nError: " + errText(e) + "\n");
230
+ process.exit(2);
231
+ }
232
+
233
+ if (o.session) settleSession(o);
234
+
235
+ for (let i = 0; i < o.maxFrames; i++) {
236
+ const d = driveOnce(o);
237
+ if (d.error) {
238
+ log(d.error);
239
+ printJson({ action: "error", reason: d.error, task_id: o.taskId });
240
+ process.exit(1);
241
+ }
242
+ const frame = d.frame;
243
+ if (frame.type === "NEED_SPAWN") {
244
+ const pre = runNode(SPAWN_STEP, ["pre", "--request", JSON.stringify(frame.request || {})]);
245
+ if (pre.ok) {
246
+ const p = parseJsonLine(pre.stdout);
247
+ if (p.ok && p.value && p.value.session_path) {
248
+ printJson({ action: "spawn", spawn: p.value, task_id: o.taskId });
249
+ process.exit(0);
250
+ }
251
+ log("spawn pre exited 0 with unparseable packet; re-driving");
252
+ continue;
253
+ }
254
+ // Pre refused (or crashed): transient refusals (DUPLICATE_RUNNING — a
255
+ // spawn is in flight) re-drive so the next derivation resolves to
256
+ // WAIT; the ledger reflects those. Permanent refusals fire before any
257
+ // ledger row opens, so re-driving blindly would loop forever — report
258
+ // the code back to the driver once via --pre-refusal and let it
259
+ // terminate through the normal closeout. Counts as a frame either way.
260
+ const ep = parseJsonLine(pre.stdout);
261
+ const code = (ep.ok && ep.value && ep.value.error && ep.value.error.code) || "pre_failed";
262
+ if (TERMINAL_REFUSAL_CODES.indexOf(code) !== -1) {
263
+ log("spawn pre refused (" + code + "); terminal — informing driver");
264
+ o.preRefusal = code;
265
+ } else {
266
+ log("spawn pre refused (" + code + "); re-driving");
267
+ }
268
+ continue;
269
+ }
270
+ if (frame.type === "WAIT") {
271
+ printJson({
272
+ action: "wait", reason: frame.reason || null, phase: frame.phase || null,
273
+ key: frame.key || null, task_id: o.taskId,
274
+ });
275
+ process.exit(0);
276
+ }
277
+ // DONE
278
+ printJson({
279
+ action: "done", outcome: frame.outcome, task_id: o.taskId,
280
+ phase: frame.phase || null, reason: frame.reason || null,
281
+ });
282
+ process.exit(0);
283
+ }
284
+ log("frame budget (" + o.maxFrames + ") exhausted; yielding wait");
285
+ printJson({ action: "wait", reason: "frame-budget-exhausted", task_id: o.taskId });
286
+ process.exit(0);
287
+ }
288
+
289
+ main();
@@ -21,11 +21,20 @@
21
21
  // evidence, not a false alarm.
22
22
  //
23
23
  // Usage: node compare-dispatch-shadow.js --crew-home <path>
24
- // Prints one of SHADOW_MATCH / SHADOW_DIVERGE / SHADOW_UNPAIRED /
25
- // SHADOW_NO_EVIDENCE and appends the verdict to
26
- // $CREW_HOME/.dispatch-shadow-verdicts.jsonl. Exit 0 on a verdict (even a
27
- // diverge — divergence is evidence, not a failure); exit 2 on usage or IO
28
- // errors.
24
+ // Prints per-shadow SHADOW_MATCH / SHADOW_DIVERGE / SHADOW_UNPAIRED /
25
+ // SHADOW_PENDING lines plus a SHADOW_SUMMARY; SHADOW_NO_EVIDENCE when no
26
+ // shadow lines exist yet, SHADOW_CURRENT when every shadow already has a
27
+ // verdict. Appends verdicts to $CREW_HOME/.dispatch-shadow-verdicts.jsonl.
28
+ // Exit 0 on a verdict (even a diverge — divergence is evidence, not a
29
+ // failure); exit 2 on usage or IO errors.
30
+ //
31
+ // Retryability: a shadow with no matching decision is left PENDING (no
32
+ // verdict written) unless the authoritative dispatcher has provably moved
33
+ // past its tick — i.e. a decision exists with tick_seq greater than the
34
+ // wanted one. Each tick's dispatcher appends exactly one tick-release line
35
+ // and then writes its decision synchronously, so a later decision proves
36
+ // the missing one will never be written. Pending shadows are retried on
37
+ // every later run; only provably-dead ticks become terminal UNPAIRED.
29
38
 
30
39
  import { readFileSync, appendFileSync } from "node:fs";
31
40
  import { join } from "node:path";
@@ -104,15 +113,31 @@ if (pending.length === 0) {
104
113
  process.exit(0);
105
114
  }
106
115
 
107
- let matched = 0, diverged = 0, unpaired = 0;
116
+ let matched = 0, diverged = 0, unpaired = 0, pendingCount = 0;
117
+ // The authoritative dispatcher appends one tick-release line and then writes
118
+ // its decision synchronously in the same step, so decision tick_seqs advance
119
+ // monotonically and each tick gets exactly one decision write. A decision
120
+ // with tick_seq past a shadow's wanted tick therefore proves the missing
121
+ // decision will never be written: the owning tick already spent its single
122
+ // write. (The release-line append and the decision write are adjacent in one
123
+ // process; a >15min stall between them — the only out-of-order path — would
124
+ // cost one sample, not correctness.)
125
+ const maxDecisionTickSeq = decisions.reduce((m, d) => Math.max(m, d.tick_seq || 0), 0);
108
126
  for (const shadow of pending) {
109
127
  const wantTickSeq = (shadow.tick_seq_before || 0) + 1;
110
128
  const decision = decisions.find((d) => d.tick_seq === wantTickSeq);
111
129
 
112
130
  if (!decision) {
113
- emit({ verdict: "unpaired", shadow_seq: shadow.seq, want_tick_seq: wantTickSeq });
114
- console.log(`SHADOW_UNPAIRED shadow_seq=${shadow.seq} want_tick_seq=${wantTickSeq} (authoritative decision not yet written)`);
115
- unpaired++;
131
+ if (maxDecisionTickSeq > wantTickSeq) {
132
+ emit({ verdict: "unpaired", shadow_seq: shadow.seq, want_tick_seq: wantTickSeq });
133
+ console.log(`SHADOW_UNPAIRED shadow_seq=${shadow.seq} want_tick_seq=${wantTickSeq} (authoritative dispatcher advanced to tick_seq=${maxDecisionTickSeq} without writing this decision)`);
134
+ unpaired++;
135
+ } else {
136
+ // The authoritative decision simply hasn't been written yet — no
137
+ // verdict, the shadow stays pending and a later run retries.
138
+ console.log(`SHADOW_PENDING shadow_seq=${shadow.seq} want_tick_seq=${wantTickSeq} (authoritative decision not yet written; retrying next run)`);
139
+ pendingCount++;
140
+ }
116
141
  continue;
117
142
  }
118
143
 
@@ -146,5 +171,5 @@ for (const shadow of pending) {
146
171
  diverged++;
147
172
  }
148
173
  }
149
- console.log(`SHADOW_SUMMARY matched=${matched} diverged=${diverged} unpaired=${unpaired}`);
174
+ console.log(`SHADOW_SUMMARY matched=${matched} diverged=${diverged} unpaired=${unpaired} pending=${pendingCount}`);
150
175
  process.exit(0);
package/lib/crew-api.js CHANGED
@@ -38,6 +38,57 @@ import { classifySurface } from "./classify-surface.js";
38
38
  // of crew-api.js is pinned in the workflows' pinLifecycle (PIN_BASENAMES),
39
39
  // mechanically verified by tests/pin-closure.test.js.
40
40
  import { matchTerminalPublishNote, TERMINAL_NOTE_MEANINGS, assertWritablePublishNote, assertCodeWritablePublishNote } from "./publish-note-vocabulary.js";
41
+ // Spawn-ledger vocabularies (sandbox exit, chore pilot Piece 2, 2026-09-26):
42
+ // the closed spawn kinds, terminal outcome vocabulary, attempt caps, and
43
+ // transport-budget constants. These are INLINED, not imported, deliberately:
44
+ // crew-api.js may only carry runtime-relative dependencies listed in the
45
+ // workflows' PIN_BASENAMES (mechanically verified by tests/pin-closure.test.js),
46
+ // and the workflow files are frozen — a new pin entry cannot be added.
47
+ // The vocabularies are frozen by design (DESIGN-chore-pilot.md §1), so the
48
+ // inline copy cannot drift; lib/chore/spawn-vocab.js remains the canonical
49
+ // source for the bridge CLIs (lib/spawn-step.js, lib/reap-spawns.js).
50
+ const SPAWN_KINDS = Object.freeze([
51
+ "work-agent",
52
+ "verdict-reask",
53
+ "evidence-gatherer",
54
+ ]);
55
+ const CLOSED_OUTCOMES = Object.freeze([
56
+ "completed",
57
+ "synthetic",
58
+ "transport_timeout",
59
+ "transport_error",
60
+ "transport_empty",
61
+ "transport_parse",
62
+ "killed_confirmed",
63
+ "killed_by_reaper",
64
+ "orphaned_unkillable",
65
+ "orphaned_unverified",
66
+ "transport_failed",
67
+ ]);
68
+ function isClosedOutcome(outcome) {
69
+ return typeof outcome === "string" && CLOSED_OUTCOMES.includes(outcome);
70
+ }
71
+ const TRANSPORT_FAILURE_OUTCOMES = Object.freeze([
72
+ "transport_timeout",
73
+ "transport_error",
74
+ "transport_empty",
75
+ "transport_parse",
76
+ "killed_confirmed",
77
+ "killed_by_reaper",
78
+ "orphaned_unkillable",
79
+ "orphaned_unverified",
80
+ "transport_failed",
81
+ ]);
82
+ function isTransportFailureOutcome(outcome) {
83
+ return typeof outcome === "string" && TRANSPORT_FAILURE_OUTCOMES.includes(outcome);
84
+ }
85
+ const MAX_ATTEMPTS = Object.freeze({
86
+ "work-agent": 3,
87
+ "verdict-reask": 2,
88
+ "evidence-gatherer": 3,
89
+ });
90
+ const TRANSPORT_BUDGET_CAP = 5;
91
+ const REAP_MARGIN_MS = 5 * 60 * 1000;
41
92
 
42
93
  // ---------------------------------------------------------------------------
43
94
  // Errors
@@ -272,6 +323,52 @@ function openDb(crewHome) {
272
323
  const schema = readFileSync(SCHEMA_PATH, "utf8");
273
324
  db.exec(schema);
274
325
  } else {
326
+ // Migration: worker_runs spawn-ledger columns (sandbox exit, chore
327
+ // pilot Piece 2, 2026-09-26). MUST run before db.exec(schema) below:
328
+ // schema.sql declares worker_runs_spawn_key_idx, and the schema exec
329
+ // would fail with "no such column: spawn_key" on a pre-migration
330
+ // database (hit live on the 0.17.2 -> ce305e9 cell upgrade, 2026-09-26).
331
+ // The production spawn wrapper (lib/spawn-step.js pre/post) opens and
332
+ // closes one worker_runs row per creative spawn (work-agent,
333
+ // verdict-reask, evidence-gatherer); the Step-0 reaper
334
+ // (lib/reap-spawns.js) derives reapability from these columns. Additive
335
+ // and NULL for pre-existing rows (the dispatcher rows); the 3-state
336
+ // status CHECK is unchanged — terminal kill states live in the result
337
+ // JSON's closed outcome vocabulary, never as new statuses. attempt_n
338
+ // is derived by count, never stored. Same idempotent PRAGMA-check
339
+ // pattern; never rebuilds the live table.
340
+ // Skip when the table doesn't exist yet (a partial/legacy database):
341
+ // the schema exec below creates it with all columns.
342
+ const workerRunsExists = db.prepare(
343
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name='worker_runs'"
344
+ ).get();
345
+ if (workerRunsExists) {
346
+ const workerRunCols = db.prepare("PRAGMA table_info(worker_runs)").all();
347
+ const workerRunColNames = new Set(workerRunCols.map((c) => c.name));
348
+ const spawnLedgerMigration = [
349
+ ["spawn_key", "TEXT"],
350
+ ["owner_tick_seq", "INTEGER"],
351
+ // SQLite forbids cross-column CHECKs on ADD COLUMN; the single-column
352
+ // form below references only the new column (same shape as the
353
+ // park_reason migration above).
354
+ ["kind", "TEXT CHECK (kind IS NULL OR kind IN ('work-agent','verdict-reask','evidence-gatherer'))"],
355
+ ["timeout_ms", "INTEGER"],
356
+ ["result", "TEXT"],
357
+ ];
358
+ for (const [colName, colType] of spawnLedgerMigration) {
359
+ if (!workerRunColNames.has(colName)) {
360
+ try {
361
+ db.exec(`ALTER TABLE worker_runs ADD COLUMN ${colName} ${colType}`);
362
+ } catch (e) {
363
+ // Another process may have added it concurrently; ignore duplicate-column errors.
364
+ if (!/duplicate column name/i.test(e.message)) throw e;
365
+ }
366
+ }
367
+ }
368
+ // The fresh schema declares worker_runs_spawn_key_idx; existing
369
+ // databases need it too. IF NOT EXISTS keeps the migration idempotent.
370
+ db.exec(`CREATE INDEX IF NOT EXISTS worker_runs_spawn_key_idx ON worker_runs (spawn_key)`);
371
+ }
275
372
  // Idempotent: CREATE TABLE IF NOT EXISTS covers upgrades that only add.
276
373
  const schema = readFileSync(SCHEMA_PATH, "utf8");
277
374
  db.exec(schema);
@@ -1349,6 +1446,182 @@ commands["record-worker-run"] = (db, args) => {
1349
1446
  return { ok: true, id: args.id, status };
1350
1447
  };
1351
1448
 
1449
+ // get-worker-run: read-only lookup of the latest worker-layer run row for
1450
+ // a task+phase (kind IS NULL — spawn-ledger rows are excluded). The chore
1451
+ // driver uses it to reuse its run-telemetry row across invocations instead
1452
+ // of opening one row per tick; a stale running row left by a killed driver
1453
+ // is reused and closed by the next invocation.
1454
+ commands["get-worker-run"] = (db, args) => {
1455
+ if (!args.task_id) throw usageError("task_id is required.");
1456
+ if (!args.phase) throw usageError("phase is required.");
1457
+ const row = db.prepare(
1458
+ `SELECT id, task_id, phase, status, started_at, ended_at, error
1459
+ FROM worker_runs WHERE task_id = ? AND phase = ? AND kind IS NULL
1460
+ ORDER BY id DESC LIMIT 1`
1461
+ ).get(args.task_id, args.phase);
1462
+ if (!row) throw notFound("no worker run for task " + args.task_id + " phase " + args.phase + ".");
1463
+ return { ok: true, row };
1464
+ };
1465
+
1466
+ // Spawn ledger (sandbox exit, chore pilot Piece 2, 2026-09-26). The
1467
+ // production spawn wrapper (lib/spawn-step.js pre/post) and the Step-0
1468
+ // reaper (lib/reap-spawns.js) keep one worker_runs row per creative spawn.
1469
+ // Crew state stays behind this API: writers never touch the DB directly.
1470
+ //
1471
+ // assess-spawn-pre: read-only pre-flight for spawn-step pre — the
1472
+ // duplicate-running guard (M2), the derived attempt_n (F5: 1 + count of
1473
+ // terminal rows for task/phase/kind), and the consecutive
1474
+ // transport-failure budget for the task. Returns
1475
+ // { duplicate_running, attempt_n, max_attempts, transport_failures,
1476
+ // budget_cap }. The caller enforces the caps; this command only reports.
1477
+ commands["assess-spawn-pre"] = (db, args) => {
1478
+ if (!args.task_id) throw usageError("task_id is required.");
1479
+ if (!args.phase) throw usageError("phase is required.");
1480
+ if (!args.kind) throw usageError("kind is required.");
1481
+ if (!SPAWN_KINDS.includes(args.kind)) throw usageError("unknown spawn kind: " + args.kind);
1482
+ if (!args.spawn_key) throw usageError("spawn_key is required.");
1483
+ const dup = db.prepare(
1484
+ "SELECT id FROM worker_runs WHERE spawn_key = ? AND status = 'running' LIMIT 1"
1485
+ ).get(args.spawn_key);
1486
+ const attemptCount = db.prepare(
1487
+ `SELECT COUNT(*) AS n FROM worker_runs
1488
+ WHERE task_id = ? AND phase = ? AND kind = ? AND status != 'running'`
1489
+ ).get(args.task_id, args.phase, args.kind).n;
1490
+ // Consecutive transport failures for the task, newest first: count
1491
+ // transport-failure outcomes until a completed non-transport row resets.
1492
+ // Running rows are in-flight, not terminal — skipped, not counted.
1493
+ // A failed row with an unrecognized outcome counts (fail-closed: an
1494
+ // unknown outcome is a bug, and the safe direction parks the task).
1495
+ const history = db.prepare(
1496
+ `SELECT status, result FROM worker_runs
1497
+ WHERE task_id = ? AND kind IS NOT NULL AND status != 'running'
1498
+ ORDER BY id DESC`
1499
+ ).all(args.task_id);
1500
+ let transportFailures = 0;
1501
+ for (const row of history) {
1502
+ let outcome = null;
1503
+ try {
1504
+ const parsed = row.result ? JSON.parse(row.result) : null;
1505
+ outcome = parsed && typeof parsed.outcome === "string" ? parsed.outcome : null;
1506
+ } catch (e) {
1507
+ outcome = null;
1508
+ }
1509
+ if (row.status === "completed" && !isTransportFailureOutcome(outcome)) break;
1510
+ if (isTransportFailureOutcome(outcome)) {
1511
+ transportFailures++;
1512
+ continue;
1513
+ }
1514
+ if (row.status === "failed") {
1515
+ // Failed with a non-transport outcome (e.g. a reask row closed
1516
+ // completed-but-unrecovered is 'completed'; a genuinely unknown
1517
+ // failed outcome is a bug) — count fail-closed, except the
1518
+ // verdict-reask terminal failures which are explicitly counted.
1519
+ transportFailures++;
1520
+ continue;
1521
+ }
1522
+ // Completed with a transport outcome (should not happen — completed
1523
+ // rows carry completed/synthetic) — count it; the writer is buggy.
1524
+ transportFailures++;
1525
+ }
1526
+ return {
1527
+ ok: true,
1528
+ duplicate_running: !!dup,
1529
+ duplicate_row_id: dup ? dup.id : null,
1530
+ attempt_n: attemptCount + 1,
1531
+ max_attempts: MAX_ATTEMPTS[args.kind],
1532
+ transport_failures: transportFailures,
1533
+ budget_cap: TRANSPORT_BUDGET_CAP,
1534
+ };
1535
+ };
1536
+
1537
+ // record-spawn-open: opens the ledger row. Validates the frozen kind and
1538
+ // re-checks the duplicate-running guard inside the same call (race safety:
1539
+ // two pres for the same key cannot both open). Returns { ok, id } or
1540
+ // { refused: "duplicate_running", row_id }.
1541
+ commands["record-spawn-open"] = (db, args) => {
1542
+ if (!args.task_id) throw usageError("task_id is required.");
1543
+ if (!args.phase) throw usageError("phase is required.");
1544
+ if (!args.spawn_key) throw usageError("spawn_key is required.");
1545
+ if (!SPAWN_KINDS.includes(args.kind)) throw usageError("unknown spawn kind: " + args.kind);
1546
+ if (!Number.isInteger(args.owner_tick_seq) || args.owner_tick_seq <= 0) {
1547
+ throw usageError("owner_tick_seq must be a positive integer.");
1548
+ }
1549
+ if (!Number.isInteger(args.timeout_ms) || args.timeout_ms <= 0) {
1550
+ throw usageError("timeout_ms must be a positive integer.");
1551
+ }
1552
+ const dup = db.prepare(
1553
+ "SELECT id FROM worker_runs WHERE spawn_key = ? AND status = 'running' LIMIT 1"
1554
+ ).get(args.spawn_key);
1555
+ if (dup) return { ok: false, refused: "duplicate_running", row_id: dup.id };
1556
+ const row = db.prepare(
1557
+ `INSERT INTO worker_runs (task_id, phase, executor, status, spawn_key, owner_tick_seq, kind, timeout_ms)
1558
+ VALUES (?, ?, 'worker', 'running', ?, ?, ?, ?) RETURNING id`
1559
+ ).get(args.task_id, args.phase, args.spawn_key, args.owner_tick_seq, args.kind, args.timeout_ms);
1560
+ return { ok: true, id: row.id, status: "running" };
1561
+ };
1562
+
1563
+ // record-spawn-close: closes the ledger row with the typed result. The
1564
+ // closed outcome vocabulary is enforced here fail-closed (K3: both writers
1565
+ // go through this command, and validate before calling). Terminal rows are
1566
+ // immutable — a close against an already-terminal row is a reported no-op.
1567
+ commands["record-spawn-close"] = (db, args) => {
1568
+ if (args.id === undefined || args.id === null) throw usageError("id is required to close a spawn row.");
1569
+ if (!["completed", "failed"].includes(args.status)) {
1570
+ throw usageError("status must be completed or failed.");
1571
+ }
1572
+ let outcome = null;
1573
+ let resultJson = null;
1574
+ if (args.result !== undefined && args.result !== null) {
1575
+ const resultObj = typeof args.result === "string" ? JSON.parse(args.result) : args.result;
1576
+ outcome = resultObj && typeof resultObj.outcome === "string" ? resultObj.outcome : null;
1577
+ if (!isClosedOutcome(outcome)) {
1578
+ throw usageError("result.outcome must be in the closed outcome vocabulary; got: " + outcome);
1579
+ }
1580
+ resultJson = JSON.stringify(resultObj);
1581
+ } else {
1582
+ throw usageError("result (with a closed-vocabulary outcome) is required to close a spawn row.");
1583
+ }
1584
+ const result = db.prepare(
1585
+ `UPDATE worker_runs SET status = ?, error = ?, result = ?, ended_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
1586
+ WHERE id = ? AND status = 'running'`
1587
+ ).run(args.status, args.error || null, resultJson, args.id);
1588
+ if (result.changes === 0) return { ok: true, noop: true };
1589
+ return { ok: true, id: args.id, status: args.status, outcome };
1590
+ };
1591
+
1592
+ // list-reapable-spawns: the Step-0 reaper's candidate set — rows still
1593
+ // 'running' past timeout_ms + REAP_MARGIN_MS. (The design's owner_ended_at
1594
+ // fast path needs a ticks table that does not exist; the timeout clause
1595
+ // alone is correct and fail-closed — reaping is deferred, never early.)
1596
+ commands["list-reapable-spawns"] = (db, args) => {
1597
+ const marginMs = Number.isInteger(args.reap_margin_ms) && args.reap_margin_ms >= 0
1598
+ ? args.reap_margin_ms
1599
+ : REAP_MARGIN_MS;
1600
+ const rows = db.prepare(
1601
+ `SELECT id, task_id, phase, spawn_key, owner_tick_seq, kind, timeout_ms, started_at
1602
+ FROM worker_runs
1603
+ WHERE status = 'running' AND kind IS NOT NULL
1604
+ AND (julianday('now') - julianday(started_at)) * 86400000 > timeout_ms + ?
1605
+ ORDER BY id`
1606
+ ).all(marginMs);
1607
+ return { ok: true, spawns: rows, reap_margin_ms: marginMs };
1608
+ };
1609
+
1610
+ // get-spawn-row: fetch one ledger row by id or spawn_key (spawn-step post
1611
+ // locates the row for the session's key; the reaper reads rows it reaps).
1612
+ commands["get-spawn-row"] = (db, args) => {
1613
+ let row = null;
1614
+ if (args.id !== undefined && args.id !== null) {
1615
+ row = db.prepare("SELECT * FROM worker_runs WHERE id = ?").get(args.id);
1616
+ } else if (args.spawn_key) {
1617
+ row = db.prepare("SELECT * FROM worker_runs WHERE spawn_key = ? ORDER BY id DESC LIMIT 1").get(args.spawn_key);
1618
+ } else {
1619
+ throw usageError("id or spawn_key is required.");
1620
+ }
1621
+ if (!row) throw notFound("spawn row not found.");
1622
+ return { ok: true, row };
1623
+ };
1624
+
1352
1625
  commands["get-run-timeline"] = (db, args) => {
1353
1626
  if (!args.run_id && !args.task_id) throw usageError("run_id or task_id is required.");
1354
1627
  let runs;