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,572 @@
1
+ #!/usr/bin/env node
2
+ // lib/spawn-step.js — the production spawn wrapper (sandbox exit, chore
3
+ // pilot Piece 2, 2026-09-26).
4
+ //
5
+ // Shebang CLI. --help answers with usage on stdout, exit 0.
6
+ //
7
+ // The bridge between the deterministic driver (chore-run.js derive) and
8
+ // the tick worker's spawn tool. The tick worker never builds prompts or
9
+ // touches the ledger directly — everything crosses this boundary.
10
+ //
11
+ // pre --request '<json>'
12
+ // Validate bounds+request, enforce the frozen kind vocabulary, check
13
+ // the duplicate-pre guard (M2), derive attempt_n (F5), enforce the
14
+ // per-kind attempt cap and the consecutive transport-failure budget,
15
+ // open the ledger row (crew-api record-spawn-open), build the prompt
16
+ // from the registered pure builder, mint the session path. Prints one
17
+ // JSON line on stdout:
18
+ // {prompt,cwd,env,timeout_ms,session_path,key,attempt_n,row_id}
19
+ // Exit non-zero = refused; stdout carries {error:{code,message}} with
20
+ // code in BOUNDS_REJECTED | DUPLICATE_RUNNING | ATTEMPTS_EXHAUSTED |
21
+ // TRANSPORT_BUDGET_EXHAUSTED.
22
+ //
23
+ // post --session '<path>' [--key '<spawn_key>']
24
+ // Read the session file, classify per the routing order (extractors →
25
+ // reask signal → transport outcomes; kill states only with machine
26
+ // evidence — an unverified kill leaves the row running for the
27
+ // reaper), close the ledger row (crew-api record-spawn-close) with a
28
+ // closed-vocabulary outcome. Prints the typed result on stdout:
29
+ // {closed:true,row_id,status,outcome,...payload}
30
+ // or {closed:false,reason} when the row was left running.
31
+ //
32
+ // Session file schema (written by the tick worker):
33
+ // { key, child: {kind:"subagent",agent_id} | {kind:"os_pid",pid,start_time},
34
+ // outcome: "returned"|"timeout"|"spawn_error"|"synthetic",
35
+ // text?, error?, kill_evidence?: {verified_dead, method, detail} }
36
+ // The tick worker records `child` immediately after spawning (so the
37
+ // Step-0 reaper can find it) and the outcome when the spawn settles.
38
+ // outcome "synthetic" is the P3 no-spawn branch: the workflow authored the
39
+ // prose, no agent was spawned; it flows through the same extractors and
40
+ // closes the row identically — the bridge never requires a spawn to
41
+ // produce a typed result.
42
+ //
43
+ // The closed outcome vocabulary is enforced fail-closed here AND in
44
+ // crew-api record-spawn-close: a row may only leave 'running' with a
45
+ // CLOSED_OUTCOMES outcome. Agent output is data, never executable —
46
+ // extractors parse it, nothing evals it.
47
+
48
+ import { execFileSync } from "node:child_process";
49
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
50
+ import { join, dirname, resolve } from "node:path";
51
+ import { fileURLToPath } from "node:url";
52
+ import { validateSpawnBounds } from "./spawn-boundary.js";
53
+ import {
54
+ SPAWN_KINDS,
55
+ MAX_ATTEMPTS,
56
+ TRANSPORT_BUDGET_CAP,
57
+ MAX_TIMEOUT_MS,
58
+ MIN_TIMEOUT_MS,
59
+ isClosedOutcome,
60
+ } from "./chore/spawn-vocab.js";
61
+ import {
62
+ extractVerdict,
63
+ extractMarkerLines,
64
+ parseToolSignals,
65
+ extractReleaseDecision,
66
+ extractWorktree,
67
+ extractExperiential,
68
+ } from "./chore/extract.js";
69
+ import { buildWorkPrompt } from "./chore/prompts/work-agent.js";
70
+ import { buildVerdictReaskPrompt } from "./chore/prompts/verdict-reask.js";
71
+ import { buildEvidenceGathererPrompt } from "./chore/prompts/evidence-gatherer.js";
72
+
73
+ const LIB_DIR = dirname(fileURLToPath(import.meta.url));
74
+ const CREW_API = join(LIB_DIR, "crew-api.js");
75
+ const PROMPTS_DIR = join(LIB_DIR, "chore", "prompts");
76
+
77
+ // Closed kind → (prompt module, schema file, builder). Unknown kinds fail
78
+ // closed in pre. The request's bounds.prompt_file / bounds.schema_file must
79
+ // equal these exact paths — a chore-run.js bug pointing at the wrong
80
+ // builder is rejected, not silently honored.
81
+ const KIND_REGISTRY = {
82
+ "work-agent": {
83
+ promptFile: join(PROMPTS_DIR, "work-agent.js"),
84
+ schemaFile: join(PROMPTS_DIR, "work-agent.schema.json"),
85
+ build: buildWorkPrompt,
86
+ },
87
+ "verdict-reask": {
88
+ promptFile: join(PROMPTS_DIR, "verdict-reask.js"),
89
+ schemaFile: join(PROMPTS_DIR, "verdict-reask.schema.json"),
90
+ build: buildVerdictReaskPrompt,
91
+ },
92
+ "evidence-gatherer": {
93
+ promptFile: join(PROMPTS_DIR, "evidence-gatherer.js"),
94
+ schemaFile: join(PROMPTS_DIR, "evidence-gatherer.schema.json"),
95
+ build: buildEvidenceGathererPrompt,
96
+ },
97
+ };
98
+
99
+ const USAGE = [
100
+ "usage: spawn-step.js pre --request '<json>'",
101
+ " spawn-step.js post --session '<path>' [--key '<spawn_key>']",
102
+ " spawn-step.js --help",
103
+ "",
104
+ "The production spawn wrapper: pre validates the typed spawn request,",
105
+ "opens the worker_runs ledger row, builds the prompt, and mints the",
106
+ "session path; post classifies the session file and closes the row with",
107
+ "a closed-vocabulary outcome. Refusal codes: BOUNDS_REJECTED |",
108
+ "DUPLICATE_RUNNING | ATTEMPTS_EXHAUSTED | TRANSPORT_BUDGET_EXHAUSTED.",
109
+ ].join("\n");
110
+
111
+ function log(msg) {
112
+ process.stderr.write("[spawn-step] " + msg + "\n");
113
+ }
114
+
115
+ function printJson(obj) {
116
+ process.stdout.write(JSON.stringify(obj) + "\n");
117
+ }
118
+
119
+ function fail(code, message, extra) {
120
+ printJson({ error: Object.assign({ code, message }, extra || {}) });
121
+ process.exit(1);
122
+ }
123
+
124
+ function callCrewApi(crewHome, command, argsObj) {
125
+ const argv = ["--crew-home", crewHome, command];
126
+ if (argsObj !== undefined) argv.push("--json", JSON.stringify(argsObj));
127
+ let stdout;
128
+ try {
129
+ stdout = execFileSync("node", [CREW_API, ...argv], {
130
+ encoding: "utf8",
131
+ maxBuffer: 16 * 1024 * 1024,
132
+ });
133
+ } catch (e) {
134
+ const detail = (e.stderr && e.stderr.toString()) || e.message;
135
+ throw new Error("crew-api " + command + " failed: " + detail.slice(0, 500));
136
+ }
137
+ try {
138
+ return JSON.parse(stdout);
139
+ } catch (e) {
140
+ throw new Error("crew-api " + command + " returned unparseable JSON");
141
+ }
142
+ }
143
+
144
+ function isPlainObject(v) {
145
+ return v !== null && typeof v === "object" && !Array.isArray(v);
146
+ }
147
+
148
+ function requireNonEmptyString(v, name) {
149
+ if (typeof v !== "string" || v.length === 0) {
150
+ throw new Error("request." + name + " must be a non-empty string");
151
+ }
152
+ return v;
153
+ }
154
+
155
+ // ── pre ──────────────────────────────────────────────────────────────
156
+
157
+ function validatePreRequest(req) {
158
+ if (!isPlainObject(req)) throw new Error("request must be a JSON object");
159
+ const key = requireNonEmptyString(req.key, "key");
160
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(key)) {
161
+ throw new Error("request.key must be [A-Za-z0-9_-], up to 128 chars");
162
+ }
163
+ const kind = requireNonEmptyString(req.kind, "kind");
164
+ if (!SPAWN_KINDS.includes(kind)) {
165
+ throw new Error("unknown spawn kind: " + kind + " (frozen vocabulary: " + SPAWN_KINDS.join(", ") + ")");
166
+ }
167
+ const taskId = requireNonEmptyString(req.task_id, "task_id");
168
+ const phase = requireNonEmptyString(req.phase, "phase");
169
+ requireNonEmptyString(req.identity, "identity");
170
+ if (!Number.isInteger(req.owner_tick_seq) || req.owner_tick_seq <= 0) {
171
+ throw new Error("request.owner_tick_seq must be a positive integer");
172
+ }
173
+ if (!Number.isInteger(req.timeout_ms) || req.timeout_ms < MIN_TIMEOUT_MS || req.timeout_ms > MAX_TIMEOUT_MS) {
174
+ throw new Error(
175
+ "request.timeout_ms must be an integer in [" + MIN_TIMEOUT_MS + ", " + MAX_TIMEOUT_MS + "]"
176
+ );
177
+ }
178
+ const bounds = req.bounds;
179
+ if (!isPlainObject(bounds)) throw new Error("request.bounds must be an object");
180
+ const promptFile = requireNonEmptyString(bounds.prompt_file, "bounds.prompt_file");
181
+ const schemaFile = requireNonEmptyString(bounds.schema_file, "bounds.schema_file");
182
+ // Closed kind→file mapping: the orchestrator declares its bounds, pre
183
+ // verifies they are exactly the registered files for the kind.
184
+ const reg = KIND_REGISTRY[kind];
185
+ if (resolve(promptFile) !== reg.promptFile) {
186
+ throw new Error("bounds.prompt_file is not the registered builder for kind '" + kind + "'");
187
+ }
188
+ if (resolve(schemaFile) !== reg.schemaFile) {
189
+ throw new Error("bounds.schema_file is not the registered schema for kind '" + kind + "'");
190
+ }
191
+ if (!isPlainObject(req.prompt_params)) throw new Error("request.prompt_params must be an object");
192
+ if (req.env !== undefined || bounds.env !== undefined) {
193
+ const env = bounds.env;
194
+ if (!isPlainObject(env)) throw new Error("bounds.env must be an object of string values");
195
+ for (const k of Object.keys(env)) {
196
+ if (typeof env[k] !== "string") throw new Error("bounds.env['" + k + "'] must be a string");
197
+ }
198
+ }
199
+ return { key, kind, taskId, phase, bounds, reg };
200
+ }
201
+
202
+ function checkFileReadable(path, what) {
203
+ try {
204
+ readFileSync(path, "utf8");
205
+ } catch (e) {
206
+ throw new Error(what + " not readable: " + path + " (" + e.message + ")");
207
+ }
208
+ }
209
+
210
+ // Close an opened ledger row as a refused open (transport_failed) so a
211
+ // post-open pre failure never leaves a running row. Best-effort: if the
212
+ // close itself fails, the Step-0 reaper owns the orphaned running row.
213
+ function closeOpenedAsRefused(crewHome, rowId, kind, taskId, phase, attemptN, key, error) {
214
+ try {
215
+ callCrewApi(crewHome, "record-spawn-close", {
216
+ id: rowId,
217
+ status: "failed",
218
+ result: {
219
+ outcome: "transport_failed",
220
+ kind,
221
+ task_id: taskId,
222
+ phase,
223
+ attempt_n: attemptN,
224
+ spawn_key: key,
225
+ error,
226
+ },
227
+ });
228
+ } catch (closeErr) {
229
+ log("WARNING: failed to close refused row " + rowId + ": " + closeErr.message);
230
+ }
231
+ }
232
+
233
+ function cmdPre(requestJson) {
234
+ let req;
235
+ try {
236
+ req = JSON.parse(requestJson);
237
+ } catch (e) {
238
+ fail("BOUNDS_REJECTED", "request is not valid JSON: " + e.message);
239
+ }
240
+ let v;
241
+ try {
242
+ v = validatePreRequest(req);
243
+ } catch (e) {
244
+ fail("BOUNDS_REJECTED", e.message);
245
+ }
246
+ const { key, kind, taskId, phase, bounds, reg } = v;
247
+ const crewHome = bounds.crewHome;
248
+
249
+ // Bounds validation (fail closed) + prompt/schema existence + readability.
250
+ // A bad path is an orchestrator bug — rejected before attempts burn.
251
+ try {
252
+ validateSpawnBounds(bounds);
253
+ checkFileReadable(reg.promptFile, "prompt_file");
254
+ checkFileReadable(reg.schemaFile, "schema_file");
255
+ } catch (e) {
256
+ fail("BOUNDS_REJECTED", e.message);
257
+ }
258
+
259
+ // Ledger pre-flight: duplicate guard (M2), attempt derivation (F5),
260
+ // transport budget — all read-only, one crew-api call.
261
+ let assess;
262
+ try {
263
+ assess = callCrewApi(crewHome, "assess-spawn-pre", {
264
+ task_id: taskId,
265
+ phase,
266
+ kind,
267
+ spawn_key: key,
268
+ });
269
+ } catch (e) {
270
+ fail("BOUNDS_REJECTED", "ledger pre-flight failed: " + e.message);
271
+ }
272
+ if (assess.duplicate_running) {
273
+ fail("DUPLICATE_RUNNING", "a running ledger row already exists for spawn_key " + key, {
274
+ row_id: assess.duplicate_row_id,
275
+ });
276
+ }
277
+ if (assess.attempt_n > assess.max_attempts) {
278
+ fail("ATTEMPTS_EXHAUSTED", "attempt " + assess.attempt_n + " exceeds max_attempts=" + assess.max_attempts + " for kind '" + kind + "'", {
279
+ attempt_n: assess.attempt_n,
280
+ max_attempts: assess.max_attempts,
281
+ });
282
+ }
283
+ if (assess.transport_failures >= assess.budget_cap) {
284
+ fail("TRANSPORT_BUDGET_EXHAUSTED", "task " + taskId + " has " + assess.transport_failures + " consecutive transport failures (cap " + assess.budget_cap + ")", {
285
+ transport_failures: assess.transport_failures,
286
+ budget_cap: assess.budget_cap,
287
+ });
288
+ }
289
+
290
+ // Open the ledger row (re-checks the duplicate guard for race safety).
291
+ let opened;
292
+ try {
293
+ opened = callCrewApi(crewHome, "record-spawn-open", {
294
+ task_id: taskId,
295
+ phase,
296
+ spawn_key: key,
297
+ owner_tick_seq: req.owner_tick_seq,
298
+ kind,
299
+ timeout_ms: req.timeout_ms,
300
+ });
301
+ } catch (e) {
302
+ fail("BOUNDS_REJECTED", "ledger open failed: " + e.message);
303
+ }
304
+ if (opened && opened.ok === false && opened.refused === "duplicate_running") {
305
+ fail("DUPLICATE_RUNNING", "a running ledger row already exists for spawn_key " + key, {
306
+ row_id: opened.row_id,
307
+ });
308
+ }
309
+ if (!opened || !opened.ok || !opened.id) {
310
+ fail("BOUNDS_REJECTED", "ledger open returned an unexpected result");
311
+ }
312
+
313
+ // Build the prompt from the registered pure builder. A params bug is an
314
+ // orchestrator bug — rejected before the spawn burns.
315
+ let prompt;
316
+ try {
317
+ prompt = reg.build(req.prompt_params);
318
+ } catch (e) {
319
+ // The row was opened but the request is malformed: close it as a
320
+ // refused open so it never lingers as running.
321
+ closeOpenedAsRefused(crewHome, opened.id, kind, taskId, phase, assess.attempt_n, key,
322
+ "prompt build failed: " + e.message);
323
+ fail("BOUNDS_REJECTED", "prompt build failed: " + e.message);
324
+ }
325
+ if (typeof prompt !== "string" || prompt.length === 0) {
326
+ closeOpenedAsRefused(crewHome, opened.id, kind, taskId, phase, assess.attempt_n, key,
327
+ "prompt builder returned an empty prompt");
328
+ fail("BOUNDS_REJECTED", "prompt builder returned an empty prompt");
329
+ }
330
+
331
+ // Mint the session path and write the opened record. The tick worker
332
+ // records `child` here right after spawning, then the outcome.
333
+ const sessionDir = join(crewHome, ".spawn-sessions");
334
+ const sessionPath = join(sessionDir, key + ".json");
335
+ try {
336
+ mkdirSync(sessionDir, { recursive: true });
337
+ writeFileSync(
338
+ sessionPath,
339
+ JSON.stringify({
340
+ key,
341
+ kind,
342
+ task_id: taskId,
343
+ phase,
344
+ attempt_n: assess.attempt_n,
345
+ row_id: opened.id,
346
+ status: "spawn_pending",
347
+ opened_at: new Date().toISOString(),
348
+ }) + "\n",
349
+ "utf8"
350
+ );
351
+ } catch (e) {
352
+ closeOpenedAsRefused(crewHome, opened.id, kind, taskId, phase, assess.attempt_n, key,
353
+ "could not write session file: " + e.message);
354
+ fail("BOUNDS_REJECTED", "could not write session file: " + e.message);
355
+ }
356
+
357
+ log("pre ok: kind=" + kind + " task=" + taskId + " phase=" + phase + " attempt_n=" + assess.attempt_n + " row=" + opened.id);
358
+ printJson({
359
+ prompt,
360
+ cwd: bounds.cwd,
361
+ env: bounds.env || {},
362
+ timeout_ms: req.timeout_ms,
363
+ session_path: sessionPath,
364
+ key,
365
+ attempt_n: assess.attempt_n,
366
+ row_id: opened.id,
367
+ });
368
+ process.exit(0);
369
+ }
370
+
371
+ // ── post ─────────────────────────────────────────────────────────────
372
+
373
+ function extractTypedResult(kind, text) {
374
+ const verdictRes = extractVerdict(text);
375
+ const base = {
376
+ verdict: verdictRes.ok ? (verdictRes.passed ? "PASS" : "FAIL") : null,
377
+ verdict_ok: verdictRes.ok,
378
+ tool_signals: parseToolSignals(text),
379
+ markers: extractMarkerLines(text),
380
+ };
381
+ if (kind === "verdict-reask" || kind === "evidence-gatherer") {
382
+ return base;
383
+ }
384
+ // work-agent (and the synthetic no-spawn branch): the full extraction.
385
+ base.worktree = extractWorktree(text);
386
+ base.release_decision = extractReleaseDecision(text);
387
+ base.experiential = extractExperiential(text);
388
+ return base;
389
+ }
390
+
391
+ function closeRow(crewHome, id, status, resultObj, errorNote) {
392
+ const outcome = resultObj.outcome;
393
+ if (!isClosedOutcome(outcome)) {
394
+ throw new Error("refusing to close with outcome outside the closed vocabulary: " + outcome);
395
+ }
396
+ const closed = callCrewApi(crewHome, "record-spawn-close", {
397
+ id,
398
+ status,
399
+ error: errorNote || null,
400
+ result: resultObj,
401
+ });
402
+ return closed;
403
+ }
404
+
405
+ function cmdPost(sessionPath, keyArg) {
406
+ let session;
407
+ try {
408
+ session = JSON.parse(readFileSync(sessionPath, "utf8"));
409
+ } catch (e) {
410
+ // No session, no outcome — the row stays running; the Step-0 reaper
411
+ // owns it. Post must not invent a result.
412
+ printJson({ closed: false, reason: "session_unreadable", detail: e.message });
413
+ process.exit(1);
414
+ }
415
+ const key = keyArg || session.key;
416
+ if (!key) {
417
+ printJson({ closed: false, reason: "session_unreadable", detail: "no spawn key" });
418
+ process.exit(1);
419
+ }
420
+ if (keyArg && session.key && keyArg !== session.key) {
421
+ printJson({ closed: false, reason: "key_mismatch", detail: "argv key != session key" });
422
+ process.exit(1);
423
+ }
424
+ // crewHome: the session file lives under <crewHome>/.spawn-sessions/.
425
+ const crewHome = resolve(sessionPath, "..", "..");
426
+ let rowRes;
427
+ try {
428
+ rowRes = callCrewApi(crewHome, "get-spawn-row", { spawn_key: key });
429
+ } catch (e) {
430
+ printJson({ closed: false, reason: "row_not_found", detail: e.message });
431
+ process.exit(1);
432
+ }
433
+ const row = rowRes.row;
434
+ if (row.status !== "running") {
435
+ // Idempotent: a duplicate post after the close is a no-op, not an error.
436
+ printJson({ closed: false, reason: "already_closed", row_id: row.id, status: row.status });
437
+ process.exit(0);
438
+ }
439
+ const kind = row.kind;
440
+ const common = {
441
+ kind,
442
+ task_id: row.task_id,
443
+ phase: row.phase,
444
+ attempt_n: null,
445
+ spawn_key: key,
446
+ };
447
+ try {
448
+ common.attempt_n = session.attempt_n || null;
449
+ } catch (e) { /* leave null */ }
450
+
451
+ const outcome = session.outcome;
452
+ let status;
453
+ let resultObj;
454
+
455
+ if (outcome === "synthetic") {
456
+ // P3 no-spawn branch: workflow-authored prose, no agent spawned. Same
457
+ // extractors, same close — the bridge never requires a spawn.
458
+ const text = typeof session.text === "string" ? session.text : "";
459
+ const typed = extractTypedResult(kind, text);
460
+ if (typed.verdict_ok) {
461
+ status = "completed";
462
+ resultObj = Object.assign({}, common, typed, { outcome: "synthetic" });
463
+ } else {
464
+ // Workflow-authored prose that fails extraction is an orchestrator
465
+ // bug — fail closed immediately (no reask for synthetic).
466
+ status = "failed";
467
+ resultObj = Object.assign({}, common, typed, {
468
+ outcome: "transport_parse",
469
+ error: "synthetic prose failed verdict extraction",
470
+ });
471
+ }
472
+ } else if (outcome === "returned") {
473
+ const text = typeof session.text === "string" ? session.text : "";
474
+ if (text.trim() === "") {
475
+ status = "failed";
476
+ resultObj = Object.assign({}, common, {
477
+ outcome: "transport_empty",
478
+ error: "agent returned blank text",
479
+ });
480
+ } else {
481
+ const typed = extractTypedResult(kind, text);
482
+ status = "completed";
483
+ resultObj = Object.assign({}, common, typed, { outcome: "completed" });
484
+ if (!typed.verdict_ok) {
485
+ // Verdict missing: the row still closes (the spawn is done). The
486
+ // driver sees needs_reask and runs the bounded verdict-reask loop
487
+ // — TRANSPORT_PARSE only after that budget is exhausted.
488
+ resultObj.needs_reask = true;
489
+ }
490
+ }
491
+ } else if (outcome === "timeout") {
492
+ const killEvidence = session.kill_evidence || {};
493
+ if (killEvidence.verified_dead === true) {
494
+ // Writer-is-verifier: the tick worker's machine liveness check
495
+ // proved the child dead. Close with the kill state.
496
+ status = "failed";
497
+ resultObj = Object.assign({}, common, {
498
+ outcome: "killed_confirmed",
499
+ kill_evidence: killEvidence,
500
+ });
501
+ } else {
502
+ // Unconfirmed kill: the row stays running. The cross-tick reaper is
503
+ // the verifier of record — post must not promote an unverified
504
+ // kill to a terminal state.
505
+ printJson({ closed: false, reason: "kill_unverified", row_id: row.id });
506
+ process.exit(0);
507
+ }
508
+ } else if (outcome === "spawn_error") {
509
+ status = "failed";
510
+ resultObj = Object.assign({}, common, {
511
+ outcome: "transport_error",
512
+ error: typeof session.error === "string" ? session.error : "spawn failed",
513
+ });
514
+ } else {
515
+ // Unknown session outcome — the tick worker wrote garbage. Terminal
516
+ // and safe (counts toward the transport budget), never guessed.
517
+ status = "failed";
518
+ resultObj = Object.assign({}, common, {
519
+ outcome: "transport_failed",
520
+ error: "unknown session outcome: " + String(outcome),
521
+ });
522
+ }
523
+
524
+ let closed;
525
+ try {
526
+ closed = closeRow(crewHome, row.id, status, resultObj);
527
+ } catch (e) {
528
+ printJson({ closed: false, reason: "close_failed", detail: e.message, row_id: row.id });
529
+ process.exit(1);
530
+ }
531
+ if (closed && closed.noop) {
532
+ printJson({ closed: false, reason: "already_closed", row_id: row.id });
533
+ process.exit(0);
534
+ }
535
+ log("post ok: row=" + row.id + " status=" + status + " outcome=" + resultObj.outcome);
536
+ printJson(Object.assign({ closed: true, row_id: row.id, status }, resultObj));
537
+ process.exit(0);
538
+ }
539
+
540
+ // ── main ─────────────────────────────────────────────────────────────
541
+
542
+ function main() {
543
+ const argv = process.argv.slice(2);
544
+ if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") {
545
+ process.stdout.write(USAGE + "\n");
546
+ process.exit(0);
547
+ }
548
+ const sub = argv[0];
549
+ if (sub === "pre") {
550
+ const ri = argv.indexOf("--request");
551
+ if (ri === -1 || !argv[ri + 1]) {
552
+ printJson({ error: { code: "BOUNDS_REJECTED", message: "pre requires --request '<json>'" } });
553
+ process.exit(1);
554
+ }
555
+ cmdPre(argv[ri + 1]);
556
+ return;
557
+ }
558
+ if (sub === "post") {
559
+ const si = argv.indexOf("--session");
560
+ if (si === -1 || !argv[si + 1]) {
561
+ printJson({ error: { code: "BOUNDS_REJECTED", message: "post requires --session '<path>'" } });
562
+ process.exit(1);
563
+ }
564
+ const ki = argv.indexOf("--key");
565
+ cmdPost(argv[si + 1], ki === -1 ? null : argv[ki + 1]);
566
+ return;
567
+ }
568
+ printJson({ error: { code: "BOUNDS_REJECTED", message: "unknown subcommand: " + sub } });
569
+ process.exit(1);
570
+ }
571
+
572
+ main();