muse-crew 0.17.2 → 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 (70) 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/crew-api.js +273 -0
  39. package/lib/crew-dispatch-worker.js +27 -7
  40. package/lib/crew-release.sh +7 -2
  41. package/lib/extract.js +252 -0
  42. package/lib/prompts/tool-check.js +18 -0
  43. package/lib/prompts/trailers.js +59 -0
  44. package/lib/prompts/verdict-reask.js +31 -0
  45. package/lib/prompts/verdict-reask.schema.json +1 -0
  46. package/lib/prompts/work-agent.js +56 -0
  47. package/lib/prompts/work-agent.schema.json +1 -0
  48. package/lib/reap-spawns.js +407 -0
  49. package/lib/schema.sql +12 -1
  50. package/lib/spawn-keys.js +47 -0
  51. package/lib/spawn-step.js +572 -0
  52. package/lib/standard/phases/build.js +120 -0
  53. package/lib/standard/phases/capture.js +163 -0
  54. package/lib/standard/phases/integrate.js +172 -0
  55. package/lib/standard/phases/map.js +119 -0
  56. package/lib/standard/phases/publish.js +565 -0
  57. package/lib/standard/phases/qa.js +399 -0
  58. package/lib/standard/phases/review.js +281 -0
  59. package/lib/standard/phases/triage.js +64 -0
  60. package/lib/test-detached-integrate.sh +47 -0
  61. package/lib/workflow-driver.js +605 -0
  62. package/lib/workflow-lib.js +1012 -0
  63. package/lib/workflow-spec.js +187 -0
  64. package/lib/worktree-lifecycle.sh +55 -3
  65. package/package.json +1 -1
  66. package/seed/cron-body-template.md +61 -9
  67. package/workflows/bugfix.js +17 -17
  68. package/workflows/chore.js +16 -16
  69. package/workflows/docs.js +14 -11
  70. 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();
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;
@@ -25,12 +25,22 @@
25
25
  //
26
26
  // Usage:
27
27
  // node lib/crew-dispatch-worker.js --crew-home <path> [--registry-text <json>]
28
- // [--read-only] [--max-tasks-per-poll <n>]
28
+ // [--read-only] [--authoritative] [--max-tasks-per-poll <n>]
29
29
  //
30
- // --read-only Shadow mode: compute claims but perform no writes — no
31
- // parks, no playtest filing, no completions, no ack, no
32
- // reservations, no decision logs. The worker_runs row is
33
- // still recorded (observability, not task state).
30
+ // (default) Shadow mode: compute claims but perform no writes — no
31
+ // parks, no playtest filing, no completions, no ack, no
32
+ // reservations, no decision logs. The worker_runs row is
33
+ // still recorded (observability, not task state). The
34
+ // default is the safe mode: an invocation without an
35
+ // explicit mode flag can never mutate task state.
36
+ // --read-only Explicit alias for the default shadow mode (backward
37
+ // compatible with the Piece-1 tick's Step 2.5 invocation).
38
+ // --authoritative Acquire claims: reserve-dispatch (same atomic UPSERT
39
+ // the sandboxed dispatcher uses — a live reservation wins,
40
+ // the loser sees acquired:false), acknowledge-poll, the
41
+ // tick-release log, and the dispatch-decision log. Claims
42
+ // carry executor:"worker". The cutover tick invokes this
43
+ // mode explicitly; nothing else should.
34
44
  //
35
45
  // Output: one JSON object on stdout:
36
46
  // { status, message, claims, partial, would_file_playtest, run_id }
@@ -64,7 +74,11 @@ determines eligible tasks, and returns structured launch claims.
64
74
  --crew-home <path> Crew home directory (required).
65
75
  --registry-text <json> Workflow registry JSON text. When omitted, read
66
76
  from <crewHome>/workflows/registry.json.
67
- --read-only Shadow mode: compute claims, perform no writes.
77
+ --read-only Shadow mode (default): compute claims, perform no
78
+ writes. Explicit alias for the default.
79
+ --authoritative Acquire claims via reserve-dispatch, acknowledge
80
+ the poll, write tick-release and dispatch-decision
81
+ logs. The cutover tick invokes this explicitly.
68
82
  --max-tasks-per-poll <n> Override the per-poll task cap (default 6).
69
83
 
70
84
  Output: JSON { status, message, claims, partial, would_file_playtest, run_id }.
@@ -79,6 +93,7 @@ function parseArgs(argv) {
79
93
  else if (a === "--crew-home") { out.crewHome = argv[++i]; }
80
94
  else if (a === "--registry-text") { out.registryText = argv[++i]; }
81
95
  else if (a === "--read-only") { out.readOnly = true; }
96
+ else if (a === "--authoritative") { out.authoritative = true; }
82
97
  else if (a === "--max-tasks-per-poll") { out.maxTasksPerPoll = argv[++i]; }
83
98
  else { throw transportError("CONFIG", "unknown argument: " + a); }
84
99
  }
@@ -468,9 +483,14 @@ async function main() {
468
483
 
469
484
  const crewHome = args.crewHome;
470
485
  if (!crewHome) throw transportError("CONFIG", "--crew-home is required");
486
+ if (args.authoritative && args.readOnly) {
487
+ throw transportError("CONFIG", "--authoritative and --read-only are mutually exclusive");
488
+ }
471
489
  CREW_HOME = crewHome;
472
490
  CREW_API = join(crewHome, "current", "lib", "crew-api.js");
473
- const readOnly = !!args.readOnly;
491
+ // The default is the safe mode: without --authoritative nothing is
492
+ // acquired, acked, or logged. --read-only is an explicit alias for it.
493
+ const readOnly = !args.authoritative;
474
494
  const MAX_TASKS_PER_POLL = args.maxTasksPerPoll ? parseInt(args.maxTasksPerPoll, 10) : 6;
475
495
 
476
496
  // worker_runs ledger: record the run start (identity minted SQLite-side).
@@ -279,8 +279,13 @@ _validate_lib_entries() {
279
279
  fi
280
280
 
281
281
  _gate_row() { # file kind verdict exit reason
282
- node -e 'console.log(JSON.stringify({file:process.argv[1],kind:process.argv[2],verdict:process.argv[3],exit:+process.argv[4],reason:process.argv[5]}))' \
283
- "$1" "$2" "$3" "$4" "$5" >> "$rows"
282
+ # NOTE: must write via fs.appendFileSync, NOT console.log with a shell
283
+ # redirect. console.log to a redirected file is async; node -e can exit
284
+ # before the write flushes, silently dropping (or truncating) rows and
285
+ # breaking the aggregation parse below. (Found 2026-09-26: the gate
286
+ # rejected a fully-passing release on "no entry rows".)
287
+ node -e 'require("fs").appendFileSync(process.argv[6], JSON.stringify({file:process.argv[1],kind:process.argv[2],verdict:process.argv[3],exit:+process.argv[4],reason:process.argv[5]})+"\n")' \
288
+ "$1" "$2" "$3" "$4" "$5" "$rows"
284
289
  }
285
290
 
286
291
  _gate_js() { # $1 = real file path; invoked through $link (the $CREW_HOME/current-shaped symlink)