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