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,605 @@
1
+ #!/usr/bin/env node
2
+ // lib/workflow-driver.js — the generic workflow driver (composition
3
+ // machinery, Phase B, 2026-09-26): a mechanical extraction of the driver
4
+ // core from lib/chore-run.js, parameterized by a workflow spec instead of
5
+ // hardcoded phases.
6
+ //
7
+ // The tick worker invokes this once per tick for a dispatched task:
8
+ // node lib/workflow-driver.js --spec <path> --crew-home H --task-id T --tick-seq N [options]
9
+ //
10
+ // Per-boundary stateless invocation. Stdout carries EXACTLY ONE JSON line:
11
+ // {"type":"NEED_SPAWN","request":{...}} — post the request via the spawn
12
+ // bridge (spawn-step.js pre), then re-invoke when it lands.
13
+ // {"type":"WAIT","reason":..,"key":..,"phase":..} — a spawn is in flight;
14
+ // re-invoke when the spawn row goes terminal.
15
+ // {"type":"DONE","outcome":..,"task_id":..} — outcome is one of
16
+ // completed | failed | parked | park-failed | stood-down.
17
+ //
18
+ // The spec (lib/workflow-spec.js) declares composition: ordered phases,
19
+ // named transitions, guard references. Phase modules are loaded by dynamic
20
+ // import from the spec's module refs and must export PHASE + runPhase.
21
+ // The driver never imports phase modules by name — module refs come from
22
+ // the spec. (Runtime helpers still come from chore's phase-lib;
23
+ // generalizing that library is Phase C/D work.)
24
+ //
25
+ // PROTOCOL NOTE (from chore-run.js, 2026-09-26): the frozen design named
26
+ // only NEED_SPAWN and DONE. WAIT is a deliberate third frame, not a silent
27
+ // expansion. When a spawn is already posted and running, re-emitting
28
+ // NEED_SPAWN would instruct the launcher to post a duplicate
29
+ // (record-spawn-open refuses with duplicate_running, forcing the launcher
30
+ // to interpret the refusal as "wait" — decision logic leaking out of the
31
+ // driver). Emitting DONE would lie about the run being over. WAIT keeps the
32
+ // single-decision-point contract: the driver decides, the launcher only
33
+ // posts and re-invokes. Flagged for cutover review; the design doc is
34
+ // amended only on approval. All logs go to stderr. All run state is derived
35
+ // from Crew API reads (tasks, sessions, events, spawn-ledger rows) — there
36
+ // is no run-state file anywhere. A derivation failure is a transport
37
+ // failure: the driver fails closed with DONE/failed, never a partial
38
+ // advance.
39
+ //
40
+ // Concurrency discipline (same as the old workflow): one invocation per
41
+ // task at a time. The tick worker must not invoke this for a task with a
42
+ // running session unless the previous invocation is known dead. A lost
43
+ // claim race stands down quietly via the claim-as-gate.
44
+ //
45
+ // No wall-clock reads and no randomness in decision code (G3). Timestamps
46
+ // are minted SQLite-side by crew-api.js.
47
+
48
+ // Spawn-pre refusal codes that are PERMANENT (2026-09-26): the refusal fires
49
+ // before any spawn-ledger row is opened, so re-deriving would emit an
50
+ // identical NEED_SPAWN forever — no ledger state advances. The ferry
51
+ // (chore-tick.js) reports these back via --pre-refusal and the driver
52
+ // terminates instead of re-emitting NEED_SPAWN. DUPLICATE_RUNNING is the
53
+ // legitimate re-drive case (a spawn is in flight; the next derivation
54
+ // resolves to WAIT). This list must match TERMINAL_REFUSAL_CODES in
55
+ // lib/chore-tick.js.
56
+ const TERMINAL_REFUSAL_CODES = [
57
+ "BOUNDS_REJECTED", // spawn bounds invalid (project misconfiguration) → PARK
58
+ "ATTEMPTS_EXHAUSTED", // spawn attempts exhausted → FAILED
59
+ "TRANSPORT_BUDGET_EXHAUSTED", // consecutive transport failures exhausted → FAILED
60
+ ];
61
+
62
+ import { rmSync, readdirSync } from "node:fs";
63
+ import { execFile } from "node:child_process";
64
+ import { pathToFileURL } from "node:url";
65
+ import { dirname, resolve } from "node:path";
66
+
67
+ import { loadSpec, phaseModuleUrl } from "./workflow-spec.js";
68
+ // Runtime helpers (Phase B generalized, Phase D): the run environment, pin
69
+ // lifecycle, guards, and parking live in the workflow-neutral library.
70
+ // Workflow-specific runtime values (project description fallback, prompt
71
+ // template directory) arrive declaratively through the spec — the driver
72
+ // stays free of workflow-shaped conditionals.
73
+ import {
74
+ deriveRunEnv, pinLifecycle, parsePinListing, PIN_BASENAMES,
75
+ deriveReworkCount, projectGuard, parkTask,
76
+ } from "./workflow-lib.js";
77
+
78
+ // Runaway guard: REWIND is self-resolving (Map→Capture writes the evidence
79
+ // the gate needs; Review→Build is capped at 2 by the phase itself), so 20
80
+ // phase iterations per invocation is far beyond any legitimate run.
81
+ var MAX_PHASE_ITERATIONS = 20;
82
+
83
+ var USAGE = [
84
+ "Usage: node lib/workflow-driver.js --spec <path> --crew-home <dir> --task-id <id> --tick-seq <n> [options]",
85
+ "",
86
+ "Runs one task through the phases declared by a workflow spec JSON.",
87
+ "Stateless per invocation; stdout is exactly one JSON frame (NEED_SPAWN | WAIT | DONE).",
88
+ "",
89
+ "Required:",
90
+ " --spec <path> workflow spec JSON (see lib/workflow-spec.js)",
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 (must name a spec phase)",
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; must match the spec)",
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
+ spec: null, 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 === "--spec") o.spec = argv[++i];
133
+ else if (a === "--crew-home") o.crewHome = argv[++i];
134
+ else if (a === "--task-id") o.taskId = argv[++i];
135
+ else if (a === "--tick-seq") o.tickSeq = argv[++i];
136
+ else if (a === "--start-step") o.startStep = argv[++i];
137
+ else if (a === "--project-id") o.projectId = argv[++i];
138
+ else if (a === "--visual-protocol") o.visualProtocol = true;
139
+ else if (a === "--no-visual-protocol") o.visualProtocol = false;
140
+ else if (a === "--resolved-workflow") o.resolvedWorkflow = argv[++i];
141
+ else if (a === "--workflow-was-null") o.workflowWasNull = true;
142
+ else if (a === "--pre-refusal") o.preRefusal = argv[++i];
143
+ else throw new Error("unknown argument: " + a);
144
+ }
145
+ if (!o.spec) throw new Error("--spec is required.");
146
+ if (!o.crewHome) throw new Error("--crew-home is required.");
147
+ if (!o.taskId) throw new Error("--task-id is required.");
148
+ if (o.tickSeq === null || o.tickSeq === undefined || o.tickSeq === "") throw new Error("--tick-seq is required.");
149
+ var n = Number(o.tickSeq);
150
+ if (!Number.isInteger(n) || n <= 0) throw new Error("--tick-seq must be a positive integer.");
151
+ o.tickSeq = n;
152
+ // --start-step values come from the spec: validated after spec load.
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
+ // Load the spec's phase modules by dynamic import. Each module must export
160
+ // PHASE (the phase definition, incl. identity for the project guard) and
161
+ // runPhase (the phase runner). Returns {name: {def, run}}.
162
+ async function loadPhases(spec, specPath) {
163
+ var phases = {};
164
+ for (var i = 0; i < spec.phases.length; i++) {
165
+ var entry = spec.phases[i];
166
+ var mod;
167
+ try {
168
+ mod = await import(phaseModuleUrl(specPath, entry.module));
169
+ } catch (e) {
170
+ throw new Error("phase \"" + entry.name + "\": cannot load module " + entry.module + ": " + errText(e));
171
+ }
172
+ if (!mod.PHASE || typeof mod.PHASE !== "object" || Array.isArray(mod.PHASE) || typeof mod.runPhase !== "function") {
173
+ throw new Error("phase \"" + entry.name + "\": module must export PHASE (object) and runPhase (function).");
174
+ }
175
+ phases[entry.name] = { def: mod.PHASE, run: mod.runPhase };
176
+ }
177
+ return phases;
178
+ }
179
+
180
+ // Bootstrap crew-api call: execFile argv only (no shell), against the
181
+ // release's own crew-api.js. Used only until the pins are verified; every
182
+ // decision-making call goes through the pinned copy.
183
+ function bootstrapApi(crewHome, command, args) {
184
+ return new Promise(function (resolve, reject) {
185
+ var argv = ["node", crewHome + "/current/lib/crew-api.js", "--crew-home", crewHome, command];
186
+ if (args !== undefined) argv.push("--json", JSON.stringify(args));
187
+ execFile(argv[0], argv.slice(1), { timeout: 30000 }, function (err, stdout, stderr) {
188
+ if (err) { reject(new Error("crew-api " + command + " failed: " + String(stderr || err.message).slice(0, 500))); return; }
189
+ try { resolve(JSON.parse(String(stdout || ""))); }
190
+ catch (e) { reject(new Error("crew-api " + command + " returned unparseable JSON")); }
191
+ });
192
+ });
193
+ }
194
+
195
+ // Pinned crew-api call (post-pin): same argv shape, against RUN_LIB.
196
+ function pinnedApi(env, command, args) {
197
+ return new Promise(function (resolve, reject) {
198
+ var argv = ["node", env.crewApiPinned, "--crew-home", env.crewHome, command];
199
+ if (args !== undefined) argv.push("--json", JSON.stringify(args));
200
+ execFile(argv[0], argv.slice(1), { timeout: 30000 }, function (err, stdout, stderr) {
201
+ if (err) { reject(new Error("crew-api " + command + " failed: " + String(stderr || err.message).slice(0, 500))); return; }
202
+ try { resolve(JSON.parse(String(stdout || ""))); }
203
+ catch (e) { reject(new Error("crew-api " + command + " returned unparseable JSON")); }
204
+ });
205
+ });
206
+ }
207
+
208
+ function errText(e) {
209
+ return (e && e.message) ? String(e.message) : String(e);
210
+ }
211
+
212
+ // displayName — the spec's workflow name with an initial capital, for
213
+ // human-facing messages (matches chore-run.js's "Chore" capitalization).
214
+ function displayName(spec) {
215
+ var w = spec.workflow || "";
216
+ return w.charAt(0).toUpperCase() + w.slice(1);
217
+ }
218
+
219
+ // ── Run telemetry (fire-and-forget: never crashes the invocation) ───────
220
+ // The worker layer's counterpart to the old record-run-start/end. One row
221
+ // per run (phase = spec workflow name, kind NULL); reused across
222
+ // invocations via get-worker-run so a tick's re-invocation doesn't open a
223
+ // row per pass.
224
+ async function openRunTelemetry(env, spec) {
225
+ try {
226
+ var existing = await pinnedApi(env, "get-worker-run", { task_id: env.taskId, phase: spec.workflow });
227
+ if (existing && existing.row && existing.row.status === "running") return existing.row.id;
228
+ } catch (e) { /* no row yet — open one */ }
229
+ try {
230
+ var opened = await pinnedApi(env, "record-worker-run", { phase: spec.workflow, task_id: env.taskId, status: "running" });
231
+ return (opened && opened.id) || null;
232
+ } catch (e) {
233
+ log("run telemetry open failed (non-fatal): " + errText(e));
234
+ return null;
235
+ }
236
+ }
237
+
238
+ async function closeRunTelemetry(env, spec, runId, frame) {
239
+ if (runId === null || runId === undefined) return;
240
+ var status, error;
241
+ if (frame.outcome === "completed" || frame.outcome === "stood-down" || frame.outcome === "parked") {
242
+ status = "completed";
243
+ error = frame.outcome === "completed" ? null : frame.outcome + (frame.reason ? ": " + frame.reason : "");
244
+ } else {
245
+ status = "failed";
246
+ error = frame.reason || frame.outcome;
247
+ }
248
+ try {
249
+ await pinnedApi(env, "record-worker-run",
250
+ { id: runId, phase: spec.workflow, status: status, error: error ? String(error).slice(0, 1000) : null });
251
+ } catch (e) {
252
+ log("run telemetry close failed (non-fatal): " + errText(e));
253
+ }
254
+ }
255
+
256
+ // ── Resume derivation ────────────────────────────────────────────────────
257
+ // All state comes from Crew API reads. A derivation failure throws — the
258
+ // caller fails closed (DONE/failed), never a partial advance.
259
+ function taskSessions(allSessions, taskId) {
260
+ var out = [];
261
+ for (var i = 0; i < allSessions.length; i++) {
262
+ if (allSessions[i] && allSessions[i].task_id === taskId) out.push(allSessions[i]);
263
+ }
264
+ out.sort(function (a, b) { return String(a.started_at || "") < String(b.started_at || "") ? -1 : 1; });
265
+ return out;
266
+ }
267
+
268
+ // Returns a phase name, or null when the last spec phase already completed
269
+ // (closeout), or {done} for nothing-to-do. Throws on unresolvable state
270
+ // (fail closed).
271
+ function deriveStartPhase(task, sessions, args, spec) {
272
+ var order = spec.phaseNames;
273
+ if (task.next_phase) {
274
+ if (order.indexOf(task.next_phase) === -1) {
275
+ throw new Error("task next_phase \"" + task.next_phase + "\" is not a " + displayName(spec) + " phase — left set for human inspection.");
276
+ }
277
+ return task.next_phase;
278
+ }
279
+ if (args.startStep) return args.startStep;
280
+ if (sessions.length === 0) return order[0];
281
+ var latest = sessions[sessions.length - 1];
282
+ var step = latest.step;
283
+ if (latest.status === "running") {
284
+ if (order.indexOf(step) === -1) throw new Error("running session has unknown step \"" + step + "\" — failing closed.");
285
+ return step;
286
+ }
287
+ if (latest.status === "completed") {
288
+ var idx = order.indexOf(step);
289
+ if (idx === -1) throw new Error("completed session has unknown step \"" + step + "\" — failing closed.");
290
+ if (idx + 1 >= order.length) return null; // last phase completed: closeout
291
+ return order[idx + 1];
292
+ }
293
+ if (latest.status === "rejected") {
294
+ // Rework routing (dispatcher parity in the chore port): a rejected
295
+ // session resumes at the spec's declared rework entry point. The
296
+ // rework target is workflow knowledge, so it lives in the spec —
297
+ // undeclared is unresolvable, and unresolvable fails closed.
298
+ if (!spec.rejectedResume) {
299
+ throw new Error("latest session is rejected but the spec declares no rejectedResume — failing closed.");
300
+ }
301
+ return spec.rejectedResume;
302
+ }
303
+ if (latest.status === "failed" || latest.status === "timed_out" || latest.status === "stalled") {
304
+ if (order.indexOf(step) === -1) throw new Error("failed session has unknown step \"" + step + "\" — failing closed.");
305
+ return step; // dispatcher retry resumes the failed phase
306
+ }
307
+ throw new Error("latest session has unresolvable status \"" + latest.status + "\" — failing closed.");
308
+ }
309
+
310
+ async function doCloseout(env, spec) {
311
+ var taskId = env.taskId;
312
+ // Source parity: mark the task done, log the completion event, clean up
313
+ // the pins. A failed mark fails closed (the task is not done); a failed
314
+ // completion event after a successful mark is logged but not fatal.
315
+ try {
316
+ await pinnedApi(env, "update-task", { id: taskId, state: "done" });
317
+ } catch (e) {
318
+ throw new Error("closeout update-task failed: " + errText(e));
319
+ }
320
+ try {
321
+ await pinnedApi(env, "log-event",
322
+ { task_id: taskId, type: "completed", message: "All " + spec.workflow + " workflow steps complete." });
323
+ } catch (e) {
324
+ log("closeout log-event failed (non-fatal): " + errText(e));
325
+ }
326
+ log(displayName(spec) + " run complete for task " + taskId);
327
+ return { type: "DONE", outcome: "completed", task_id: taskId };
328
+ }
329
+
330
+ // cleanupPins — the run's last filesystem act on success: remove the
331
+ // per-task pin dir. Runs AFTER the telemetry close (the pinned crew-api.js
332
+ // lives in the pin dir). Best-effort, never fails the run.
333
+ async function cleanupPins(env) {
334
+ try {
335
+ rmSync(env.runLib, { recursive: true, force: true });
336
+ } catch (e) {
337
+ log("closeout pin cleanup failed (non-fatal): " + errText(e));
338
+ }
339
+ }
340
+
341
+ // Handle one phase result. Returns a frame (NEED_SPAWN | WAIT | DONE), or
342
+ // null to continue the loop at result.next (ADVANCE/REWIND).
343
+ async function handlePhaseResult(env, state, args, spec, phaseName, result) {
344
+ var taskId = env.taskId;
345
+ switch (result.type) {
346
+ case "NEED_SPAWN": {
347
+ // Permanent pre refusal (2026-09-26): the spawn bridge refused this
348
+ // spawn before opening a ledger row, so re-emitting NEED_SPAWN would
349
+ // loop forever — no ledger state advances between derivations.
350
+ // The ferry reports the refusal via --pre-refusal; terminate here
351
+ // through the normal closeout (pins released, terminal task state
352
+ // written) instead of burning frame budget.
353
+ if (args.preRefusal && TERMINAL_REFUSAL_CODES.indexOf(args.preRefusal) !== -1) {
354
+ var refusalReason = "spawn pre permanently refused (" + args.preRefusal + ")";
355
+ if (args.preRefusal === "BOUNDS_REJECTED") {
356
+ // Operational misconfiguration (project bounds), not a task
357
+ // defect: park for a human to fix the project config and recover.
358
+ // parkTask writes the parked state and runs terminal cleanup;
359
+ // convert to the DONE frame here (the PARK case below assumes a
360
+ // phase already parked, which did not happen on this path).
361
+ var parked = await parkTask(env, refusalReason + ": spawn bounds invalid — fix the project configuration, then recover the task.");
362
+ if (parked.type === "PARK_FAILED") {
363
+ return { type: "DONE", outcome: "park-failed", task_id: taskId, phase: phaseName, reason: parked.reason };
364
+ }
365
+ return { type: "DONE", outcome: "parked", task_id: taskId, phase: phaseName, reason: parked.reason };
366
+ }
367
+ // Budget/attempt exhaustion is a task-level terminal failure: convert
368
+ // to the DONE frame directly (the FAILED case below assumes a phase
369
+ // result, which did not happen on this path).
370
+ return { type: "DONE", outcome: "failed", task_id: taskId, phase: phaseName,
371
+ reason: refusalReason + ": the task exhausted its spawn budget." };
372
+ }
373
+ var request = result.request || {};
374
+ request.bounds = request.bounds || {};
375
+ request.bounds.owner_tick_seq = args.tickSeq;
376
+ request.owner_tick_seq = args.tickSeq;
377
+ return { type: "NEED_SPAWN", request: request };
378
+ }
379
+ case "STANDBY": {
380
+ var wait = { type: "WAIT", reason: result.reason, phase: phaseName, task_id: taskId };
381
+ if (result.key) wait.key = result.key;
382
+ return wait;
383
+ }
384
+ case "STAND_DOWN":
385
+ return { type: "DONE", outcome: "stood-down", task_id: taskId, phase: phaseName, reason: result.reason };
386
+ case "PARK":
387
+ // parkTask already parked the task and ran terminal cleanup.
388
+ return { type: "DONE", outcome: "parked", task_id: taskId, phase: phaseName, reason: result.reason };
389
+ case "PARK_FAILED":
390
+ return { type: "DONE", outcome: "park-failed", task_id: taskId, phase: phaseName, reason: result.reason };
391
+ case "FAILED":
392
+ return { type: "DONE", outcome: "failed", task_id: taskId, phase: phaseName,
393
+ reason: result.reason, detail: result.detail };
394
+ case "ADVANCE":
395
+ case "REWIND": {
396
+ // The phase's typed result seeds the driver's experiential state
397
+ // (Triage ADVANCE carries the boolean from extractExperiential;
398
+ // null leaves any prior value).
399
+ if (result.experiential === true) state.experiential = true;
400
+ else if (result.experiential === false) state.experiential = false;
401
+ // Map-gate bounce counter (Standard): a REWIND to Capture needs fresh
402
+ // spawn keys on the re-visit. Only Capture reads this, and Capture is
403
+ // never re-visited via rework REWINDs, so incrementing on every REWIND
404
+ // is safe.
405
+ if (result.type === "REWIND") state.gateBounceCount = (state.gateBounceCount || 0) + 1;
406
+ if (result.next === null || result.next === undefined) return await doCloseout(env, spec);
407
+ if (spec.phaseNames.indexOf(result.next) === -1) {
408
+ throw new Error("phase " + phaseName + " returned unknown next phase \"" + result.next + "\" — failing closed.");
409
+ }
410
+ // Fresh per-phase claim for the next phase (source parity: one
411
+ // session per phase). Re-derive the rework count — a REWIND just
412
+ // wrote the rejected event this result was computed from.
413
+ state.activeSessionId = null;
414
+ state.reworkCount = await deriveReworkCount(env, spec);
415
+ return null;
416
+ }
417
+ default:
418
+ throw new Error("phase " + phaseName + " returned unknown result type \"" + result.type + "\" — failing closed.");
419
+ }
420
+ }
421
+
422
+ async function runDriver(args, spec) {
423
+ var crewHome = args.crewHome, taskId = args.taskId;
424
+
425
+ // Pin lifecycle first (pure fs — no crew-api needed). Persistent disk,
426
+ // NOT /tmp (tmpfs is wiped on cell reboot — canary b5efd1b1).
427
+ var runLib = crewHome + "/.pins/" + taskId;
428
+ var pinsOk = false;
429
+ try {
430
+ pinLifecycle({ crewHome: crewHome, runLib: runLib });
431
+ var listing = parsePinListing({ listing: readdirSync(runLib).join("\n") });
432
+ pinsOk = PIN_BASENAMES.every(function (b) { return listing.indexOf(b) !== -1; });
433
+ } catch (e) {
434
+ throw new Error("pin lifecycle failed: " + errText(e));
435
+ }
436
+
437
+ // Bootstrap reads (task + project) through the release's own crew-api.
438
+ var st = await bootstrapApi(crewHome, "get-state", { events_limit: 1 });
439
+ var task = null;
440
+ var tasks = (st && st.tasks) || [];
441
+ for (var i = 0; i < tasks.length; i++) if (tasks[i].id === taskId) { task = tasks[i]; break; }
442
+ if (!task) throw new Error("task " + taskId + " not found — failing closed.");
443
+ var project = await bootstrapApi(crewHome, "get-project", { id: task.project });
444
+ project = (project && project.project) || null;
445
+ if (!project || !project.id) throw new Error("project " + task.project + " not found — failing closed.");
446
+ var repoPath = project.repo_path || "";
447
+ if (!repoPath) throw new Error("Project '" + project.id + "' has no repo_path configured — failing closed.");
448
+
449
+ var visualProtocol = args.visualProtocol !== null ? args.visualProtocol : (project.visual_protocol === true);
450
+ var resolvedWorkflow = args.resolvedWorkflow || task.workflow || null;
451
+ var workflowWasNull = args.workflowWasNull || (!args.resolvedWorkflow && !task.workflow);
452
+
453
+ // Workflow-declared runtime values (spec-driven, no workflow conditionals):
454
+ // the project description fallback and the prompt-template directory,
455
+ // resolved against the spec file's directory (phaseModuleUrl's rule).
456
+ var specPromptsDir = spec.promptsDir
457
+ ? resolve(dirname(resolve(args.spec)), spec.promptsDir)
458
+ : undefined;
459
+ var env = deriveRunEnv({
460
+ crewHome: crewHome, taskId: taskId,
461
+ repoPath: repoPath, orchPath: crewHome + "/.orchestration", runLib: runLib,
462
+ lifecycle: runLib + "/worktree-lifecycle.sh",
463
+ mergeLock: runLib + "/merge-lock.sh",
464
+ publishNpm: runLib + "/publish-npm.sh",
465
+ computeDiff: runLib + "/compute-publish-diff.js",
466
+ crewApi: crewHome + "/current/lib/crew-api.js",
467
+ crewApiPinned: runLib + "/crew-api.js",
468
+ taskTitle: task.title, taskDescription: task.description,
469
+ projectId: task.project, projectConfig: project,
470
+ visualProtocol: visualProtocol,
471
+ workflowWasNull: workflowWasNull, resolvedWorkflow: resolvedWorkflow,
472
+ projectDescFallback: spec.projectDescFallback || undefined,
473
+ promptsDir: specPromptsDir,
474
+ });
475
+
476
+ // The pins gate the run: an incomplete pin set parks the task for human
477
+ // attention (source parity), exactly like the source's pin-verification.
478
+ if (!pinsOk) {
479
+ var parked = await parkTask(env, "Pin verification failed — required lifecycle scripts missing from " + runLib);
480
+ if (parked.type === "PARK") return { type: "DONE", outcome: "parked", task_id: taskId, reason: parked.reason };
481
+ return { type: "DONE", outcome: "park-failed", task_id: taskId, reason: parked.reason };
482
+ }
483
+
484
+ // Nothing to do for terminal tasks.
485
+ if (task.state === "done" || task.state === "cancelled" || task.state === "parked") {
486
+ return { type: "DONE", outcome: "stood-down", task_id: taskId, reason: "task already " + task.state };
487
+ }
488
+ // The driver only runs tasks for the spec's workflow.
489
+ if (resolvedWorkflow && resolvedWorkflow !== spec.workflow) {
490
+ throw new Error("task workflow is \"" + resolvedWorkflow + "\" but the spec declares \"" + spec.workflow + "\" — refusing.");
491
+ }
492
+ // Optional launch-project guard (the tick worker passes the dispatch
493
+ // claim's project; the per-phase projectGuard re-checks it).
494
+ if (args.projectId && args.projectId !== task.project) {
495
+ throw new Error("task project changed from '" + args.projectId + "' to '" + task.project + "' — failing closed.");
496
+ }
497
+
498
+ var phases = await loadPhases(spec, args.spec);
499
+
500
+ var sessions = taskSessions((st && st.sessions) || [], taskId);
501
+ var running = null;
502
+ for (var s = 0; s < sessions.length; s++) {
503
+ if (sessions[s].status === "running") { running = sessions[s]; break; }
504
+ }
505
+ var state = {
506
+ activeSessionId: running ? running.id : null,
507
+ reworkCount: await deriveReworkCount(env, spec),
508
+ gateBounceCount: 0,
509
+ isFirstClaimVisit: sessions.length === 0,
510
+ nextPhaseRouted: task.next_phase || null,
511
+ experiential: undefined,
512
+ memo: {},
513
+ };
514
+
515
+ var runId = await openRunTelemetry(env, spec);
516
+
517
+ var frame;
518
+ try {
519
+ frame = await driveLoop(env, state, args, spec, phases, task, sessions);
520
+ } catch (e) {
521
+ var msg = errText(e);
522
+ // A mid-run terminal transition (done/cancelled under us) is a
523
+ // stand-down, not a failure.
524
+ var terminalRace = /already done|cancelled/i.test(msg);
525
+ frame = {
526
+ type: "DONE",
527
+ outcome: terminalRace ? "stood-down" : "failed",
528
+ task_id: taskId,
529
+ reason: (terminalRace ? "stood down: " : "driver error: ") + msg,
530
+ };
531
+ }
532
+ if (frame.type === "DONE") await closeRunTelemetry(env, spec, runId, frame);
533
+ // Pin cleanup lands after the telemetry close: the pinned crew-api.js
534
+ // the close runs through lives inside the pin dir.
535
+ if (frame.type === "DONE" && frame.outcome === "completed") await cleanupPins(env);
536
+ return frame;
537
+ }
538
+
539
+ async function driveLoop(env, state, args, spec, phases, task, sessions) {
540
+ var taskId = env.taskId;
541
+ var start = deriveStartPhase(task, sessions, args, spec);
542
+ if (start === null) return await doCloseout(env, spec);
543
+
544
+ var current = start;
545
+ for (var iter = 0; iter < MAX_PHASE_ITERATIONS; iter++) {
546
+ var phase = phases[current];
547
+ var guard = await projectGuard(env, state, phase.def);
548
+ if (!guard.ok) {
549
+ return { type: "DONE", outcome: "failed", task_id: taskId, phase: current, reason: guard.reason };
550
+ }
551
+ var result = await phase.run({ env: env, state: state });
552
+ var frame = await handlePhaseResult(env, state, args, spec, current, result);
553
+ if (frame) return frame;
554
+ current = result.next;
555
+ }
556
+ throw new Error("runaway guard: exceeded " + MAX_PHASE_ITERATIONS + " phase iterations — failing closed.");
557
+ }
558
+
559
+ async function main() {
560
+ var args;
561
+ try {
562
+ args = parseArgs(RAW_ARGS);
563
+ } catch (e) {
564
+ process.stderr.write(USAGE + "\n\nError: " + errText(e) + "\n");
565
+ process.exit(2);
566
+ }
567
+ var spec;
568
+ try {
569
+ spec = loadSpec(args.spec);
570
+ if (args.startStep !== null && spec.phaseNames.indexOf(args.startStep) === -1) {
571
+ throw new Error("--start-step must be one of: " + spec.phaseNames.join("|") + ".");
572
+ }
573
+ } catch (e) {
574
+ process.stderr.write(USAGE + "\n\nError: " + errText(e) + "\n");
575
+ process.exit(2);
576
+ }
577
+ var frame;
578
+ try {
579
+ frame = await runDriver(args, spec);
580
+ } catch (e) {
581
+ // Even a catastrophic setup failure emits the one JSON frame.
582
+ frame = { type: "DONE", outcome: "failed", task_id: args.taskId || null, reason: "driver error: " + errText(e) };
583
+ }
584
+ process.stdout.write(JSON.stringify(frame) + "\n");
585
+ }
586
+
587
+ // Import-safe: no side effects on import. main() runs only on direct
588
+ // execution; bare `node lib/workflow-driver.js` reports the usage error
589
+ // (exit 2), matching the other lib CLIs.
590
+ var isDirectExecution = false;
591
+ try {
592
+ isDirectExecution = !!process.argv[1] &&
593
+ import.meta.url === pathToFileURL(process.argv[1]).href;
594
+ } catch (e) { /* conservative: do not run */ }
595
+ if (isDirectExecution) {
596
+ main().catch(function (e) {
597
+ try {
598
+ process.stdout.write(JSON.stringify({
599
+ type: "DONE", outcome: "failed", task_id: null,
600
+ reason: "driver error: " + errText(e),
601
+ }) + "\n");
602
+ } catch (e2) { /* stdout itself failed */ }
603
+ process.exit(1);
604
+ });
605
+ }