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,87 @@
1
+ // lib/chore/spawn-vocab.js — closed vocabularies for the production spawn
2
+ // bridge (sandbox exit, chore pilot Piece 2, 2026-09-26).
3
+ //
4
+ // Import-safe: pure constants, no I/O, no clock, no randomness. Single
5
+ // source of truth shared by lib/spawn-step.js, lib/reap-spawns.js, and the
6
+ // spawn-ledger commands in lib/crew-api.js.
7
+ //
8
+ // The frozen creative spawn kinds (DESIGN-chore-pilot.md §1): work-agent,
9
+ // verdict-reask, evidence-gatherer. Unknown kinds fail closed at the bridge.
10
+
11
+ export const SPAWN_KINDS = Object.freeze([
12
+ "work-agent",
13
+ "verdict-reask",
14
+ "evidence-gatherer",
15
+ ]);
16
+
17
+ // Terminal outcome vocabulary for the worker_runs.result JSON. Both writers
18
+ // (spawn-step post, reap-spawns.js) enforce this closed set fail-closed: a
19
+ // row may only leave 'running' with one of these outcomes. The 3-state
20
+ // status CHECK (running|completed|failed) is unchanged — kill states live
21
+ // here, in the result JSON, never as new statuses.
22
+ export const CLOSED_OUTCOMES = Object.freeze([
23
+ "completed", // spawn returned; typed result in the payload
24
+ "synthetic", // no-spawn branch: workflow-authored prose, extracted identically
25
+ "transport_timeout", // agent did not return within timeout_ms
26
+ "transport_error", // the spawn itself failed
27
+ "transport_empty", // agent returned blank text
28
+ "transport_parse", // verdict unrecoverable after the task-domain reask budget
29
+ "killed_confirmed", // timeout + machine-verified dead
30
+ "killed_by_reaper", // reaper closed an already-dead child
31
+ "orphaned_unkillable", // child believed alive but unkillable
32
+ "orphaned_unverified", // child liveness unverifiable; no kill attempted
33
+ "transport_failed", // terminal transport failure (unrecoverable)
34
+ ]);
35
+
36
+ export function isClosedOutcome(outcome) {
37
+ return typeof outcome === "string" && CLOSED_OUTCOMES.includes(outcome);
38
+ }
39
+
40
+ // Outcomes that count toward the per-task consecutive transport-failure
41
+ // budget (DESIGN-chore-pilot.md §4): terminal TRANSPORT_TIMEOUT /
42
+ // TRANSPORT_ERROR / TRANSPORT_PARSE equivalents, orphaned_*,
43
+ // killed_by_reaper, and terminal reask failures. Reset by any completed
44
+ // non-transport row for the task.
45
+ export const TRANSPORT_FAILURE_OUTCOMES = Object.freeze([
46
+ "transport_timeout",
47
+ "transport_error",
48
+ "transport_empty",
49
+ "transport_parse",
50
+ "killed_confirmed",
51
+ "killed_by_reaper",
52
+ "orphaned_unkillable",
53
+ "orphaned_unverified",
54
+ "transport_failed",
55
+ ]);
56
+
57
+ export function isTransportFailureOutcome(outcome) {
58
+ return typeof outcome === "string" && TRANSPORT_FAILURE_OUTCOMES.includes(outcome);
59
+ }
60
+
61
+ // Per-kind attempt caps, mirroring the chore workflow's bounded loops:
62
+ // work-agent 3 attempts (t0-t2), verdict-reask 2 attempts (180s each).
63
+ // evidence-gatherer has no chore site; 3 is the work-agent analog —
64
+ // revisit when the kind gets its Piece 4 contract.
65
+ export const MAX_ATTEMPTS = Object.freeze({
66
+ "work-agent": 3,
67
+ "verdict-reask": 2,
68
+ "evidence-gatherer": 3,
69
+ });
70
+
71
+ // Consecutive transport failures for one task before the bridge refuses
72
+ // new spawns (the task parks instead of burning attempts).
73
+ export const TRANSPORT_BUDGET_CAP = 5;
74
+
75
+ // Platform tick kill: the tick worker is killed at 90m. A spawn's timeout
76
+ // must leave headroom for post-processing and the driver loop, so the
77
+ // tick never dies mid-spawn-wait.
78
+ export const TICK_KILL_MS = 90 * 60 * 1000;
79
+ export const SPAWN_HEADROOM_MS = 10 * 60 * 1000;
80
+ export const MAX_TIMEOUT_MS = TICK_KILL_MS - SPAWN_HEADROOM_MS;
81
+ export const MIN_TIMEOUT_MS = 60 * 1000;
82
+
83
+ // Reaper: a running row is reapable once it has been past its timeout_ms
84
+ // for longer than this margin (slow-post / clock-skew grace). Unknown kill
85
+ // states keep the row running — it may only leave 'running' with a closed
86
+ // outcome.
87
+ export const REAP_MARGIN_MS = 5 * 60 * 1000;
@@ -0,0 +1,538 @@
1
+ #!/usr/bin/env node
2
+ // lib/chore-run.js — the Chore driver loop: the worker-layer port of
3
+ // workflows/chore.js (sandbox-exit Piece 3, 2026-09-26).
4
+ //
5
+ // The tick worker invokes this once per tick for a dispatched Chore task:
6
+ // node lib/chore-run.js --crew-home H --task-id T --tick-seq N [options]
7
+ //
8
+ // Per-boundary stateless invocation. Stdout carries EXACTLY ONE JSON line:
9
+ // {"type":"NEED_SPAWN","request":{...}} — post the request via the spawn
10
+ // bridge (spawn-step.js pre), then re-invoke when it lands.
11
+ // {"type":"WAIT","reason":..,"key":..,"phase":..} — a spawn is in flight;
12
+ // re-invoke when the spawn row goes terminal.
13
+ // {"type":"DONE","outcome":..,"task_id":..} — outcome is one of
14
+ // completed | failed | parked | park-failed | stood-down.
15
+ //
16
+ // PROTOCOL NOTE (2026-09-26): the frozen design named only NEED_SPAWN and
17
+ // DONE. WAIT is a deliberate third frame, not a silent expansion. When a
18
+ // spawn is already posted and running, re-emitting NEED_SPAWN would instruct
19
+ // the launcher to post a duplicate (record-spawn-open refuses with
20
+ // duplicate_running, forcing the launcher to interpret the refusal as
21
+ // "wait" — decision logic leaking out of the driver). Emitting DONE would
22
+ // lie about the run being over. WAIT keeps the single-decision-point
23
+ // contract: the driver decides, the launcher only posts and re-invokes.
24
+ // Flagged for cutover review; the design doc is amended only on approval.
25
+ // All logs go to stderr. All run state is derived from Crew API reads
26
+ // (tasks, sessions, events, spawn-ledger rows) — there is no run-state
27
+ // file anywhere. A derivation failure is a transport failure: the driver
28
+ // fails closed with DONE/failed, never a partial advance.
29
+ //
30
+ // Concurrency discipline (same as the old workflow): one invocation per
31
+ // task at a time. The tick worker must not invoke this for a task with a
32
+ // running session unless the previous invocation is known dead. A lost
33
+ // claim race stands down quietly via the claim-as-gate.
34
+ //
35
+ // No wall-clock reads and no randomness in decision code (G3). Timestamps
36
+ // are minted SQLite-side by crew-api.js.
37
+
38
+ // Spawn-pre refusal codes that are PERMANENT (2026-09-26): the refusal fires
39
+ // before any spawn-ledger row is opened, so re-deriving would emit an
40
+ // identical NEED_SPAWN forever — no ledger state advances. The ferry
41
+ // (chore-tick.js) reports these back via --pre-refusal and the driver
42
+ // terminates instead of re-emitting NEED_SPAWN. DUPLICATE_RUNNING is the
43
+ // legitimate re-drive case (a spawn is in flight; the next derivation
44
+ // resolves to WAIT). This list must match TERMINAL_REFUSAL_CODES in
45
+ // lib/chore-tick.js.
46
+ const TERMINAL_REFUSAL_CODES = [
47
+ "BOUNDS_REJECTED", // spawn bounds invalid (project misconfiguration) → PARK
48
+ "ATTEMPTS_EXHAUSTED", // spawn attempts exhausted → FAILED
49
+ "TRANSPORT_BUDGET_EXHAUSTED", // consecutive transport failures exhausted → FAILED
50
+ ];
51
+
52
+ import { rmSync, readdirSync } from "node:fs";
53
+ import { execFile } from "node:child_process";
54
+ import { pathToFileURL } from "node:url";
55
+
56
+ import {
57
+ deriveRunEnv, pinLifecycle, parsePinListing, PIN_BASENAMES,
58
+ deriveReworkCount, projectGuard, parkTask,
59
+ } from "./chore/phase-lib.js";
60
+ import { PHASE as TRIAGE, runPhase as triageRun } from "./chore/phases/triage.js";
61
+ import { PHASE as CAPTURE, runPhase as captureRun } from "./chore/phases/capture.js";
62
+ import { PHASE as MAP, runPhase as mapRun } from "./chore/phases/map.js";
63
+ import { PHASE as BUILD, runPhase as buildRun } from "./chore/phases/build.js";
64
+ import { PHASE as REVIEW, runPhase as reviewRun } from "./chore/phases/review.js";
65
+ import { PHASE as INTEGRATE, runPhase as integrateRun } from "./chore/phases/integrate.js";
66
+ import { PHASE as PUBLISH, runPhase as publishRun } from "./chore/phases/publish.js";
67
+
68
+ var PHASES = {
69
+ Triage: { def: TRIAGE, run: triageRun },
70
+ Capture: { def: CAPTURE, run: captureRun },
71
+ Map: { def: MAP, run: mapRun },
72
+ Build: { def: BUILD, run: buildRun },
73
+ Review: { def: REVIEW, run: reviewRun },
74
+ Integrate: { def: INTEGRATE, run: integrateRun },
75
+ Publish: { def: PUBLISH, run: publishRun },
76
+ };
77
+ var PHASE_ORDER = ["Triage", "Capture", "Map", "Build", "Review", "Integrate", "Publish"];
78
+
79
+ // Runaway guard: REWIND is self-resolving (Map→Capture writes the evidence
80
+ // the gate needs; Review→Build is capped at 2 by the phase itself), so 20
81
+ // phase iterations per invocation is far beyond any legitimate run.
82
+ var MAX_PHASE_ITERATIONS = 20;
83
+
84
+ var USAGE = [
85
+ "Usage: node lib/chore-run.js --crew-home <dir> --task-id <id> --tick-seq <n> [options]",
86
+ "",
87
+ "Runs one Chore task through the seven worker-layer phases (Triage → Publish).",
88
+ "Stateless per invocation; stdout is exactly one JSON frame (NEED_SPAWN | WAIT | DONE).",
89
+ "",
90
+ "Required:",
91
+ " --crew-home <dir> crew home directory",
92
+ " --task-id <id> task to run",
93
+ " --tick-seq <n> owning tick sequence (positive integer; fills spawn bounds)",
94
+ "",
95
+ "Options:",
96
+ " --start-step <name> resume at this phase (Triage|Capture|Map|Build|Review|Integrate|Publish)",
97
+ " --project-id <id> expected project (mid-run project-change guard)",
98
+ " --visual-protocol visual protocol available (overrides project default)",
99
+ " --no-visual-protocol visual protocol unavailable (overrides project default)",
100
+ " --resolved-workflow <w> workflow name (defaults to the task's workflow)",
101
+ " --workflow-was-null the dispatcher did not specify a workflow",
102
+ " --help print this usage",
103
+ ].join("\n");
104
+
105
+ // --help is answered on stdout with exit 0 BEFORE required-argument parsing
106
+ // (lib/AGENTS.md shebang⇔CLI contract) and before the stderr-log patch.
107
+ var RAW_ARGS = process.argv.slice(2);
108
+ if (RAW_ARGS.indexOf("--help") !== -1 || RAW_ARGS.indexOf("-h") !== -1) {
109
+ process.stdout.write(USAGE + "\n");
110
+ process.exit(0);
111
+ }
112
+
113
+ // From here on, all logs go to stderr: stdout carries exactly one JSON
114
+ // frame. phase-lib's log() uses console.log, so it is rerouted here.
115
+ console.log = function () {
116
+ process.stderr.write(Array.prototype.map.call(arguments, String).join(" ") + "\n");
117
+ };
118
+ console.info = console.log;
119
+
120
+ function log(message) {
121
+ process.stderr.write(String(message) + "\n");
122
+ }
123
+
124
+ function parseArgs(argv) {
125
+ var o = {
126
+ crewHome: null, taskId: null, tickSeq: null, startStep: null,
127
+ projectId: null, visualProtocol: null, resolvedWorkflow: null,
128
+ workflowWasNull: false, preRefusal: null,
129
+ };
130
+ for (var i = 0; i < argv.length; i++) {
131
+ var a = argv[i];
132
+ if (a === "--crew-home") o.crewHome = argv[++i];
133
+ else if (a === "--task-id") o.taskId = argv[++i];
134
+ else if (a === "--tick-seq") o.tickSeq = argv[++i];
135
+ else if (a === "--start-step") o.startStep = argv[++i];
136
+ else if (a === "--project-id") o.projectId = argv[++i];
137
+ else if (a === "--visual-protocol") o.visualProtocol = true;
138
+ else if (a === "--no-visual-protocol") o.visualProtocol = false;
139
+ else if (a === "--resolved-workflow") o.resolvedWorkflow = argv[++i];
140
+ else if (a === "--workflow-was-null") o.workflowWasNull = true;
141
+ else if (a === "--pre-refusal") o.preRefusal = argv[++i];
142
+ else throw new Error("unknown argument: " + a);
143
+ }
144
+ if (!o.crewHome) throw new Error("--crew-home is required.");
145
+ if (!o.taskId) throw new Error("--task-id is required.");
146
+ if (o.tickSeq === null || o.tickSeq === undefined || o.tickSeq === "") throw new Error("--tick-seq is required.");
147
+ var n = Number(o.tickSeq);
148
+ if (!Number.isInteger(n) || n <= 0) throw new Error("--tick-seq must be a positive integer.");
149
+ o.tickSeq = n;
150
+ if (o.startStep !== null && PHASE_ORDER.indexOf(o.startStep) === -1) {
151
+ throw new Error("--start-step must be one of: " + PHASE_ORDER.join("|") + ".");
152
+ }
153
+ if (o.preRefusal !== null && TERMINAL_REFUSAL_CODES.indexOf(o.preRefusal) === -1) {
154
+ throw new Error("--pre-refusal must be one of: " + TERMINAL_REFUSAL_CODES.join("|") + ".");
155
+ }
156
+ return o;
157
+ }
158
+
159
+ // Bootstrap crew-api call: execFile argv only (no shell), against the
160
+ // release's own crew-api.js. Used only until the pins are verified; every
161
+ // decision-making call goes through the pinned copy.
162
+ function bootstrapApi(crewHome, command, args) {
163
+ return new Promise(function (resolve, reject) {
164
+ var argv = ["node", crewHome + "/current/lib/crew-api.js", "--crew-home", crewHome, command];
165
+ if (args !== undefined) argv.push("--json", JSON.stringify(args));
166
+ execFile(argv[0], argv.slice(1), { timeout: 30000 }, function (err, stdout, stderr) {
167
+ if (err) { reject(new Error("crew-api " + command + " failed: " + String(stderr || err.message).slice(0, 500))); return; }
168
+ try { resolve(JSON.parse(String(stdout || ""))); }
169
+ catch (e) { reject(new Error("crew-api " + command + " returned unparseable JSON")); }
170
+ });
171
+ });
172
+ }
173
+
174
+ // Pinned crew-api call (post-pin): same argv shape, against RUN_LIB.
175
+ function pinnedApi(env, command, args) {
176
+ return new Promise(function (resolve, reject) {
177
+ var argv = ["node", env.crewApiPinned, "--crew-home", env.crewHome, command];
178
+ if (args !== undefined) argv.push("--json", JSON.stringify(args));
179
+ execFile(argv[0], argv.slice(1), { timeout: 30000 }, function (err, stdout, stderr) {
180
+ if (err) { reject(new Error("crew-api " + command + " failed: " + String(stderr || err.message).slice(0, 500))); return; }
181
+ try { resolve(JSON.parse(String(stdout || ""))); }
182
+ catch (e) { reject(new Error("crew-api " + command + " returned unparseable JSON")); }
183
+ });
184
+ });
185
+ }
186
+
187
+ function errText(e) {
188
+ return (e && e.message) ? String(e.message) : String(e);
189
+ }
190
+
191
+ // ── Run telemetry (fire-and-forget: never crashes the invocation) ───────
192
+ // The worker layer's counterpart to the old record-run-start/end. One row
193
+ // per chore run (phase "chore", kind NULL); reused across invocations via
194
+ // get-worker-run so a tick's re-invocation doesn't open a row per pass.
195
+ async function openRunTelemetry(env) {
196
+ try {
197
+ var existing = await pinnedApi(env, "get-worker-run", { task_id: env.taskId, phase: "chore" });
198
+ if (existing && existing.row && existing.row.status === "running") return existing.row.id;
199
+ } catch (e) { /* no row yet — open one */ }
200
+ try {
201
+ var opened = await pinnedApi(env, "record-worker-run", { phase: "chore", task_id: env.taskId, status: "running" });
202
+ return (opened && opened.id) || null;
203
+ } catch (e) {
204
+ log("run telemetry open failed (non-fatal): " + errText(e));
205
+ return null;
206
+ }
207
+ }
208
+
209
+ async function closeRunTelemetry(env, runId, frame) {
210
+ if (runId === null || runId === undefined) return;
211
+ var status, error;
212
+ if (frame.outcome === "completed" || frame.outcome === "stood-down" || frame.outcome === "parked") {
213
+ status = "completed";
214
+ error = frame.outcome === "completed" ? null : frame.outcome + (frame.reason ? ": " + frame.reason : "");
215
+ } else {
216
+ status = "failed";
217
+ error = frame.reason || frame.outcome;
218
+ }
219
+ try {
220
+ await pinnedApi(env, "record-worker-run",
221
+ { id: runId, phase: "chore", status: status, error: error ? String(error).slice(0, 1000) : null });
222
+ } catch (e) {
223
+ log("run telemetry close failed (non-fatal): " + errText(e));
224
+ }
225
+ }
226
+
227
+ // ── Resume derivation ────────────────────────────────────────────────────
228
+ // All state comes from Crew API reads. A derivation failure throws — the
229
+ // caller fails closed (DONE/failed), never a partial advance.
230
+ function taskSessions(allSessions, taskId) {
231
+ var out = [];
232
+ for (var i = 0; i < allSessions.length; i++) {
233
+ if (allSessions[i] && allSessions[i].task_id === taskId) out.push(allSessions[i]);
234
+ }
235
+ out.sort(function (a, b) { return String(a.started_at || "") < String(b.started_at || "") ? -1 : 1; });
236
+ return out;
237
+ }
238
+
239
+ // Returns a phase name, or null when Publish already completed (closeout),
240
+ // or {done} for nothing-to-do. Throws on unresolvable state (fail closed).
241
+ function deriveStartPhase(task, sessions, args) {
242
+ if (task.next_phase) {
243
+ if (PHASE_ORDER.indexOf(task.next_phase) === -1) {
244
+ throw new Error("task next_phase \"" + task.next_phase + "\" is not a Chore phase — left set for human inspection.");
245
+ }
246
+ return task.next_phase;
247
+ }
248
+ if (args.startStep) return args.startStep;
249
+ if (sessions.length === 0) return "Triage";
250
+ var latest = sessions[sessions.length - 1];
251
+ var step = latest.step;
252
+ if (latest.status === "running") {
253
+ if (PHASE_ORDER.indexOf(step) === -1) throw new Error("running session has unknown step \"" + step + "\" — failing closed.");
254
+ return step;
255
+ }
256
+ if (latest.status === "completed") {
257
+ var idx = PHASE_ORDER.indexOf(step);
258
+ if (idx === -1) throw new Error("completed session has unknown step \"" + step + "\" — failing closed.");
259
+ if (idx + 1 >= PHASE_ORDER.length) return null; // Publish completed: closeout
260
+ return PHASE_ORDER[idx + 1];
261
+ }
262
+ if (latest.status === "rejected") return "Build"; // rework routing (dispatcher parity)
263
+ if (latest.status === "failed" || latest.status === "timed_out" || latest.status === "stalled") {
264
+ if (PHASE_ORDER.indexOf(step) === -1) throw new Error("failed session has unknown step \"" + step + "\" — failing closed.");
265
+ return step; // dispatcher retry resumes the failed phase
266
+ }
267
+ throw new Error("latest session has unresolvable status \"" + latest.status + "\" — failing closed.");
268
+ }
269
+
270
+ async function doCloseout(env) {
271
+ var taskId = env.taskId;
272
+ // Source parity: mark the task done, log the completion event, clean up
273
+ // the pins. A failed mark fails closed (the task is not done); a failed
274
+ // completion event after a successful mark is logged but not fatal.
275
+ try {
276
+ await pinnedApi(env, "update-task", { id: taskId, state: "done" });
277
+ } catch (e) {
278
+ throw new Error("closeout update-task failed: " + errText(e));
279
+ }
280
+ try {
281
+ await pinnedApi(env, "log-event",
282
+ { task_id: taskId, type: "completed", message: "All chore workflow steps complete." });
283
+ } catch (e) {
284
+ log("closeout log-event failed (non-fatal): " + errText(e));
285
+ }
286
+ log("Chore run complete for task " + taskId);
287
+ return { type: "DONE", outcome: "completed", task_id: taskId };
288
+ }
289
+
290
+ // cleanupPins — the run's last filesystem act on success: remove the
291
+ // per-task pin dir. Runs AFTER the telemetry close (the pinned crew-api.js
292
+ // lives in the pin dir). Best-effort, never fails the run.
293
+ async function cleanupPins(env) {
294
+ try {
295
+ rmSync(env.runLib, { recursive: true, force: true });
296
+ } catch (e) {
297
+ log("closeout pin cleanup failed (non-fatal): " + errText(e));
298
+ }
299
+ }
300
+
301
+ // Handle one phase result. Returns a frame (NEED_SPAWN | WAIT | DONE), or
302
+ // null to continue the loop at result.next (ADVANCE/REWIND).
303
+ async function handlePhaseResult(env, state, args, phaseName, result) {
304
+ var taskId = env.taskId;
305
+ switch (result.type) {
306
+ case "NEED_SPAWN": {
307
+ // Permanent pre refusal (2026-09-26): the spawn bridge refused this
308
+ // spawn before opening a ledger row, so re-emitting NEED_SPAWN would
309
+ // loop forever — no ledger state advances between derivations.
310
+ // The ferry reports the refusal via --pre-refusal; terminate here
311
+ // through the normal closeout (pins released, terminal task state
312
+ // written) instead of burning frame budget.
313
+ if (args.preRefusal && TERMINAL_REFUSAL_CODES.indexOf(args.preRefusal) !== -1) {
314
+ var refusalReason = "spawn pre permanently refused (" + args.preRefusal + ")";
315
+ if (args.preRefusal === "BOUNDS_REJECTED") {
316
+ // Operational misconfiguration (project bounds), not a task
317
+ // defect: park for a human to fix the project config and recover.
318
+ // parkTask writes the parked state and runs terminal cleanup;
319
+ // convert to the DONE frame here (the PARK case below assumes a
320
+ // phase already parked, which did not happen on this path).
321
+ var parked = await parkTask(env, refusalReason + ": spawn bounds invalid — fix the project configuration, then recover the task.");
322
+ if (parked.type === "PARK_FAILED") {
323
+ return { type: "DONE", outcome: "park-failed", task_id: taskId, phase: phaseName, reason: parked.reason };
324
+ }
325
+ return { type: "DONE", outcome: "parked", task_id: taskId, phase: phaseName, reason: parked.reason };
326
+ }
327
+ // Budget/attempt exhaustion is a task-level terminal failure: convert
328
+ // to the DONE frame directly (the FAILED case below assumes a phase
329
+ // result, which did not happen on this path).
330
+ return { type: "DONE", outcome: "failed", task_id: taskId, phase: phaseName,
331
+ reason: refusalReason + ": the task exhausted its spawn budget." };
332
+ }
333
+ var request = result.request || {};
334
+ request.bounds = request.bounds || {};
335
+ request.bounds.owner_tick_seq = args.tickSeq;
336
+ request.owner_tick_seq = args.tickSeq;
337
+ return { type: "NEED_SPAWN", request: request };
338
+ }
339
+ case "STANDBY": {
340
+ var wait = { type: "WAIT", reason: result.reason, phase: phaseName, task_id: taskId };
341
+ if (result.key) wait.key = result.key;
342
+ return wait;
343
+ }
344
+ case "STAND_DOWN":
345
+ return { type: "DONE", outcome: "stood-down", task_id: taskId, phase: phaseName, reason: result.reason };
346
+ case "PARK":
347
+ // parkTask already parked the task and ran terminal cleanup.
348
+ return { type: "DONE", outcome: "parked", task_id: taskId, phase: phaseName, reason: result.reason };
349
+ case "PARK_FAILED":
350
+ return { type: "DONE", outcome: "park-failed", task_id: taskId, phase: phaseName, reason: result.reason };
351
+ case "FAILED":
352
+ return { type: "DONE", outcome: "failed", task_id: taskId, phase: phaseName,
353
+ reason: result.reason, detail: result.detail };
354
+ case "ADVANCE":
355
+ case "REWIND": {
356
+ // The phase's typed result seeds the driver's experiential state
357
+ // (Triage ADVANCE carries it; null leaves any prior value).
358
+ if (result.experiential === "yes") state.experiential = true;
359
+ else if (result.experiential === "no") state.experiential = false;
360
+ if (result.next === null || result.next === undefined) return await doCloseout(env);
361
+ if (PHASE_ORDER.indexOf(result.next) === -1) {
362
+ throw new Error("phase " + phaseName + " returned unknown next phase \"" + result.next + "\" — failing closed.");
363
+ }
364
+ // Fresh per-phase claim for the next phase (source parity: one
365
+ // session per phase). Re-derive the rework count — a REWIND just
366
+ // wrote the rejected event this result was computed from.
367
+ state.activeSessionId = null;
368
+ state.reworkCount = await deriveReworkCount(env);
369
+ return null;
370
+ }
371
+ default:
372
+ throw new Error("phase " + phaseName + " returned unknown result type \"" + result.type + "\" — failing closed.");
373
+ }
374
+ }
375
+
376
+ async function runDriver(args) {
377
+ var crewHome = args.crewHome, taskId = args.taskId;
378
+
379
+ // Pin lifecycle first (pure fs — no crew-api needed). Persistent disk,
380
+ // NOT /tmp (tmpfs is wiped on cell reboot — canary b5efd1b1).
381
+ var runLib = crewHome + "/.pins/" + taskId;
382
+ var pinsOk = false;
383
+ try {
384
+ pinLifecycle({ crewHome: crewHome, runLib: runLib });
385
+ var listing = parsePinListing({ listing: readdirSync(runLib).join("\n") });
386
+ pinsOk = PIN_BASENAMES.every(function (b) { return listing.indexOf(b) !== -1; });
387
+ } catch (e) {
388
+ throw new Error("pin lifecycle failed: " + errText(e));
389
+ }
390
+
391
+ // Bootstrap reads (task + project) through the release's own crew-api.
392
+ var st = await bootstrapApi(crewHome, "get-state", { events_limit: 1 });
393
+ var task = null;
394
+ var tasks = (st && st.tasks) || [];
395
+ for (var i = 0; i < tasks.length; i++) if (tasks[i].id === taskId) { task = tasks[i]; break; }
396
+ if (!task) throw new Error("task " + taskId + " not found — failing closed.");
397
+ var project = await bootstrapApi(crewHome, "get-project", { id: task.project });
398
+ project = (project && project.project) || null;
399
+ if (!project || !project.id) throw new Error("project " + task.project + " not found — failing closed.");
400
+ var repoPath = project.repo_path || "";
401
+ if (!repoPath) throw new Error("Project '" + project.id + "' has no repo_path configured — failing closed.");
402
+
403
+ var visualProtocol = args.visualProtocol !== null ? args.visualProtocol : (project.visual_protocol === true);
404
+ var resolvedWorkflow = args.resolvedWorkflow || task.workflow || null;
405
+ var workflowWasNull = args.workflowWasNull || (!args.resolvedWorkflow && !task.workflow);
406
+
407
+ var env = deriveRunEnv({
408
+ crewHome: crewHome, taskId: taskId,
409
+ repoPath: repoPath, orchPath: crewHome + "/.orchestration", runLib: runLib,
410
+ lifecycle: runLib + "/worktree-lifecycle.sh",
411
+ mergeLock: runLib + "/merge-lock.sh",
412
+ publishNpm: runLib + "/publish-npm.sh",
413
+ computeDiff: runLib + "/compute-publish-diff.js",
414
+ crewApi: crewHome + "/current/lib/crew-api.js",
415
+ crewApiPinned: runLib + "/crew-api.js",
416
+ taskTitle: task.title, taskDescription: task.description,
417
+ projectId: task.project, projectConfig: project,
418
+ visualProtocol: visualProtocol,
419
+ workflowWasNull: workflowWasNull, resolvedWorkflow: resolvedWorkflow,
420
+ });
421
+
422
+ // The pins gate the run: an incomplete pin set parks the task for human
423
+ // attention (source parity), exactly like the source's pin-verification.
424
+ if (!pinsOk) {
425
+ var parked = await parkTask(env, "Pin verification failed — required lifecycle scripts missing from " + runLib);
426
+ if (parked.type === "PARK") return { type: "DONE", outcome: "parked", task_id: taskId, reason: parked.reason };
427
+ return { type: "DONE", outcome: "park-failed", task_id: taskId, reason: parked.reason };
428
+ }
429
+
430
+ // Nothing to do for terminal tasks.
431
+ if (task.state === "done" || task.state === "cancelled" || task.state === "parked") {
432
+ return { type: "DONE", outcome: "stood-down", task_id: taskId, reason: "task already " + task.state };
433
+ }
434
+ // The driver only runs Chore tasks.
435
+ if (resolvedWorkflow && resolvedWorkflow !== "chore") {
436
+ throw new Error("task workflow is \"" + resolvedWorkflow + "\", not chore — refusing to run the chore driver.");
437
+ }
438
+ // Optional launch-project guard (the tick worker passes the dispatch
439
+ // claim's project; the per-phase projectGuard re-checks it).
440
+ if (args.projectId && args.projectId !== task.project) {
441
+ throw new Error("task project changed from '" + args.projectId + "' to '" + task.project + "' — failing closed.");
442
+ }
443
+
444
+ var sessions = taskSessions((st && st.sessions) || [], taskId);
445
+ var running = null;
446
+ for (var s = 0; s < sessions.length; s++) {
447
+ if (sessions[s].status === "running") { running = sessions[s]; break; }
448
+ }
449
+ var state = {
450
+ activeSessionId: running ? running.id : null,
451
+ reworkCount: await deriveReworkCount(env),
452
+ isFirstClaimVisit: sessions.length === 0,
453
+ nextPhaseRouted: task.next_phase || null,
454
+ experiential: undefined,
455
+ memo: {},
456
+ };
457
+
458
+ var runId = await openRunTelemetry(env);
459
+
460
+ var frame;
461
+ try {
462
+ frame = await driveLoop(env, state, args, task, sessions);
463
+ } catch (e) {
464
+ var msg = errText(e);
465
+ // A mid-run terminal transition (done/cancelled under us) is a
466
+ // stand-down, not a failure.
467
+ var terminalRace = /already done|cancelled/i.test(msg);
468
+ frame = {
469
+ type: "DONE",
470
+ outcome: terminalRace ? "stood-down" : "failed",
471
+ task_id: taskId,
472
+ reason: (terminalRace ? "stood down: " : "driver error: ") + msg,
473
+ };
474
+ }
475
+ if (frame.type === "DONE") await closeRunTelemetry(env, runId, frame);
476
+ // Pin cleanup lands after the telemetry close: the pinned crew-api.js
477
+ // the close runs through lives inside the pin dir.
478
+ if (frame.type === "DONE" && frame.outcome === "completed") await cleanupPins(env);
479
+ return frame;
480
+ }
481
+
482
+ async function driveLoop(env, state, args, task, sessions) {
483
+ var taskId = env.taskId;
484
+ var start = deriveStartPhase(task, sessions, args);
485
+ if (start === null) return await doCloseout(env);
486
+
487
+ var current = start;
488
+ for (var iter = 0; iter < MAX_PHASE_ITERATIONS; iter++) {
489
+ var phase = PHASES[current];
490
+ var guard = await projectGuard(env, state, phase.def);
491
+ if (!guard.ok) {
492
+ return { type: "DONE", outcome: "failed", task_id: taskId, phase: current, reason: guard.reason };
493
+ }
494
+ var result = await phase.run({ env: env, state: state });
495
+ var frame = await handlePhaseResult(env, state, args, current, result);
496
+ if (frame) return frame;
497
+ current = result.next;
498
+ }
499
+ throw new Error("runaway guard: exceeded " + MAX_PHASE_ITERATIONS + " phase iterations — failing closed.");
500
+ }
501
+
502
+ async function main() {
503
+ var args;
504
+ try {
505
+ args = parseArgs(RAW_ARGS);
506
+ } catch (e) {
507
+ process.stderr.write(USAGE + "\n\nError: " + errText(e) + "\n");
508
+ process.exit(2);
509
+ }
510
+ var frame;
511
+ try {
512
+ frame = await runDriver(args);
513
+ } catch (e) {
514
+ // Even a catastrophic setup failure emits the one JSON frame.
515
+ frame = { type: "DONE", outcome: "failed", task_id: args.taskId || null, reason: "driver error: " + errText(e) };
516
+ }
517
+ process.stdout.write(JSON.stringify(frame) + "\n");
518
+ }
519
+
520
+ // Import-safe: no side effects on import. main() runs only on direct
521
+ // execution; bare `node lib/chore-run.js` reports the usage error (exit 2),
522
+ // matching the other lib CLIs.
523
+ var isDirectExecution = false;
524
+ try {
525
+ isDirectExecution = !!process.argv[1] &&
526
+ import.meta.url === pathToFileURL(process.argv[1]).href;
527
+ } catch (e) { /* conservative: do not run */ }
528
+ if (isDirectExecution) {
529
+ main().catch(function (e) {
530
+ try {
531
+ process.stdout.write(JSON.stringify({
532
+ type: "DONE", outcome: "failed", task_id: null,
533
+ reason: "driver error: " + errText(e),
534
+ }) + "\n");
535
+ } catch (e2) { /* stdout itself failed */ }
536
+ process.exit(1);
537
+ });
538
+ }