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,885 @@
1
+ // lib/chore/phase-lib.js — shared deterministic operations for the
2
+ // worker-layer chore phase modules (sandbox exit, Piece 2).
3
+ //
4
+ // Import-safe: no side effects on import, bare `node` exits 0. All I/O goes
5
+ // through execFile with argv only (never shell, never bare exec) or through
6
+ // the pinned crew-api CLI. No creative-boundary calls anywhere in this file — the only
7
+ // crossings are NEED_SPAWN results returned to the driver.
8
+ //
9
+ // The ctx contract (see lib/chore/CONTRACT.md):
10
+ // ctx.env — static run config (paths, task facts, project config)
11
+ // ctx.state — derived per-pass scalars (phase, session, counters, handoffs)
12
+
13
+ import { execFile } from "node:child_process";
14
+ import {
15
+ readFileSync, writeFileSync, appendFileSync, mkdirSync,
16
+ copyFileSync, chmodSync, existsSync, statSync, readlinkSync, readdirSync,
17
+ } from "node:fs";
18
+ import { join, dirname, basename } from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+ import {
21
+ extractVerdict, extractMarkerLines, parseToolSignals,
22
+ extractReleaseDecision, extractWorktree, extractExperiential,
23
+ } from "./extract.js";
24
+ import { buildTransportRetryTrailer } from "./prompts/trailers.js";
25
+ import { workKeyBase, workRetryKey, verdictReaskKey, attemptKey } from "./spawn-keys.js";
26
+
27
+ const LIB_DIR = dirname(fileURLToPath(import.meta.url));
28
+ export const PROMPTS_DIR = join(LIB_DIR, "prompts");
29
+
30
+ // Workflow log line. Phase modules prefix with their step name, mirroring
31
+ // the source's log() call sites.
32
+ export function log(message) {
33
+ console.log(message);
34
+ }
35
+
36
+ // ── argv-only command runner ────────────────────────────────────────────
37
+ // execFile, never shell. Rejects with an Error carrying .stdout/.stderr/.code.
38
+ export function runCmd(argv, opts) {
39
+ var o = opts || {};
40
+ return new Promise(function (resolve, reject) {
41
+ execFile(argv[0], argv.slice(1), {
42
+ cwd: o.cwd,
43
+ env: o.env,
44
+ encoding: "utf8",
45
+ maxBuffer: 16 * 1024 * 1024,
46
+ timeout: o.timeout_ms || 300000,
47
+ }, function (err, stdout, stderr) {
48
+ if (err) {
49
+ var e = new Error(
50
+ "command failed: " + argv[0] + " " + argv.slice(1).join(" ") +
51
+ " (code " + err.code + (err.killed ? ", killed" : "") + "): " +
52
+ String(stderr || stdout || err.message).slice(0, 500)
53
+ );
54
+ e.stdout = String(stdout || "");
55
+ e.stderr = String(stderr || "");
56
+ e.code = err.code;
57
+ reject(e);
58
+ return;
59
+ }
60
+ resolve({ stdout: String(stdout || ""), stderr: String(stderr || "") });
61
+ });
62
+ });
63
+ }
64
+
65
+ // ── Pinned crew-api caller ──────────────────────────────────────────────
66
+ // Mirrors spawn-step.js callCrewApi: node <pinned-api> --crew-home <home>
67
+ // <command> --json '<args>'. Throws with the CLI's stderr on failure.
68
+ export async function crewApi(env, command, args) {
69
+ var argv = ["node", env.crewApiPinned, "--crew-home", env.crewHome, command];
70
+ if (args !== undefined) argv.push("--json", JSON.stringify(args));
71
+ var out;
72
+ try {
73
+ out = await runCmd(argv);
74
+ } catch (e) {
75
+ throw new Error("crew-api " + command + " failed: " + (e.stderr || e.message).slice(0, 500));
76
+ }
77
+ try {
78
+ return JSON.parse(out.stdout);
79
+ } catch (e) {
80
+ throw new Error("crew-api " + command + " returned unparseable JSON");
81
+ }
82
+ }
83
+
84
+ // ── Pinned script runners ───────────────────────────────────────────────
85
+ // The lifecycle and merge-lock scripts take CREW_HOME/CREW_REPO in the
86
+ // environment (LIFECYCLE_ENV in the source). argv only, never shell.
87
+ function scriptEnv(env) {
88
+ return Object.assign({}, process.env, {
89
+ CREW_HOME: env.crewHome,
90
+ CREW_REPO: env.repoPath,
91
+ });
92
+ }
93
+
94
+ export function lifecycle(env, subcommand, args) {
95
+ return runCmd([env.lifecycle, subcommand].concat(args || []), { env: scriptEnv(env) });
96
+ }
97
+
98
+ export function mergeLock(env, subcommand, args) {
99
+ return runCmd([env.mergeLock, subcommand].concat(args || []), { env: scriptEnv(env) });
100
+ }
101
+
102
+ // ── Crew API conveniences ───────────────────────────────────────────────
103
+
104
+ export async function logEvent(env, entry) {
105
+ return crewApi(env, "log-event", entry);
106
+ }
107
+
108
+ export async function recordPhase(env, payload) {
109
+ return crewApi(env, "record-phase", payload);
110
+ }
111
+
112
+ export async function getEvents(env, taskId, limit) {
113
+ var args = { task_id: taskId };
114
+ if (limit !== undefined) args.limit = limit;
115
+ var res = await crewApi(env, "get-events", args);
116
+ return (res && res.events) || [];
117
+ }
118
+
119
+ export async function getSpawnRow(env, key) {
120
+ try {
121
+ var res = await crewApi(env, "get-spawn-row", { spawn_key: key });
122
+ return (res && res.row) || null;
123
+ } catch (e) {
124
+ if (/not found/i.test(e.message)) return null;
125
+ throw e;
126
+ }
127
+ }
128
+
129
+ // ── Pin lifecycle scripts (pure Node — no shell, no agent) ─────────────
130
+ // Verbatim port of the pinLifecycle courier's shell: mkdir -p RUN_LIB,
131
+ // copy the 11 pinned scripts, chmod +x the three executables. Returns the
132
+ // basenames present (parsePinListing of an ls -1 equivalent).
133
+ const PIN_FILES = [
134
+ ["lib/worktree-lifecycle.sh", "worktree-lifecycle.sh", true],
135
+ ["lib/merge-lock.sh", "merge-lock.sh", true],
136
+ ["lib/publish-npm.sh", "publish-npm.sh", true],
137
+ ["current/lib/crew-api.js", "crew-api.js", false],
138
+ ["lib/schema.sql", "schema.sql", false],
139
+ ["current/lib/compute-publish-diff.js", "compute-publish-diff.js", false],
140
+ ["current/lib/classify-surface.js", "classify-surface.js", false],
141
+ ["current/lib/publish-note-vocabulary.js", "publish-note-vocabulary.js", false],
142
+ ["current/lib/qa-deploy.mjs", "qa-deploy.mjs", false],
143
+ ["current/lib/serve-artifact.js", "serve-artifact.js", false],
144
+ ["current/lib/qa-db.js", "qa-db.js", false],
145
+ ];
146
+
147
+ export const PIN_BASENAMES = PIN_FILES.map(function (f) { return f[1]; });
148
+
149
+ export function pinLifecycle(env) {
150
+ mkdirSync(env.runLib, { recursive: true });
151
+ var present = [];
152
+ for (var i = 0; i < PIN_FILES.length; i++) {
153
+ var src = join(env.crewHome, PIN_FILES[i][0]);
154
+ var dst = join(env.runLib, PIN_FILES[i][1]);
155
+ copyFileSync(src, dst);
156
+ if (PIN_FILES[i][2]) chmodSync(dst, 0o755);
157
+ present.push(PIN_FILES[i][1]);
158
+ }
159
+ return present;
160
+ }
161
+
162
+ // parsePinListing — basenames from a pin/verify listing. Verbatim from
163
+ // workflows/chore.js (byte-identical across standard/bugfix/chore).
164
+ export function parsePinListing(result) {
165
+ return (result && result.listing ? result.listing : "").split("\n").map(function (s) { return s.trim(); }).filter(function (s) { return s.length > 0; });
166
+ }
167
+
168
+ // ── Spawn-boundary plumbing ─────────────────────────────────────────────
169
+ // Bounds for a creative-spawn pre request. owner_tick_seq is filled by the
170
+ // driver (the only writer that knows the tick).
171
+ export function buildSpawnBounds(env, kind) {
172
+ return {
173
+ prompt_file: join(PROMPTS_DIR, kind === "work-agent" ? "work-agent.js" : kind + ".js"),
174
+ schema_file: join(PROMPTS_DIR, kind + ".schema.json"),
175
+ cwd: env.repoPath,
176
+ env: {},
177
+ workdir: env.repoPath,
178
+ crewHome: env.crewHome,
179
+ };
180
+ }
181
+
182
+ // NEED_SPAWN result. The driver fills owner_tick_seq and posts the pre
183
+ // request to the spawn bridge; the phase resumes on the next pass.
184
+ export function needSpawn(request) {
185
+ return { type: "NEED_SPAWN", request: request };
186
+ }
187
+
188
+ // workRequest — a work-agent pre request for the boundary. Every prompt
189
+ // param is declared (P1); the trailer is a pure function of the prior
190
+ // transport failure.
191
+ export function workRequest(env, state, spec) {
192
+ var trailer = spec.trailer || "";
193
+ return {
194
+ key: spec.key,
195
+ kind: "work-agent",
196
+ task_id: env.taskId,
197
+ phase: spec.phase,
198
+ identity: spec.identity,
199
+ timeout_ms: 3600000,
200
+ bounds: buildSpawnBounds(env, "work-agent"),
201
+ prompt_params: {
202
+ identity: spec.identity,
203
+ orch_path: env.orchPath,
204
+ task_title: env.taskTitle,
205
+ task_description: env.taskDescription,
206
+ task_id: env.taskId,
207
+ step_name: spec.phase,
208
+ instructions: spec.instructions,
209
+ crew_api: spec.crewApiLine === false ? "" : env.crewApiPinned,
210
+ event_preamble: spec.eventPreamble,
211
+ transport_retry_trailer: trailer,
212
+ },
213
+ };
214
+ }
215
+
216
+ // reaskRequest — a verdict-reask pre request. Always reads from the work
217
+ // report's own content (never copies a VERDICT line).
218
+ export function reaskRequest(env, state, spec) {
219
+ return {
220
+ key: spec.key,
221
+ kind: "verdict-reask",
222
+ task_id: env.taskId,
223
+ phase: spec.phase,
224
+ identity: "verdict-reask",
225
+ timeout_ms: 180000,
226
+ bounds: buildSpawnBounds(env, "verdict-reask"),
227
+ prompt_params: {
228
+ step_name: spec.phase,
229
+ worker_text: spec.workerText,
230
+ },
231
+ };
232
+ }
233
+
234
+ // ── Spawn session text ──────────────────────────────────────────────────
235
+ // Reads the pinned session file the bridge wrote for a completed spawn.
236
+ // Returns null on any read/parse failure (treated as unusable output).
237
+ export function readSessionText(env, key) {
238
+ try {
239
+ var raw = readFileSync(join(env.crewHome, ".spawn-sessions", key + ".json"), "utf8");
240
+ var obj = JSON.parse(raw);
241
+ return typeof obj.text === "string" ? obj.text : null;
242
+ } catch (e) {
243
+ return null;
244
+ }
245
+ }
246
+
247
+ // ── Boundary internals ──────────────────────────────────────────────────
248
+ // Terminal rows are the bridge's closed vocabulary (lib/crew-api.js
249
+ // CLOSED_OUTCOMES): completed, synthetic, transport_timeout, transport_error,
250
+ // transport_empty, transport_parse, killed_confirmed, killed_by_reaper,
251
+ // orphaned_unkillable, orphaned_unverified, transport_failed. A row whose
252
+ // status is still "running" is not terminal — the boundary stands by.
253
+ function isTerminalRow(row) {
254
+ return !!row && row.status !== "running";
255
+ }
256
+
257
+ // classifyRow — maps a terminal spawn-ledger row to the source work loop's
258
+ // attempt lanes. Returns one of:
259
+ // {lane: "report", text} completed with usable session text
260
+ // {lane: "no-tools"} report says artifact_tools: missing
261
+ // {lane: "no-transport"} report says shell_transport: unavailable
262
+ // {lane: "empty"} no usable text (blank/transport_empty)
263
+ // {lane: "threw", error} transport_error / kills / other failures
264
+ function classifyRow(env, key, row) {
265
+ // The outcome lives in the result JSON's closed vocabulary (there is no
266
+ // outcome column — record-spawn-close stores it inside result).
267
+ var outcome = null;
268
+ try {
269
+ var res = row.result ? JSON.parse(row.result) : null;
270
+ outcome = (res && typeof res.outcome === "string") ? res.outcome : null;
271
+ } catch (e) { /* keep null */ }
272
+ if (outcome === "completed" || outcome === "synthetic") {
273
+ var text = readSessionText(env, key);
274
+ if (text === null || text.trim().length === 0) return { lane: "empty" };
275
+ var signals = parseToolSignals(text);
276
+ if (signals.artifactTools === "missing") return { lane: "no-tools", text: text };
277
+ if (signals.shellTransport === "unavailable") return { lane: "no-transport", text: text };
278
+ return { lane: "report", text: text };
279
+ }
280
+ if (outcome === "transport_empty") return { lane: "empty" };
281
+ var errDetail = "";
282
+ try {
283
+ var res2 = row.result ? JSON.parse(row.result) : null;
284
+ errDetail = (res2 && res2.error) || "";
285
+ } catch (e) { /* keep empty */ }
286
+ if (outcome === "transport_error") {
287
+ return { lane: "threw", error: String(errDetail).replace(/"/g, "'").slice(0, 160) };
288
+ }
289
+ // Kills, timeouts, orphans, and unknown terminal outcomes are transport
290
+ // failures the source loop would have seen as a thrown worker call.
291
+ return { lane: "threw", error: String(outcome + (errDetail ? ": " + errDetail : "")).replace(/"/g, "'").slice(0, 160) };
292
+ }
293
+
294
+ // describeWorkAgentFailure — honest classification of a work-agent call that
295
+ // yielded no usable report, with the per-attempt evidence preserved in the
296
+ // session notes. Verbatim from workflows/chore.js (returns
297
+ // {notes, eventMessage, blockedReason, message}).
298
+ export function describeWorkAgentFailure(stepName, identity, attempts) {
299
+ var parts = [];
300
+ for (var i = 0; i < attempts.length; i++) {
301
+ var a = attempts[i];
302
+ parts.push("attempt " + (i + 1) + "/" + attempts.length + ": " +
303
+ (a.threw ? "threw '" + a.error + "'" : "returned " + a.outcome));
304
+ }
305
+ var detail = parts.join("; ").replace(/"/g, "'").slice(0, 400);
306
+ var anyThrow = false;
307
+ for (var j = 0; j < attempts.length; j++) {
308
+ if (attempts[j].threw) { anyThrow = true; break; }
309
+ }
310
+ if (anyThrow) {
311
+ return {
312
+ notes: "Work agent produced no machine-readable report after " + attempts.length + " attempts; runtime discarded the output (" + detail + ")",
313
+ eventMessage: stepName + " work agent produced no machine-readable report after " + attempts.length + " attempts — phase failed, dispatcher will retry",
314
+ blockedReason: stepName + " work agent produced no machine-readable report after " + attempts.length + " attempts",
315
+ message: "The " + identity + " agent's output could not be machine-read (" + attempts.length + " attempts exhausted); the runtime discarded the raw output before the workflow could see it. Surviving evidence: " + detail
316
+ };
317
+ }
318
+ return {
319
+ notes: "Work agent returned no usable output after " + attempts.length + " attempts (" + detail + ")",
320
+ eventMessage: stepName + " work agent returned no usable output after " + attempts.length + " attempts — phase failed, dispatcher will retry",
321
+ blockedReason: stepName + " work agent returned no usable output",
322
+ message: "The " + identity + " agent returned no usable output after " + attempts.length + " attempts (" + detail + ")."
323
+ };
324
+ }
325
+
326
+ // buildFailureAttempts — source-shaped workAttempts entries from the terminal
327
+ // ledger rows, for describeWorkAgentFailure.
328
+ export async function buildFailureAttempts(env, state, phase) {
329
+ var suffix = state.reworkCount > 0 ? "-r" + state.reworkCount : "";
330
+ var keys = [
331
+ workKeyBase(env.taskId, phase, state.reworkCount),
332
+ workRetryKey(env.taskId, phase, suffix, 1),
333
+ workRetryKey(env.taskId, phase, suffix, 2),
334
+ ];
335
+ var attempts = [];
336
+ for (var i = 0; i < keys.length; i++) {
337
+ var row = await getSpawnRow(env, keys[i]);
338
+ if (!row || !isTerminalRow(row)) continue;
339
+ var c = classifyRow(env, keys[i], row);
340
+ if (c.lane === "report") continue;
341
+ if (c.lane === "threw") {
342
+ attempts.push({ threw: true, error: c.error, outcome: "" });
343
+ } else if (c.lane === "empty") {
344
+ attempts.push({ threw: false, error: "", outcome: "blank string" });
345
+ } else if (c.lane === "no-tools") {
346
+ attempts.push({ threw: false, error: "", outcome: "missing-artifact-tools" });
347
+ } else if (c.lane === "no-transport") {
348
+ attempts.push({ threw: false, error: "", outcome: "unavailable-shell-transport" });
349
+ }
350
+ }
351
+ if (attempts.length === 0) {
352
+ attempts.push({ threw: false, error: "", outcome: "no record" });
353
+ }
354
+ return attempts;
355
+ }
356
+
357
+ // ── The shared creative boundary ────────────────────────────────────────
358
+ // Verbatim port of the source phase work loop (work-agent attempts,
359
+ // transport-retry trailers, deterministic verdict extraction, bounded
360
+ // verdict re-ask). It consumes terminal spawn-ledger rows instead of
361
+ // in-process worker calls; it never spawns itself. The driver acts on
362
+ // NEED_SPAWN/STANDBY and re-invokes the phase on the next pass.
363
+ // spec: {phase, identity, instructions, eventPreamble, crewApiLine}
364
+ // Returns:
365
+ // {type: "NEED_SPAWN", request} — post this pre via the spawn bridge
366
+ // {type: "STANDBY", reason} — a spawn is still running; wait
367
+ // {type: "BOUNDARY_DONE", workerText, verdictPassed}
368
+ // {type: "FAILED", reason, detail} — failure recorded; the run stops
369
+ export async function runWorkBoundary(env, state, spec) {
370
+ var phase = spec.phase, identity = spec.identity;
371
+ var suffix = state.reworkCount > 0 ? "-r" + state.reworkCount : "";
372
+ var workKeys = [
373
+ workKeyBase(env.taskId, phase, state.reworkCount),
374
+ workRetryKey(env.taskId, phase, suffix, 1),
375
+ workRetryKey(env.taskId, phase, suffix, 2),
376
+ ];
377
+ var attempts = [];
378
+ var workerText = null;
379
+ for (var i = 0; i < workKeys.length; i++) {
380
+ var row = await getSpawnRow(env, workKeys[i]);
381
+ if (!row) {
382
+ // The driver has not posted this attempt yet. Attempt 0 carries no
383
+ // trailer; retries carry the trailer derived from the previous
384
+ // attempt's failure mode (source: retryReason).
385
+ var trailer = "";
386
+ if (i > 0) {
387
+ var prev = attempts[i - 1];
388
+ var retryReason = prev.threw ? "discarded"
389
+ : prev.outcome === "missing-artifact-tools" ? "no-tools"
390
+ : prev.outcome === "unavailable-shell-transport" ? "no-transport"
391
+ : "empty";
392
+ trailer = buildTransportRetryTrailer(phase, env.repoPath, env.taskId, i, retryReason);
393
+ log(phase + " work agent transport retry " + i + " of 2 — requesting spawn " + workKeys[i]);
394
+ }
395
+ return needSpawn(workRequest(env, state, {
396
+ key: workKeys[i], phase: phase, identity: identity,
397
+ instructions: spec.instructions, eventPreamble: spec.eventPreamble,
398
+ crewApiLine: spec.crewApiLine, trailer: trailer,
399
+ }));
400
+ }
401
+ if (!isTerminalRow(row)) {
402
+ return { type: "STANDBY", reason: phase + " work attempt " + (i + 1) + " of 3 still running (" + workKeys[i] + ")", key: workKeys[i] };
403
+ }
404
+ var c = classifyRow(env, workKeys[i], row);
405
+ if (c.lane === "report") {
406
+ if (i > 0) log(phase + " work agent transport retry " + i + " returned a machine-readable report");
407
+ workerText = c.text;
408
+ break;
409
+ }
410
+ if (c.lane === "threw") {
411
+ attempts.push({ threw: true, error: c.error, outcome: "" });
412
+ log(phase + " work agent attempt " + (i + 1) + " of 3 threw: " + c.error);
413
+ } else if (c.lane === "empty") {
414
+ attempts.push({ threw: false, error: "", outcome: "blank string" });
415
+ log(phase + " work agent attempt " + (i + 1) + " of 3 returned no usable output (blank string) — retrying with a fresh key");
416
+ } else if (c.lane === "no-tools") {
417
+ attempts.push({ threw: false, error: "", outcome: "missing-artifact-tools" });
418
+ log(phase + " work agent attempt " + (i + 1) + " of 3 reported artifact_tools: missing — retrying with a fresh launch");
419
+ } else {
420
+ attempts.push({ threw: false, error: "", outcome: "unavailable-shell-transport" });
421
+ log(phase + " work agent attempt " + (i + 1) + " of 3 reported shell_transport: unavailable — retrying with a fresh launch");
422
+ }
423
+ }
424
+
425
+ if (typeof workerText !== "string" || !workerText.trim()) {
426
+ // All three attempts exhausted with no usable report — the phase fails
427
+ // for dispatcher retry (session "failed", NOT "blocked": blocked
428
+ // sessions are never picked up again).
429
+ var failure = describeWorkAgentFailure(phase, identity, attempts);
430
+ var notes = failure.notes + " If the step's work is actually complete, the task can be re-launched from " + phase + ".";
431
+ log(phase + " " + notes + " — marking failed for retry");
432
+ await recordPhase(env, {
433
+ task_id: env.taskId,
434
+ session: { id: state.activeSessionId, task_id: env.taskId, identity: identity, step: phase, status: "failed", notes: notes },
435
+ event: { task_id: env.taskId, type: "failed", message: failure.eventMessage },
436
+ });
437
+ return { type: "FAILED", reason: failure.blockedReason, detail: failure.message };
438
+ }
439
+
440
+ // Verdict derivation (deterministic): for VERDICT_STEPS (Build, Review,
441
+ // Integrate, Publish) the workflow owns the verdict — extracted by regex
442
+ // from the report text, never by an agent. A missing/malformed verdict
443
+ // gets a bounded mechanical re-ask before the phase fails closed. For
444
+ // non-verdict steps (Triage, Capture, Map) the worker producing output
445
+ // means the step passed.
446
+ var verdictPassed = null;
447
+ if (spec.verdictStep) {
448
+ var verdict = extractVerdict(workerText);
449
+ if (!verdict.ok) {
450
+ log(phase + " verdict line missing or ambiguous (" + verdict.count + " trailing-window matches) — attempting bounded re-ask");
451
+ var reasked = await runVerdictReask(env, state, phase, workerText);
452
+ if (reasked.type === "NEED_SPAWN" || reasked.type === "STANDBY") return reasked;
453
+ if (reasked.ok) {
454
+ verdict = reasked.verdict;
455
+ } else {
456
+ log(phase + " verdict re-ask exhausted — marking failed for retry");
457
+ await recordPhase(env, {
458
+ task_id: env.taskId,
459
+ session: { id: state.activeSessionId, task_id: env.taskId, identity: identity, step: phase, status: "failed", notes: "Worker report had no single unambiguous VERDICT: PASS/FAIL line (bounded re-ask exhausted)" },
460
+ event: { task_id: env.taskId, type: "failed", message: phase + " verdict line missing or ambiguous, re-ask exhausted — phase failed, dispatcher will retry" },
461
+ // Room #26 blocker 33: INDETERMINATE verdict record — the report
462
+ // could not be read at all, but its full text is still preserved
463
+ // as grounds. Review-scoped; other verdict steps keep the
464
+ // existing failed-session behavior with no verdict row.
465
+ verdict: (phase === "Review" ? {
466
+ step: "Review",
467
+ attempt: state.reworkCount,
468
+ reviewer: identity,
469
+ verdict: "INDETERMINATE",
470
+ grounds: workerText,
471
+ } : null),
472
+ });
473
+ return {
474
+ type: "FAILED",
475
+ reason: phase + " worker report had no single unambiguous VERDICT: PASS/FAIL line (bounded re-ask exhausted)",
476
+ detail: "The " + identity + " agent's work may be valid — its report did not declare a verdict the workflow could read, and two bounded re-ask attempts could not transcribe one. The report is preserved in the workflow log.",
477
+ };
478
+ }
479
+ }
480
+ verdictPassed = verdict.passed;
481
+ }
482
+ return { type: "BOUNDARY_DONE", workerText: workerText, verdictPassed: verdictPassed };
483
+ }
484
+
485
+ // runVerdictReask — the bounded mechanical verdict re-ask (source:
486
+ // reaskVerdict). The re-ask judges the report's own content; it never
487
+ // copies a VERDICT line. Consumes the two verdict-reask keys' terminal
488
+ // rows. Returns {type:"NEED_SPAWN"|"STANDBY"} for the driver, or
489
+ // {ok: true, verdict} / {ok: false} once the budget is resolved.
490
+ export async function runVerdictReask(env, state, phase, workerText) {
491
+ var suffix = state.reworkCount > 0 ? "-r" + state.reworkCount : "";
492
+ for (var attempt = 1; attempt <= 2; attempt++) {
493
+ var key = verdictReaskKey(env.taskId, phase, suffix, attempt);
494
+ var row = await getSpawnRow(env, key);
495
+ if (!row) {
496
+ return needSpawn(reaskRequest(env, state, { key: key, phase: phase, workerText: workerText }));
497
+ }
498
+ if (!isTerminalRow(row)) {
499
+ return { type: "STANDBY", reason: phase + " verdict re-ask attempt " + attempt + " of 2 still running (" + key + ")", key: key };
500
+ }
501
+ var text = readSessionText(env, key);
502
+ var verdict = extractVerdict(text || "");
503
+ if (verdict.ok) {
504
+ log(phase + " verdict re-ask attempt " + attempt + " recovered verdict: " + (verdict.passed ? "PASS" : "FAIL"));
505
+ return { ok: true, verdict: verdict };
506
+ }
507
+ log(phase + " verdict re-ask attempt " + attempt + " produced no readable verdict (" + verdict.count + " trailing-window matches)");
508
+ }
509
+ return { ok: false };
510
+ }
511
+
512
+ // ── Closeout assembly ───────────────────────────────────────────────────
513
+ // summarizeReport — the step-result summary: the full report text (capped
514
+ // at 2000 chars minus marker space) plus the machine-readable marker lines
515
+ // re-attached. Verbatim from the source's stepResult.summary construction.
516
+ // opts: {markerLines} for the Publish npm target-version block (the
517
+ // TARGET_VERSION line plus the skipped:/published: line); the source's
518
+ // exact 2000 - markerLines - workerMarkers - 2 slice is reproduced.
519
+ export function summarizeReport(workerText, opts) {
520
+ var workerMarkers = extractMarkerLines(workerText);
521
+ var text = workerText || "Step completed";
522
+ if (opts && opts.markerLines) {
523
+ var markerLines = opts.markerLines;
524
+ return text.slice(0, 2000 - markerLines.length - workerMarkers.length - 2) + "\n" + markerLines + (workerMarkers ? "\n" + workerMarkers : "");
525
+ }
526
+ return text.slice(0, 2000 - workerMarkers.length - 1) + (workerMarkers ? "\n" + workerMarkers : "");
527
+ }
528
+
529
+ // buildEventPreamble — the CONTEXT preamble the agent runs to fetch its own
530
+ // event history. Review is cold by design and Publish receives its release
531
+ // decision deterministically, so both get "".
532
+ export function buildEventPreamble(env, phase) {
533
+ if (phase === "Review" || phase === "Publish") return "";
534
+ return "CONTEXT: First, fetch this task's event history for background.\n" +
535
+ "Run in shell and return the stdout verbatim:\n" + crewCmdString(env, "get-events", { task_id: env.taskId }) + "\n" +
536
+ "The returned events are filtered to this task. They contain notes and decisions from prior phases.\n\n";
537
+ }
538
+
539
+ // ── Claim protocol (driver-level) ───────────────────────────────────────
540
+ // claimFirst — the run's first claim: task → in_progress (+ workflow when
541
+ // the dispatcher launched it null), claim, then clear the reservation ONLY
542
+ // when the claim succeeded. Verbatim from the source's CLAIM block.
543
+ export async function claimFirst(env, state, step) {
544
+ var taskId = env.taskId;
545
+ var updateArgs = { id: taskId, state: "in_progress" };
546
+ if (env.workflowWasNull && env.resolvedWorkflow) updateArgs.workflow = env.resolvedWorkflow;
547
+ await crewApi(env, "update-task", updateArgs);
548
+ var claimArgs = {
549
+ task_id: taskId, identity: step.identity, step: step.name,
550
+ notes: step.name + " step started",
551
+ };
552
+ if (state.nextPhaseRouted) claimArgs.expected_next_phase = state.nextPhaseRouted;
553
+ var claim = await crewApi(env, "claim-task", claimArgs);
554
+ if (!claim || !claim.claimed) return { claimed: false };
555
+ await crewApi(env, "clear-reservation", { task_id: taskId });
556
+ return { claimed: true, session_id: claim.session_id };
557
+ }
558
+
559
+ // claimStep — per-phase claim (no reservation clearing; the first claim
560
+ // consumed it). Verbatim from the source's phase-transition claim block.
561
+ export async function claimStep(env, state, step) {
562
+ var notes = step.name + " step started" + (state.reworkCount > 0 ? " (rework #" + state.reworkCount + ")" : "");
563
+ var claim = await crewApi(env, "claim-task", {
564
+ task_id: env.taskId, identity: step.identity, step: step.name, notes: notes,
565
+ });
566
+ // A lost claim race returns claimed:false (no session_id). That is the
567
+ // claim-as-gate: stand down, never proceed with an undefined session.
568
+ if (claim.claimed === false || !claim.session_id) {
569
+ return { stand_down: true, reason: "claim-task lost the race for " + step.name + " (claimed:false)" };
570
+ }
571
+ return { session_id: claim.session_id };
572
+ }
573
+
574
+ // ── Project guard (driver-level) ────────────────────────────────────────
575
+ // projectGuard — when the task's project changed mid-run, the run is stale:
576
+ // record the phase as failed and clean up, so the dispatcher re-launches
577
+ // from Build with the new project context. Verbatim from the source.
578
+ export async function projectGuard(env, state, step) {
579
+ if (!env.projectId) return { ok: true };
580
+ var st = await crewApi(env, "get-state", { events_limit: 1 });
581
+ var tasks = (st && st.tasks) || [];
582
+ var task = null;
583
+ for (var i = 0; i < tasks.length; i++) {
584
+ if (tasks[i].id === env.taskId) { task = tasks[i]; break; }
585
+ }
586
+ var currentProject = (task && task.project) ? task.project : env.projectId;
587
+ if (currentProject === env.projectId) return { ok: true };
588
+ var abortMessage = "Task project changed mid-run from '" + env.projectId + "' to '" + currentProject + "' — aborting stale run. The dispatcher will re-launch from " + "Build" + " with the new project context.";
589
+ await recordPhase(env, {
590
+ task_id: env.taskId,
591
+ session: { task_id: env.taskId, identity: step.identity, step: "Build", status: "failed", notes: abortMessage + " Rebuild from the Map session notes in the task's event history." },
592
+ event: { task_id: env.taskId, type: "failed", identity: step.identity, message: abortMessage },
593
+ });
594
+ // The source ran cleanup via the agent with a CLEANUP-output contract;
595
+ // the worker layer runs it directly (argv only) and lets a throw abort
596
+ // the run the same way the source's agent throw would.
597
+ await lifecycle(env, "cleanup", [env.taskId]);
598
+ return { ok: false, reason: abortMessage };
599
+ }
600
+
601
+ // ── Cross-cutting reads ─────────────────────────────────────────────────
602
+ // resolveExperiential — "yes" | "no" | "unknown". The driver's per-pass
603
+ // experiential seed (from the Triage typed result) wins; otherwise the
604
+ // Triage session notes are read once per pass and memoized (all three
605
+ // outcomes, so a missing marker never re-fires the lookup).
606
+ export async function resolveExperiential(env, state) {
607
+ if (state.memo.experientialResolved !== undefined) return state.memo.experientialResolved;
608
+ var result;
609
+ if (state.experiential === true) result = "yes";
610
+ else if (state.experiential === false) result = "no";
611
+ else {
612
+ try {
613
+ var st = await crewApi(env, "get-state", { events_limit: 1 });
614
+ var sessions = (st && st.sessions) || [];
615
+ var triage = null;
616
+ for (var i = 0; i < sessions.length; i++) {
617
+ var s = sessions[i];
618
+ if (s.task_id === env.taskId && s.step === "Triage" && s.status === "completed") {
619
+ if (!triage || String(s.started_at || "") > String(triage.started_at || "")) triage = s;
620
+ }
621
+ }
622
+ var marker = extractExperiential((triage && triage.notes) || "");
623
+ result = marker === null ? "unknown" : (marker ? "yes" : "no");
624
+ } catch (e) {
625
+ log("resolveExperiential: crew-api call failed (" + (e && e.message ? e.message : e) + ") — treating as unknown");
626
+ result = "unknown";
627
+ }
628
+ }
629
+ state.memo.experientialResolved = result;
630
+ return result;
631
+ }
632
+
633
+ // baselineStatus — reads the task's note events for the exact protocol
634
+ // prefixes (explicit state, never English matching). Returns
635
+ // {baseline_found, baseline_kind, baseline_refs, requested_count, evidence_count}.
636
+ export async function baselineStatus(env) {
637
+ var result = {
638
+ baseline_found: false, baseline_kind: null, baseline_refs: [],
639
+ requested_count: 0, evidence_count: 0,
640
+ };
641
+ try {
642
+ var events = await getEvents(env, env.taskId);
643
+ var latestBaseline = null;
644
+ for (var i = 0; i < events.length; i++) {
645
+ var msg = String((events[i] && events[i].message) || "");
646
+ if (msg.indexOf("baseline: captured") === 0 || msg.indexOf("baseline: none") === 0) {
647
+ result.evidence_count++;
648
+ if (!latestBaseline) latestBaseline = msg;
649
+ } else if (msg.indexOf("baseline: requested") === 0) {
650
+ result.requested_count++;
651
+ }
652
+ }
653
+ if (latestBaseline) {
654
+ result.baseline_found = true;
655
+ result.baseline_kind = latestBaseline.indexOf("baseline: captured") === 0 ? "captured" : "none";
656
+ var refMatch = /refs:\s*([^\n]+)/.exec(latestBaseline);
657
+ if (refMatch) {
658
+ result.baseline_refs = refMatch[1].split(",").map(function (s) { return s.trim(); }).filter(function (s) { return s.length > 0; });
659
+ }
660
+ }
661
+ } catch (e) {
662
+ log("baselineStatus: crew-api call failed (" + (e && e.message ? e.message : e) + ") — treating as no evidence");
663
+ }
664
+ return result;
665
+ }
666
+
667
+ // deriveReworkCount — cross-tick rework count: the number of "rejected"
668
+ // events on the task. getEvents already returns [] when there are no
669
+ // events, so a fresh task starts at 0 without any fallback here.
670
+ // Derivation failure is a transport failure: this MUST throw, never
671
+ // default — a swallowed error would collide attempt keys (stale
672
+ // spawn-report pickup) and bypass the MAX_REWORK cap.
673
+ export async function deriveReworkCount(env) {
674
+ var events = await getEvents(env, env.taskId, 100);
675
+ var n = 0;
676
+ for (var i = 0; i < events.length; i++) {
677
+ if (events[i] && events[i].type === "rejected") n++;
678
+ }
679
+ return n;
680
+ }
681
+
682
+
683
+ // ── Terminal cleanup & parking ──────────────────────────────────────────
684
+ // terminalCleanup — the run's last act at every park/fail boundary: two
685
+ // direct attempts at terminal-cleanup, then the backstop log line. The
686
+ // source's agent return-shape nuance is preserved: the second attempt is
687
+ // only skipped when the first returned a non-blank string.
688
+ export async function terminalCleanup(env) {
689
+ var taskId = env.taskId;
690
+ for (var attempt = 0; attempt < 2; attempt++) {
691
+ try {
692
+ var out = await lifecycle(env, "terminal-cleanup", [taskId]);
693
+ if (typeof out.stdout === "string" && out.stdout.trim().length > 0) return;
694
+ } catch (e) {
695
+ log("terminal-cleanup attempt " + (attempt + 1) + " of 2 failed: " + (e && e.message ? e.message : e));
696
+ }
697
+ }
698
+ log("terminal-cleanup backstop: both attempts failed; worktree/branch state may need manual review for task " + taskId);
699
+ }
700
+
701
+ // parkTask — parks the task for human attention (message capped at 1000
702
+ // chars), then runs terminal cleanup. The driver owns telemetryEnd.
703
+ export async function parkTask(env, reason) {
704
+ var taskId = env.taskId;
705
+ log("Parking task " + taskId + " for human attention: " + reason);
706
+ var parkMessage = ("Parked: " + reason).slice(0, 1000);
707
+ try {
708
+ await crewApi(env, "park-task", { task_id: taskId, message: parkMessage });
709
+ } catch (e) {
710
+ log("PARK FAILED for task " + taskId + ": " + (e && e.message ? e.message : e));
711
+ await terminalCleanup(env);
712
+ return { type: "PARK_FAILED", reason: "park failed: " + reason };
713
+ }
714
+ await terminalCleanup(env);
715
+ return { type: "PARK", reason: reason };
716
+ }
717
+
718
+ // recordPublishLedger — the append-only publish outcome ledger
719
+ // ($CREW_HOME/.publish-ledger/<publish-slug>.jsonl), ported to direct
720
+ // Node file I/O (the source ran it through a courier agent's shell; the
721
+ // bytes on disk are identical). Field-for-field identical to the source.
722
+ // Non-fatal: a write failure logs and returns false, exactly like the
723
+ // source's catch. P4 is preserved: entry.commit is referenced at the
724
+ // early artifact-publish rows BEFORE its `var` declaration, so it is
725
+ // undefined there and serializes as commit: null.
726
+ export async function recordPublishLedger(env, entry) {
727
+ var taskId = env.taskId;
728
+ try {
729
+ var ledgerDir = join(env.crewHome, ".publish-ledger");
730
+ mkdirSync(ledgerDir, { recursive: true });
731
+ // The source substituted `date -u +%Y-%m-%dT%H:%M:%SZ` via sed; the
732
+ // port runs the same command directly.
733
+ var tsOut = await runCmd(["date", "-u", "+%Y-%m-%dT%H:%M:%SZ"]);
734
+ var line = JSON.stringify({
735
+ ts: String(tsOut.stdout || "").trim(),
736
+ task_id: taskId,
737
+ workflow: env.resolvedWorkflow || "unknown",
738
+ slug: env.publishSlug,
739
+ commit: entry.commit || null,
740
+ attempt: entry.attempt || null,
741
+ agent_id: entry.agent_id || null,
742
+ applied_report: entry.applied_report || null,
743
+ manifest_before: entry.manifest_before || null,
744
+ outcome: entry.outcome,
745
+ detail: entry.detail || "",
746
+ // D1 (2026-09-19): issued_at = upper bound on the trigger-issuance
747
+ // instant (null when no trigger); ts = ledger-write instant.
748
+ issued_at: entry.issued_at || null,
749
+ // (2026-09-20, one-party worker-owned publish) issuer = which party
750
+ // wrote the entry ("workflow" for the intent entry, "tick-worker" for
751
+ // the worker's own issuance); diff_path/diff_sha256 locate the
752
+ // checksummed diff the worker issues. The workflow never writes an
753
+ // issuance ("submitted") entry — it did not issue.
754
+ issuer: entry.issuer || null,
755
+ diff_path: entry.diff_path || null,
756
+ diff_sha256: entry.diff_sha256 || null,
757
+ base: entry.base || null,
758
+ // (0.14.6) Numeric publication attempt + the attempt's deterministic
759
+ // version. Attempt 1 is staged here by the workflow; attempts 2-3 are
760
+ // staged by scan-ack-pending's re-issue path with fresh versions.
761
+ publish_attempt: entry.publish_attempt || null,
762
+ version: entry.version || null,
763
+ });
764
+ appendFileSync(join(ledgerDir, env.publishSlug + ".jsonl"), line + "\n");
765
+ log("Noted publish outcome '" + entry.outcome + "' for task " + taskId + " in ledger");
766
+ return true;
767
+ } catch (e) {
768
+ log("Publish ledger: write failed for task " + taskId + " (non-fatal, observability only): " + (e && e.message ? e.message : e));
769
+ return false;
770
+ }
771
+ }
772
+
773
+ // ── Instruction-string helpers ──────────────────────────────────────────
774
+ // crewCmdString — the pinned crew-api CLI invocation for agent
775
+ // instructions. Verbatim port of the source's crewCmd (which used the
776
+ // pinned CREW_API after the pin step).
777
+ export function crewCmdString(env, command, args) {
778
+ var json = JSON.stringify(args || {}).replace(/'/g, "'\\''");
779
+ return "node " + env.crewApiPinned + " --crew-home " + env.crewHome + " " + command + " --json '" + json + "'";
780
+ }
781
+
782
+ // lifecycleEnvPrefix — the "CREW_HOME=... CREW_REPO=... " prefix baked
783
+ // into every lifecycle invocation the agents run (source: LIFECYCLE_ENV).
784
+ export function lifecycleEnvPrefix(env) {
785
+ return "CREW_HOME=" + env.crewHome + " CREW_REPO=" + env.repoPath + " ";
786
+ }
787
+
788
+ // latestSessionNotes — the notes of the latest session for this task with
789
+ // the given step+status ("" when none). Cross-phase handoffs (mapper spec,
790
+ // rejection notes, release decision, repo_diff:none claim) are re-derived
791
+ // from durable session notes so a run survives tick boundaries.
792
+ export async function latestSessionNotes(env, step, status) {
793
+ var st = await crewApi(env, "get-state", { events_limit: 1 });
794
+ var sessions = (st && st.sessions) || [];
795
+ var best = null;
796
+ for (var i = 0; i < sessions.length; i++) {
797
+ var s = sessions[i];
798
+ if (s.task_id === env.taskId && s.step === step && s.status === status) {
799
+ if (!best || String(s.started_at || "") > String(best.started_at || "")) best = s;
800
+ }
801
+ }
802
+ return best ? (best.notes || "") : "";
803
+ }
804
+
805
+ // verifyPin — the pre-Publish pin guard's mechanical check: every
806
+ // PIN_BASENAMES entry present in the run lib.
807
+ export function verifyPin(env) {
808
+ var names;
809
+ try {
810
+ names = readdirSync(env.runLib);
811
+ } catch (e) {
812
+ return { ok: false, missing: PIN_BASENAMES.slice() };
813
+ }
814
+ var missing = PIN_BASENAMES.filter(function (b) { return names.indexOf(b) === -1; });
815
+ return { ok: missing.length === 0, missing: missing };
816
+ }
817
+
818
+ // deriveRunEnv — the static per-run environment, derived once by the
819
+ // driver from the dispatch inputs and the project config. All surface /
820
+ // path / title derivations are verbatim from workflows/chore.js.
821
+ export function deriveRunEnv(o) {
822
+ var publishType = o.projectConfig.deploy_type || "";
823
+ var envType = o.projectConfig.environment_type || null;
824
+ var surfaceArtifact = (publishType === "artifact" || envType === "artifact");
825
+ var surfaceTerminal = (!surfaceArtifact && envType === "terminal");
826
+ var surfaceTriageDesc = surfaceArtifact
827
+ ? "This project's user-facing surface is artifact: a rendered web UI."
828
+ : surfaceTerminal
829
+ ? "This project's user-facing surface is terminal: a command-line interface."
830
+ : "This project's user-facing surface is unclassified (environment_type not set): judge by what a user would directly observe.";
831
+ var uxDoctrinePage = surfaceTerminal ? "terminal-ux.md" : (surfaceArtifact ? "artifact-ux.md" : null);
832
+ return {
833
+ crewHome: o.crewHome, repoPath: o.repoPath, orchPath: o.orchPath, runLib: o.runLib,
834
+ lifecycle: o.lifecycle, mergeLock: o.mergeLock, publishNpm: o.publishNpm, computeDiff: o.computeDiff,
835
+ releaseScript: o.crewHome + "/crew-release.sh",
836
+ crewApi: o.crewApi, crewApiPinned: o.crewApiPinned,
837
+ taskId: o.taskId, taskTitle: o.taskTitle, taskDescription: o.taskDescription,
838
+ projectId: o.projectId, publishType: publishType,
839
+ publishSlug: o.projectConfig.deploy_slug || "",
840
+ projectDesc: o.projectConfig.description || "React + TypeScript web dashboard (client/src/, server/src/, drizzle/)",
841
+ surfaceArtifact: surfaceArtifact, surfaceTerminal: surfaceTerminal,
842
+ surfaceTriageDesc: surfaceTriageDesc,
843
+ uxDoctrinePath: uxDoctrinePage ? o.crewHome + "/current/docs/" + uxDoctrinePage : null,
844
+ visualProtocolAvailable: o.visualProtocol === true,
845
+ workflowWasNull: !!o.workflowWasNull, resolvedWorkflow: o.resolvedWorkflow || null,
846
+ worktreeHint: o.repoPath + "/.worktrees/" + o.taskId,
847
+ worktreePreservedHint: ".worktrees/" + o.taskId,
848
+ taskBranch: "task/" + o.taskId,
849
+ safeTitle: String(o.taskTitle || "").replace(/"/g, "'").replace(/\\/g, "\\\\").replace(/`/g, "'"),
850
+ };
851
+ }
852
+
853
+ // closeoutPassed — the deterministic closeout rule: for verdict steps the
854
+ // verdict decides; for non-verdict steps (Triage, Capture, Map) the worker
855
+ // producing output means the step passed.
856
+ export function closeoutPassed(boundaryResult) {
857
+ return boundaryResult.verdictPassed !== null ? boundaryResult.verdictPassed === true : true;
858
+ }
859
+
860
+ // ensureClaimed — the phase claim protocol. Claims once per phase visit;
861
+ // on resume passes the driver passes the persisted activeSessionId and the
862
+ // claim is skipped. A lost first-claim race stands down quietly.
863
+ export async function ensureClaimed(env, state, phase) {
864
+ if (state.activeSessionId) return { type: "CLAIMED", session_id: state.activeSessionId };
865
+ if (state.isFirstClaimVisit) {
866
+ var first = await claimFirst(env, state, phase);
867
+ if (!first.claimed) {
868
+ log(phase.name + " lost the claim race for task " + env.taskId + " — standing down quietly");
869
+ return { type: "STAND_DOWN", reason: "lost claim race for task " + env.taskId };
870
+ }
871
+ state.activeSessionId = first.session_id;
872
+ // Blocker 46: the first claim happens exactly once per run. Later
873
+ // phases in the same run go through claimStep (a fresh per-phase
874
+ // session), never a second first-claim.
875
+ state.isFirstClaimVisit = false;
876
+ return { type: "CLAIMED", session_id: first.session_id };
877
+ }
878
+ var step = await claimStep(env, state, phase);
879
+ if (step.stand_down) {
880
+ log(phase.name + " lost the claim race for task " + env.taskId + " — standing down quietly");
881
+ return { type: "STAND_DOWN", reason: step.reason };
882
+ }
883
+ state.activeSessionId = step.session_id;
884
+ return { type: "CLAIMED", session_id: step.session_id };
885
+ }