muse-crew 0.17.1 → 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 (71) 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/compare-dispatch-shadow.js +35 -10
  39. package/lib/crew-api.js +273 -0
  40. package/lib/crew-dispatch-worker.js +27 -7
  41. package/lib/crew-release.sh +7 -2
  42. package/lib/extract.js +252 -0
  43. package/lib/prompts/tool-check.js +18 -0
  44. package/lib/prompts/trailers.js +59 -0
  45. package/lib/prompts/verdict-reask.js +31 -0
  46. package/lib/prompts/verdict-reask.schema.json +1 -0
  47. package/lib/prompts/work-agent.js +56 -0
  48. package/lib/prompts/work-agent.schema.json +1 -0
  49. package/lib/reap-spawns.js +407 -0
  50. package/lib/schema.sql +12 -1
  51. package/lib/spawn-keys.js +47 -0
  52. package/lib/spawn-step.js +572 -0
  53. package/lib/standard/phases/build.js +120 -0
  54. package/lib/standard/phases/capture.js +163 -0
  55. package/lib/standard/phases/integrate.js +172 -0
  56. package/lib/standard/phases/map.js +119 -0
  57. package/lib/standard/phases/publish.js +565 -0
  58. package/lib/standard/phases/qa.js +399 -0
  59. package/lib/standard/phases/review.js +281 -0
  60. package/lib/standard/phases/triage.js +64 -0
  61. package/lib/test-detached-integrate.sh +47 -0
  62. package/lib/workflow-driver.js +605 -0
  63. package/lib/workflow-lib.js +1012 -0
  64. package/lib/workflow-spec.js +187 -0
  65. package/lib/worktree-lifecycle.sh +55 -3
  66. package/package.json +1 -1
  67. package/seed/cron-body-template.md +61 -9
  68. package/workflows/bugfix.js +17 -17
  69. package/workflows/chore.js +16 -16
  70. package/workflows/docs.js +14 -11
  71. package/workflows/standard.js +16 -16
@@ -0,0 +1,187 @@
1
+ // lib/workflow-spec.js — workflow spec loader/validator (composition
2
+ // machinery, Phase B, 2026-09-26). Import-safe ESM: no side effects on
3
+ // import, bare `node` exits 0.
4
+ //
5
+ // A workflow spec is JSON declaring COMPOSITION: the ordered phases, the
6
+ // named non-sequential transitions (rework loops), and guard references.
7
+ // Phases stay code — the spec names the module, never the implementation.
8
+ // The future composition UI edits specs; the generic driver
9
+ // (lib/workflow-driver.js) runs them.
10
+ //
11
+ // Spec shape:
12
+ // {
13
+ // "workflow": "chore",
14
+ // "phases": [
15
+ // { "name": "Triage",
16
+ // "module": "../lib/chore/phases/triage.js",
17
+ // "contract": "Reads: ... Writes: ..." }
18
+ // ],
19
+ // "transitions": [
20
+ // { "from": "Review", "on": "rework", "to": "Build" }
21
+ // ],
22
+ // "guards": [],
23
+ // "rejectedResume": "Build"
24
+ // }
25
+ //
26
+ // Module refs resolve against the spec file's own directory (so the spec
27
+ // is self-contained and relocatable). Validation fails closed: any
28
+ // malformed spec throws with a clear reason — the driver never runs a
29
+ // half-understood workflow. No wall-clock reads, no randomness.
30
+
31
+ import { readFileSync } from "node:fs";
32
+ import { dirname, resolve } from "node:path";
33
+ import { pathToFileURL } from "node:url";
34
+
35
+ function fail(msg) { throw new Error("workflow spec: " + msg); }
36
+
37
+ function isObj(v) { return v !== null && typeof v === "object" && !Array.isArray(v); }
38
+
39
+ // Closed schema: unknown keys are almost certainly typos (a misspelled
40
+ // "rejectedResume" must not silently load). Fail closed on them.
41
+ function rejectUnknown(obj, known, where) {
42
+ for (var k in obj) {
43
+ if (Object.prototype.hasOwnProperty.call(obj, k) && known.indexOf(k) === -1) {
44
+ fail(where + ": unknown key \"" + k + "\" — must be one of: " + known.join(", ") + ".");
45
+ }
46
+ }
47
+ }
48
+
49
+ // validateSpec — pure: takes a parsed value, returns the normalized spec
50
+ // or throws. Normalized: { workflow, phases, phaseNames, phaseIndex,
51
+ // transitions, guards, rejectedResume, projectDescFallback, promptsDir }.
52
+ // projectDescFallback/promptsDir are optional workflow-declared runtime
53
+ // values: the generic driver threads them into the run environment so a
54
+ // workflow keeps its own project description fallback and prompt-template
55
+ // directory without workflow-shaped conditionals in the driver.
56
+ export function validateSpec(spec) {
57
+ if (!isObj(spec)) fail("top level must be an object.");
58
+ rejectUnknown(spec, ["workflow", "phases", "transitions", "guards", "rejectedResume", "projectDescFallback", "promptsDir", "reworkPhases"], "spec");
59
+ if (typeof spec.workflow !== "string" || spec.workflow.trim() === "")
60
+ fail("\"workflow\" must be a non-empty string.");
61
+ if (!Array.isArray(spec.phases) || spec.phases.length === 0)
62
+ fail("\"phases\" must be a non-empty array.");
63
+ var phases = [];
64
+ var seen = {};
65
+ for (var i = 0; i < spec.phases.length; i++) {
66
+ var p = spec.phases[i];
67
+ if (!isObj(p)) fail("phases[" + i + "] must be an object.");
68
+ rejectUnknown(p, ["name", "module", "contract"], "phases[" + i + "]");
69
+ if (typeof p.name !== "string" || p.name.trim() === "")
70
+ fail("phases[" + i + "].name must be a non-empty string.");
71
+ if (seen[p.name]) fail("duplicate phase name \"" + p.name + "\".");
72
+ seen[p.name] = true;
73
+ if (typeof p.module !== "string" || p.module.trim() === "")
74
+ fail("phases[" + i + "].module must be a non-empty string.");
75
+ phases.push({
76
+ name: p.name,
77
+ module: p.module,
78
+ contract: typeof p.contract === "string" ? p.contract : "",
79
+ });
80
+ }
81
+ var transitions = [];
82
+ if (spec.transitions !== undefined) {
83
+ if (!Array.isArray(spec.transitions)) fail("\"transitions\" must be an array.");
84
+ for (var t = 0; t < spec.transitions.length; t++) {
85
+ var tr = spec.transitions[t];
86
+ if (!isObj(tr)) fail("transitions[" + t + "] must be an object.");
87
+ rejectUnknown(tr, ["from", "to", "on"], "transitions[" + t + "]");
88
+ if (typeof tr.from !== "string" || !seen[tr.from])
89
+ fail("transitions[" + t + "].from must name a declared phase.");
90
+ if (typeof tr.to !== "string" || !seen[tr.to])
91
+ fail("transitions[" + t + "].to must name a declared phase.");
92
+ if (tr.on !== undefined && (typeof tr.on !== "string" || tr.on.trim() === ""))
93
+ fail("transitions[" + t + "].on must be a non-empty string when present.");
94
+ transitions.push({ from: tr.from, to: tr.to, on: tr.on || null });
95
+ }
96
+ }
97
+ var guards = [];
98
+ if (spec.guards !== undefined) {
99
+ if (!Array.isArray(spec.guards)) fail("\"guards\" must be an array.");
100
+ for (var g = 0; g < spec.guards.length; g++) {
101
+ if (typeof spec.guards[g] !== "string" || spec.guards[g].trim() === "")
102
+ fail("guards[" + g + "] must be a non-empty string.");
103
+ guards.push(spec.guards[g]);
104
+ }
105
+ }
106
+ var phaseNames = phases.map(function (p) { return p.name; });
107
+ var phaseIndex = {};
108
+ for (var n = 0; n < phaseNames.length; n++) phaseIndex[phaseNames[n]] = n;
109
+ // rejectedResume — optional: the phase a rejected latest session resumes
110
+ // at (rework entry point; workflow knowledge, so spec-declared).
111
+ var rejectedResume = null;
112
+ if (spec.rejectedResume !== undefined) {
113
+ if (typeof spec.rejectedResume !== "string" || !seen[spec.rejectedResume])
114
+ fail("\"rejectedResume\" must name a declared phase.");
115
+ rejectedResume = spec.rejectedResume;
116
+ }
117
+ // projectDescFallback — optional: the project-description fallback the
118
+ // driver threads into deriveRunEnv (chore's dashboard description; other
119
+ // workflows leave it empty).
120
+ var projectDescFallback = "";
121
+ if (spec.projectDescFallback !== undefined) {
122
+ if (typeof spec.projectDescFallback !== "string")
123
+ fail("\"projectDescFallback\" must be a string when present.");
124
+ projectDescFallback = spec.projectDescFallback;
125
+ }
126
+ // promptsDir — optional: the prompt-template directory the driver threads
127
+ // into deriveRunEnv, resolved relative to the spec file's directory by the
128
+ // driver (phaseModuleUrl's rule). Unset keeps the run-lib default.
129
+ var promptsDir = null;
130
+ if (spec.promptsDir !== undefined) {
131
+ if (typeof spec.promptsDir !== "string" || spec.promptsDir.trim() === "")
132
+ fail("\"promptsDir\" must be a non-empty string when present.");
133
+ promptsDir = spec.promptsDir;
134
+ }
135
+ // reworkPhases — optional: the phases whose rejections consume the shared
136
+ // rework budget. When declared, deriveReworkCount counts rejected sessions
137
+ // for exactly these phases; when absent, it counts every rejected event
138
+ // (the pre-spec behavior Standard and Chore rely on). Bugfix declares
139
+ // ["Review", "QA"] — its Build and Reproduce failures record "rejected"
140
+ // but must not spend the shared Review/QA budget.
141
+ var reworkPhases = null;
142
+ if (spec.reworkPhases !== undefined) {
143
+ if (!Array.isArray(spec.reworkPhases) || spec.reworkPhases.length === 0)
144
+ fail("\"reworkPhases\" must be a non-empty array when present.");
145
+ for (var r = 0; r < spec.reworkPhases.length; r++) {
146
+ if (typeof spec.reworkPhases[r] !== "string" || !seen[spec.reworkPhases[r]])
147
+ fail("\"reworkPhases[" + r + "]\" must name a declared phase.");
148
+ }
149
+ reworkPhases = spec.reworkPhases.slice();
150
+ }
151
+ return {
152
+ workflow: spec.workflow,
153
+ phases: phases,
154
+ phaseNames: phaseNames,
155
+ phaseIndex: phaseIndex,
156
+ transitions: transitions,
157
+ guards: guards,
158
+ rejectedResume: rejectedResume,
159
+ projectDescFallback: projectDescFallback,
160
+ promptsDir: promptsDir,
161
+ reworkPhases: reworkPhases,
162
+ };
163
+ }
164
+
165
+ // loadSpec — read + JSON.parse + validate. IO and syntax errors fail
166
+ // closed with the spec path named.
167
+ export function loadSpec(specPath) {
168
+ var text;
169
+ try {
170
+ text = readFileSync(specPath, "utf8");
171
+ } catch (e) {
172
+ fail("cannot read " + specPath + ": " + (e && e.message ? e.message : String(e)));
173
+ }
174
+ var raw;
175
+ try {
176
+ raw = JSON.parse(text);
177
+ } catch (e) {
178
+ fail(specPath + " is not valid JSON: " + (e && e.message ? e.message : String(e)));
179
+ }
180
+ return validateSpec(raw);
181
+ }
182
+
183
+ // phaseModuleUrl — resolve a phase's module ref against the spec file's
184
+ // directory, as a file: URL string for dynamic import(). Pure path logic.
185
+ export function phaseModuleUrl(specPath, moduleRef) {
186
+ return pathToFileURL(resolve(dirname(resolve(specPath)), moduleRef)).href;
187
+ }
@@ -422,6 +422,26 @@ acquire_merge_lock() {
422
422
  done
423
423
  }
424
424
 
425
+ # ff_integrated(branch, target): print the branch's current tip when it is
426
+ # EXACTLY the integration target's tip — the branch was pushed by
427
+ # fast-forward (or a prior recovery pushed it), so no merge commit exists
428
+ # but the line carries precisely the branch's present state. Prints
429
+ # nothing and returns 1 otherwise. Exact tip equality is the only shape
430
+ # recognized: a branch reset backward after integration can remain an
431
+ # ancestor of the target, so ancestry alone would defeat blocker-35's
432
+ # fail-closed protection (fixture F3: backward rework after a recorded
433
+ # merge). Anything less certain than tip == target tip stays fail-closed.
434
+ ff_integrated() {
435
+ local branch="$1" target="$2" tip target_tip
436
+ tip=$(git rev-parse --verify "$branch" 2>/dev/null) || return 1
437
+ target_tip=$(git rev-parse --verify "$target" 2>/dev/null) || return 1
438
+ if [ "$tip" = "$target_tip" ]; then
439
+ printf '%s' "$tip"
440
+ return 0
441
+ fi
442
+ return 1
443
+ }
444
+
425
445
  # current_branch_merge(branch): print the newest merge commit on live HEAD's
426
446
  # first-parent chain whose branch-side parent (^2) is the branch's CURRENT
427
447
  # tip — i.e. the merge that brought the branch's present state onto the
@@ -597,7 +617,28 @@ cmd_integrate() {
597
617
  echo "LOCK: held by $task_id — release after deploy with post-deploy"
598
618
  return 0
599
619
  fi
620
+ # Fast-forward recovery (2026-09-27): no merge commit exists, but the
621
+ # branch tip IS the target tip (pushed by fast-forward in a prior
622
+ # attempt) — ff_integrated recognizes only exact tip equality, so
623
+ # blocker-35's fail-closed protection is preserved (a branch moved
624
+ # backward after integration is still an ancestor of the target, and
625
+ # ancestry alone is not accepted). No merge record is appended:
626
+ # scan_merge_records skips non-merge commits, and the ff state is
627
+ # self-describing (tip == target tip).
600
628
  if [ -n "$recorded_commit" ]; then
629
+ local ff_tip
630
+ ff_tip=$(ff_integrated "$branch" "$target" 2>/dev/null) || ff_tip=""
631
+ if [ -n "$ff_tip" ]; then
632
+ acquire_merge_lock "$task_id" || exit 2
633
+ if do_push "$task_id"; then
634
+ echo "MERGED: $ff_tip (recovered: branch tip fast-forwarded onto $target in a prior attempt; push completed now)"
635
+ else
636
+ echo "ERROR: push recovery failed for fast-forwarded branch tip $ff_tip"
637
+ return 1
638
+ fi
639
+ echo "LOCK: held by $task_id — release after deploy with post-deploy"
640
+ return 0
641
+ fi
601
642
  echo "STALE_MERGE: recorded merge $recorded_commit no longer corresponds to $branch (tip $(git rev-parse --verify "$branch" 2>/dev/null)) — no merge of the current tip is on the live line; refusing to push possibly-stale state"
602
643
  return 1
603
644
  fi
@@ -1221,12 +1262,19 @@ cmd_push_target() {
1221
1262
  # branch's current tip was merged onto the live line before pushing.
1222
1263
  # Branch gone (merge-lease reclaim): no rework could have moved it, so
1223
1264
  # the recorded merge on the line is still the deliverable.
1224
- local branch live_merge recorded_commit
1265
+ local branch live_merge recorded_commit ff_tip
1225
1266
  recorded_commit=$(grep "^merge_commit=" "$record_file" | tail -1 | cut -d= -f2-)
1226
1267
  live_merge=""
1268
+ ff_tip=""
1227
1269
  if branch=$(resolve_branch "$task_id" 2>/dev/null); then
1228
1270
  if git rev-parse --verify "$branch" >/dev/null 2>&1; then
1229
1271
  live_merge=$(current_branch_merge "$branch") || live_merge=""
1272
+ if [ -z "$live_merge" ]; then
1273
+ # Fast-forward case (2026-09-27): no merge commit exists, but the
1274
+ # branch tip is exactly the target tip — ff_integrated accepts only
1275
+ # tip equality, so blocker-35's fail-closed protection is preserved.
1276
+ ff_tip=$(ff_integrated "$branch" "HEAD" 2>/dev/null) || ff_tip=""
1277
+ fi
1230
1278
  elif [ -n "$recorded_commit" ] && git merge-base --is-ancestor "$recorded_commit" "$(git rev-parse HEAD 2>/dev/null)" 2>/dev/null; then
1231
1279
  live_merge="$recorded_commit"
1232
1280
  fi
@@ -1234,11 +1282,15 @@ cmd_push_target() {
1234
1282
  echo "ERROR: cannot resolve task branch for $task_id — not pushing without a verifiable branch"
1235
1283
  return 1
1236
1284
  fi
1237
- if [ -z "$live_merge" ]; then
1285
+ if [ -z "$live_merge" ] && [ -z "$ff_tip" ]; then
1238
1286
  echo "STALE_MERGE: no merge of the current ${branch:-unknown} tip is on the live line (recorded: ${recorded_commit:-none}) — the task branch changed since the recorded merge; refusing to push possibly-stale state"
1239
1287
  return 1
1240
1288
  fi
1241
- echo "PUSH_IDENTITY_OK: pushing merge $live_merge carrying the current $branch tip"
1289
+ if [ -n "$ff_tip" ]; then
1290
+ echo "PUSH_IDENTITY_OK: branch $branch tip $ff_tip is on the live line via fast-forward — pushing"
1291
+ else
1292
+ echo "PUSH_IDENTITY_OK: pushing merge $live_merge carrying the current $branch tip"
1293
+ fi
1242
1294
 
1243
1295
  do_push "$task_id"
1244
1296
  return $?
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muse-crew",
3
- "version": "0.17.1",
3
+ "version": "0.17.3",
4
4
  "description": "Opinionated orchestration for Muse — workflows, identities, and tooling for autonomous software development.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -39,19 +39,38 @@ The failures below are settled and recorded in the Gate 1 OODA state. The author
39
39
  - Route each completed-run failure through the same two commands — `record-platform-failure`, then `retry-platform-failure` — exactly as above.
40
40
  - Log the results. This ensures transient platform failures don't strand tasks.
41
41
 
42
+ 0b. **Reap worker-layer spawn rows (cutover piece):** the worker-layer
43
+ Chore driver leaves `worker_runs` rows `running` when its tick dies
44
+ mid-spawn. Reap them before dispatch so a dead tick never strands a
45
+ spawn past its timeout. Run in shell:
46
+ `node {crewHome}/lib/reap-spawns.js --crew-home {crewHome}`
47
+ The last stdout line is one JSON object
48
+ `{closed:[...], kill_instructions:[...], errors:[]}`; log it verbatim.
49
+ - `closed` entries were finished by the reaper itself — no action.
50
+ - For each entry in `kill_instructions`: attempt the runtime close of
51
+ the named child (subagent handle or OS pid, as the instruction
52
+ specifies), then record the attempt in shell:
53
+ `node {crewHome}/lib/reap-spawns.js --crew-home {crewHome} --record-close --id <row_id> --result <killed|not_found|refused> [--detail <text>]`
54
+ Log the `{recorded:...}` result verbatim. A `refused` close leaves
55
+ the row for the kill-and-retry path — never force it.
56
+ - `errors` = log only; a reaper error never aborts the tick.
57
+ (Inert until worker spawn rows exist — pre-cutover this always returns
58
+ empty lists.)
59
+
42
60
  1. **Load tools:** Call tool_search_load_tool_namespace with paths ["workflow_launch"].
43
61
 
44
- 2. **Load the workflow registry:** Read the file "{crewHome}/workflows/registry.json" with the read tool and keep its RAW TEXT — do NOT parse it, do NOT re-emit it as a JSON object. Pass the text verbatim: never reformat, summarize, or hand-transcribe it (room #26: 15+ launch rejections 09-19→09-21 from garbled nested-object args, and one hallucinated registry that passed validation). If the file does not exist (the live release predates the registry), proceed without it — omit the `registryText` arg and the dispatcher will load the registry the slow way and log a warning. If the read FAILS on a file that exists (transient read error — observed 2026-09-13; the file itself was healthy and later ticks read it fine), retry the read once; if it still fails, write the error text into your run summary (observability — never silently swallow a failed read) and proceed without the registry the same way.
62
+ 2. **Check the workflow registry:** Read the file "{crewHome}/workflows/registry.json" with the read tool and confirm it is valid JSON — do NOT hand-transcribe it anywhere (room #26: 15+ launch rejections 09-19→09-21 from garbled nested-object args, and one hallucinated registry that passed validation). The Step-3 dispatcher reads this file itself; you only verify it exists and parses. If the read FAILS on a file that exists (transient read error — observed 2026-09-13; the file itself was healthy and later ticks read it fine), retry the read once; if it still fails, write the error text into your run summary (observability — never silently swallow a failed read) and continue — the dispatcher will log its own registry warning.
45
63
 
46
- 2.5. **Shadow dispatch (Piece 1, 2026-09-26):** the worker-layer dispatcher (`lib/crew-dispatch-worker.js`) is the sandbox exit's replacement for the sandboxed dispatcher in Step 3. It is NOT authoritative yet — Step 3's sandboxed dispatcher still owns every launch decision. Run the worker dispatcher in read-only shadow mode BEFORE Step 3 (both must read the same board state; the shadow never mutates it). Run in shell:
47
- `node {crewHome}/lib/crew-dispatch-worker.js --crew-home {crewHome} --read-only`
48
- The last stdout line is a JSON result (`{status, claims, ...}`); lines before it are the decision log. The script appends its own shadow evidence to `{crewHome}/.dispatch-shadow.jsonl` — do NOT transcribe or re-emit the claims yourself. If the script exits non-zero, log `SHADOW_FAIL <stderr tail>` and continue to Step 3 (the shadow is evidence, never a gate). A zero exit with claims is normal — the claims are recommendations only; NOTHING is launched from them. Every claim carries `executor: "worker"`; the shadow comparison ignores it.
64
+ 3. **Run the dispatcher (worker-layer, authoritative — cutover 2026-09-26):** the dispatcher is `lib/crew-dispatch-worker.js`, run in shell — NOT the sandboxed `workflows/crew-dispatch.js` (retired; two dispatchers would double-dispatch). Run:
65
+ `node {crewHome}/lib/crew-dispatch-worker.js --crew-home {crewHome} --authoritative`
66
+ The last stdout line is one JSON object (`{status, claims, ...}`); lines before it are the decision log. Read the `claims` array from the parsed JSON — do NOT transcribe claims by hand. (The dispatcher reads `{crewHome}/workflows/registry.json` itself; never pass the registry on the command line.)
49
67
 
50
- 3. **Run the dispatcher:** Call workflow_launch with scriptPath "{crewHome}/workflows/crew-dispatch.js" and args {"crewHome": "{crewHome}", "registryText": <raw registry file text, or omit the key when the file was missing>}. The dispatcher parses the text itself — a nested-object `registry` arg is not accepted. If the launch is rejected, retry once with only {"crewHome": "{crewHome}"} (the sanctioned fallback — the dispatcher loads the registry the slow way); a second rejection is a platform problem, not an args problem — write it in your run summary and move on.
68
+ It reads the crew's task state, determines eligibility, reserves tasks through the atomic `reserve-dispatch` (a live reservation wins; the loser stands down), acknowledges the poll, and writes the tick-release and dispatch-decision logs. Every claim carries `executor: "worker"` and the launch contract (`task_id`, `workflow`, `step`, `scriptPath`, `args`).
51
69
 
52
- Wait for it to complete. It reads the crew's task state, determines eligibility, claims tasks, acknowledges the poll, and returns structured results.
70
+ If the script exits non-zero, log the exit code and the stderr tail, then CONTINUE to Step 4.4 with zero claims — a failed dispatch means no launches this tick, but the intent scan, ack scan, and evidence steps still run. Do NOT fall back to the sandboxed dispatcher.
53
71
 
54
72
  4. **Launch claims autonomously:** Extract the `claims` array from the dispatcher result. For each claim (up to 3 per tick — if more than 3, launch the first 3 and log the rest as deferred):
73
+ - **Routing (cutover piece):** if `claim.workflow === "chore"` AND `claim.executor === "worker"`, do NOT launch it here — follow Step 4w (worker-layer Chore) instead. Every other claim follows this step unchanged. (Every claim now carries `executor: "worker"` — the worker-layer dispatcher is the sole launch authority since the 2026-09-26 cutover.)
55
74
  - Call workflow_launch_async with scriptPath={claim.scriptPath} and args={claim.args}. Record the returned run_id.
56
75
  - Immediately acknowledge the dispatch reservation with the real run_id (the dispatcher already created a placeholder reservation — this updates it). Run in shell:
57
76
  `node {crewHome}/lib/crew-api.js --crew-home {crewHome} acknowledge-dispatch-run --json '{"task_id": "{claim.task_id}", "run_id": "<run_id>"}'`
@@ -60,6 +79,42 @@ The failures below are settled and recorded in the Gate 1 OODA state. The author
60
79
 
61
80
  If the dispatcher returned no claims or the claims array is empty, log NO_DISPATCH and CONTINUE to Step 4.4 — do NOT exit. Intent scan (4.4), ack scan (4.5) and evidence (6) run independently of dispatch claims. A no-claims tick must still issue parked publish intents, scan for acknowledgements, and deliver evidence. (Fixed 2026-09-14: the old "exit on NO_DISPATCH" skipped publication permanently.)
62
81
 
82
+ 4w. **Worker-layer Chore claim (cutover piece — inert until a dispatcher
83
+ tags a chore claim `executor: "worker"`):** the worker-layer driver
84
+ (`lib/chore-run.js`) replaces `workflow_launch_async` for this claim.
85
+ You are the ferry: the driver decides, the bridge posts, you spawn.
86
+ The driver claims the task itself through the atomic `claim-task`
87
+ (claim-as-gate) — you do not claim, and you do not check the claim
88
+ first. If the sandbox dispatcher (or another tick) won the race, the
89
+ ferry returns `done` with outcome `stood-down`: log it and move on;
90
+ the task is already owned.
91
+ - Tick sequence: the `tick_seq` of the dispatch decision from this
92
+ tick's Step 3 — the last line of
93
+ `{crewHome}/.dispatch-decisions.jsonl`. Use it verbatim (no
94
+ arithmetic); it must be a positive integer. It becomes the spawn
95
+ rows' `owner_tick_seq`. If the file is missing or the last line
96
+ carries no `tick_seq`, stop and log — do not invent a sequence.
97
+ - Invoke the ferry in shell:
98
+ `node {crewHome}/lib/chore-tick.js --crew-home {crewHome} --task-id "<claim.task_id>" --tick-seq <N> --project-id "<claim.args.project_id>" [--visual-protocol|--no-visual-protocol from claim.args.visual_protocol] [--resolved-workflow <claim.args.resolved_workflow>] [--workflow-was-null when claim.args.workflow_was_null]`
99
+ Copy flag values verbatim from the claim's `args` — never re-derive them.
100
+ - Read the ferry's single stdout JSON line (`action`):
101
+ - `"spawn"`: perform the spawn ritual —
102
+ 1. Spawn ONE subagent with `spawn.prompt` as its full instructions.
103
+ Convey the working directory (`spawn.cwd`) and environment
104
+ (`spawn.env`) in the spawn — if your spawn mechanism takes only
105
+ a message, state them explicitly at the top of the instructions;
106
+ never let the agent guess where to work.
107
+ 2. Immediately record `child: {"kind":"subagent","agent_id":"<id>"}` into the existing session file at `spawn.session_path` (read-modify-write: preserve its fields, add `child`).
108
+ 3. Wait up to `spawn.timeout_ms` for the agent's final report (the
109
+ report arrives asynchronously — do not poll; if the timeout
110
+ elapses first, treat it as a timeout). Update the session file: `outcome: "returned"` + `text` = the final report verbatim. On timeout: stop the agent if possible (your agent-close mechanism), then `outcome: "timeout"` (+ `kill_evidence: {"verified_dead": true, "method": "<how>", "detail": "<what>"}` only when a machine liveness check proved it dead — an unconfirmed kill leaves the row running for the Step-0b reaper, by design). On spawn failure: `outcome: "spawn_error"` + `error`.
111
+ 4. Re-invoke the ferry with `--session "<spawn.session_path>"` appended. Repeat from the action read.
112
+ - `"wait"`: log `reason`; stop driving this task this tick. The next tick resumes (re-invocation is safe — a running spawn yields WAIT again; a lost claim race yields done/stood-down).
113
+ - `"done"`: log `outcome` (+ `reason`); stop. The driver already wrote the terminal task state.
114
+ - `"error"`: log `reason`; stop. Do not retry in a tight loop — the next tick retries.
115
+ - Loop within the tick until `wait`/`done`/`error`. The ferry enforces its own frame budget (20 frames; exhaustion yields `wait` with reason `frame-budget-exhausted` — never done/failed). If the tick's 90-minute ceiling (Step 5) is nearly exhausted, stop looping — the next tick resumes. Budgets end the tick's participation, never the task.
116
+ - Never build prompts, never write the ledger except through the ferry, never interpret agent output — `spawn-step.js post` classifies it.
117
+
63
118
  4.4. **Publish intent scan (0.14.6):** the workflow parks at Publish with a version-carrying publish-intent ledger entry; issuance belongs to the tick. Deterministic code owns every transition; you are the ferry (scan → issue → record). Run this BEFORE Step 4.5 so an issued edit's ack window is open to the acknowledgement scan on a later tick. Log every list the scan returns.
64
119
  - Scan (code): `node {crewHome}/lib/crew-api.js --crew-home {crewHome} scan-publish-intent`
65
120
  This atomically claims parked publish intents for this tick (1-hour lease, so a second tick cannot double-issue). It returns `{ intent: [...], skipped: [...], requeued: [...] }`. Each intent entry carries `task_id`, `commit`, `attempt`, `slug`, `repo_path`, `project_id`, `ledger_path`, `base`, `diff_path`, `diff_sha256`, `version` (`<commit>:<attempt>`), `files` (changed paths), `claimed_at`, `claim_expiry`, `reclaimed` (true when a previous tick died mid-issuance). Copy the entry's fields verbatim — never re-derive them.
@@ -111,9 +166,6 @@ The failures below are settled and recorded in the Gate 1 OODA state. The author
111
166
  - `reissued` = fresh intents for the next attempt — a 30-minute window expired with no acknowledgement, so code re-issued with a fresh version (by derivation), immediately claimable (no backoff). This tick does NOT issue them; the NEXT tick's `scan-publish-intent` claims them. Log only.
112
167
  - `skipped` = window still open or evidence absent — log only, take no action.
113
168
 
114
- 4.6. **Shadow verdict (Piece 1, 2026-09-26):** after the Step-3 dispatcher's decision is recorded, pair its decision with the Step-2.5 shadow evidence. Run in shell:
115
- `node {crewHome}/lib/compare-dispatch-shadow.js --crew-home {crewHome}`
116
- Log every `SHADOW_*` line verbatim. `SHADOW_MATCH` means the worker layer agreed with the sandbox on this tick's claims. `SHADOW_DIVERGE` names the differing claim triples (`task_id|workflow|step`) — log them; divergence is evidence for the cutover review, not a tick failure, so continue the tick. `SHADOW_UNPAIRED` means the authoritative decision line is missing (the Step-3 dispatcher died after the shadow ran) — log it loudly and continue. `SHADOW_NO_EVIDENCE` on the first shadowed tick is normal. Verdicts append to `{crewHome}/.dispatch-shadow-verdicts.jsonl`; the cutover decision after ~100-200 ticks is made from that file, never from a single tick.
117
169
  - Never stamp provenance from prose. Never infer a verdict from an inspector's summary text. The exact version string is the sole positive signal.
118
170
 
119
171
  5. **Monitor launched workflows until terminal (stay-alive — 2026-09-13):** The platform ties async workflow `agent()` authorization to the launcher's lifetime: if THIS tick ends while a workflow is still running, the workflow's next `agent()` call fails with "subagent bootstrap is no longer authorized" / "subagent reservation owner is terminal". Prevention beats recovery here, so this tick is configured with a 90-minute execution timeout (`timeout_secs: 5400` in seed/crons.json) and you MUST stay alive until every launched run reaches a terminal state. Do not exit early while a launched run is still `running` — your death is what kills it.
@@ -300,13 +300,17 @@ var TOOL_CHECK_PREAMBLE =
300
300
  "Then do the assignment below.\n\n";
301
301
  function buildTransportRetryTrailer(stepName, repoPath, taskId, attempt, reason) {
302
302
  // reason: "discarded" (the runtime threw the output away — the JSON-candidate
303
- // scan found {...}-shaped fragments it could not parse), "empty" (agent() returned without throwing but produced
304
- // nothing usable), "no-tools" (the worker's TOOL CHECK reported
305
- // artifact_tools: missing), or "no-transport" (the worker's TOOL CHECK
306
- // reported shell_transport: unavailable).
303
+ // scan found {...}-shaped fragments it could not parse), "errored" (the
304
+ // attempt errored — not a brace discard), "empty" (agent() returned
305
+ // without throwing but produced nothing usable), "no-tools" (the
306
+ // worker's TOOL CHECK reported artifact_tools: missing), or
307
+ // "no-transport" (the worker's TOOL CHECK reported shell_transport:
308
+ // unavailable).
307
309
  // The trailer tells the retry what to expect, not just to try again.
308
310
  var why = reason === "empty"
309
311
  ? "your previous attempt returned no usable output"
312
+ : reason === "errored"
313
+ ? "your previous attempt errored before producing a usable report (not a brace discard — diagnose the error and report the true state in plain prose)"
310
314
  : reason === "no-tools"
311
315
  ? "your previous attempt's TOOL CHECK reported artifact_tools: missing (this is a fresh launch, so run the TOOL CHECK's load step again before the work)"
312
316
  : reason === "no-transport"
@@ -474,12 +478,11 @@ function describeWorkAgentFailure(stepName, identity, attempts) {
474
478
  // Honest classification of a work-agent call that yielded no usable
475
479
  // report, with the per-attempt evidence preserved in the session notes.
476
480
  // Two distinct cases:
477
- // - agent() THREW: the runtime discarded output it could not machine-read
478
- // (e.g. prose tripping the JSON-candidate heuristic). The raw output is
479
- // gone — the workflow never received it — so the surviving error text is
480
- // recorded here instead. (An earlier comment claimed the raw output was
481
- // "preserved in the run record"; that was false — nothing preserved it —
482
- // and the claim is removed.)
481
+ // - agent() THREW: either the runtime discarded output it could not
482
+ // machine-read (prose tripping the JSON-candidate heuristic), or the
483
+ // agent errored before producing a report. The raw output is gone —
484
+ // the workflow never received it — so the surviving error text is
485
+ // recorded here instead.
483
486
  // - agent() RETURNED EMPTY without throwing: the child produced nothing
484
487
  // usable. Each attempt's outcome is the evidence.
485
488
  // attempts: [{threw, error, outcome}, ...], in order.
@@ -943,7 +946,7 @@ let alreadyMergedSha = null;
943
946
  // verified sha and no `repo_diff: none` claim fails mechanically without
944
947
  // dispatching Cass; reviewer prose is never parsed for git identity.
945
948
  let branchState = null; // "has-work" | "already-merged:<sha>" | "empty-no-work"
946
- let buildClaimedNoDiff = false; // Build declared plain `repo_diff: none` (no repo change — runtime-state deliverable, or believed already-merged). Same-process Build report only; there is no cross-process hydration — an empty branch on a fresh dispatch with no task-attributed merge fails mechanically.
949
+ let buildClaimedNoDiff = false; // Build's `repo_diff: none` claim, from the same-process Build report only (no cross-process hydration).
947
950
  let versionTouched = false; // npm: branch changed package.json's `version` (mechanical git fact, blocker-34 follow-up)
948
951
  let branchStateErr = ""; // classifier failure detail (retry trailer + park note grounds)
949
952
  // Deterministic publish target — computed by the workflow (registry base +
@@ -2029,13 +2032,10 @@ while (i < STEPS.length) {
2029
2032
  // state agent steps were deleted — their outputs fed only the
2030
2033
  // retired content-verdict/receipt-attribution machinery, and the
2031
2034
  // toolcheck ran in a workflow child that cannot reach artifact_edit.
2032
- // Issuance belongs to the session-carrying tick worker, which
2033
- // handles tool-unavailability as inconclusive per the tick protocol.)
2034
- // Publish at intent. Everything above — preflight, provenance base,
2035
- // checksummed diff, toolcheck, pre-trigger baselines — is read-only.
2036
2035
  // Issuance belongs to the session-carrying tick worker
2037
2036
  // (scan-publish-intent's intent bucket), the only caller class that
2038
- // can reach artifact_edit. This entry is issuer-written BY THE
2037
+ // can reach artifact_edit; it handles tool-unavailability as
2038
+ // inconclusive per the tick protocol. This entry is issuer-written BY THE
2039
2039
  // WORKFLOW and names the intent: outcome "publish-intent", never
2040
2040
  // "submitted" — the workflow issued nothing. The worker claims it
2041
2041
  // via the park note below.
@@ -2341,7 +2341,7 @@ while (i < STEPS.length) {
2341
2341
  for (var workAttempt = 0; workAttempt <= 2 && !mechanicalReviewFail; workAttempt++) {
2342
2342
  var workKey = workAttempt === 0 ? workKeyBase : workRetryKey(step.name, (totalReworkCount > 0 ? "-r" + totalReworkCount : ""), workAttempt);
2343
2343
  var prevAttempt = workAttempt === 0 ? null : workAttempts[workAttempt - 1];
2344
- var retryReason = workAttempt === 0 ? null : (prevAttempt.threw ? "discarded" : (prevAttempt.outcome === "missing-artifact-tools" ? "no-tools" : (prevAttempt.outcome === "unavailable-shell-transport" ? "no-transport" : "empty")));
2344
+ var retryReason = workAttempt === 0 ? null : (prevAttempt.threw ? (/output was not JSON/.test(prevAttempt.error || "") ? "discarded" : "errored") : (prevAttempt.outcome === "missing-artifact-tools" ? "no-tools" : (prevAttempt.outcome === "unavailable-shell-transport" ? "no-transport" : "empty")));
2345
2345
  try {
2346
2346
  workerResult = await agent(
2347
2347
  workPromptBase + (workAttempt === 0 ? "" : buildTransportRetryTrailer(step.name, REPO_PATH, taskId, workAttempt, retryReason)),
@@ -310,13 +310,17 @@ var TOOL_CHECK_PREAMBLE =
310
310
  "Then do the assignment below.\n\n";
311
311
  function buildTransportRetryTrailer(stepName, repoPath, taskId, attempt, reason) {
312
312
  // reason: "discarded" (the runtime threw the output away — the JSON-candidate
313
- // scan found {...}-shaped fragments it could not parse), "empty" (agent() returned without throwing but produced
314
- // nothing usable), "no-tools" (the worker's TOOL CHECK reported
315
- // artifact_tools: missing), or "no-transport" (the worker's TOOL CHECK
316
- // reported shell_transport: unavailable).
313
+ // scan found {...}-shaped fragments it could not parse), "errored" (the
314
+ // attempt errored — not a brace discard), "empty" (agent() returned
315
+ // without throwing but produced nothing usable), "no-tools" (the
316
+ // worker's TOOL CHECK reported artifact_tools: missing), or
317
+ // "no-transport" (the worker's TOOL CHECK reported shell_transport:
318
+ // unavailable).
317
319
  // The trailer tells the retry what to expect, not just to try again.
318
320
  var why = reason === "empty"
319
321
  ? "your previous attempt returned no usable output"
322
+ : reason === "errored"
323
+ ? "your previous attempt errored before producing a usable report (not a brace discard — diagnose the error and report the true state in plain prose)"
320
324
  : reason === "no-tools"
321
325
  ? "your previous attempt's TOOL CHECK reported artifact_tools: missing (this is a fresh launch, so run the TOOL CHECK's load step again before the work)"
322
326
  : reason === "no-transport"
@@ -484,12 +488,11 @@ function describeWorkAgentFailure(stepName, identity, attempts) {
484
488
  // Honest classification of a work-agent call that yielded no usable
485
489
  // report, with the per-attempt evidence preserved in the session notes.
486
490
  // Two distinct cases:
487
- // - agent() THREW: the runtime discarded output it could not machine-read
488
- // (e.g. prose tripping the JSON-candidate heuristic). The raw output is
489
- // gone — the workflow never received it — so the surviving error text is
490
- // recorded here instead. (An earlier comment claimed the raw output was
491
- // "preserved in the run record"; that was false — nothing preserved it —
492
- // and the claim is removed.)
491
+ // - agent() THREW: either the runtime discarded output it could not
492
+ // machine-read (prose tripping the JSON-candidate heuristic), or the
493
+ // agent errored before producing a report. The raw output is gone —
494
+ // the workflow never received it — so the surviving error text is
495
+ // recorded here instead.
493
496
  // - agent() RETURNED EMPTY without throwing: the child produced nothing
494
497
  // usable. Each attempt's outcome is the evidence.
495
498
  // attempts: [{threw, error, outcome}, ...], in order.
@@ -1823,13 +1826,10 @@ while (i < STEPS.length) {
1823
1826
  // state agent steps were deleted — their outputs fed only the
1824
1827
  // retired content-verdict/receipt-attribution machinery, and the
1825
1828
  // toolcheck ran in a workflow child that cannot reach artifact_edit.
1826
- // Issuance belongs to the session-carrying tick worker, which
1827
- // handles tool-unavailability as inconclusive per the tick protocol.)
1828
- // Publish at intent. Everything above — preflight, provenance base,
1829
- // checksummed diff, toolcheck, pre-trigger baselines — is read-only.
1830
1829
  // Issuance belongs to the session-carrying tick worker
1831
1830
  // (scan-publish-intent's intent bucket), the only caller class that
1832
- // can reach artifact_edit. This entry is issuer-written BY THE
1831
+ // can reach artifact_edit; it handles tool-unavailability as
1832
+ // inconclusive per the tick protocol. This entry is issuer-written BY THE
1833
1833
  // WORKFLOW and names the intent: outcome "publish-intent", never
1834
1834
  // "submitted" — the workflow issued nothing. The worker claims it
1835
1835
  // via the park note below.
@@ -1999,7 +1999,7 @@ while (i < STEPS.length) {
1999
1999
  for (var workAttempt = 0; workAttempt <= 2 && !mechanicalReviewFail; workAttempt++) {
2000
2000
  var workKey = workAttempt === 0 ? workKeyBase : workRetryKey(step.name, (reworkCount > 0 ? "-r" + reworkCount : ""), workAttempt);
2001
2001
  var prevAttempt = workAttempt === 0 ? null : workAttempts[workAttempt - 1];
2002
- var retryReason = workAttempt === 0 ? null : (prevAttempt.threw ? "discarded" : (prevAttempt.outcome === "missing-artifact-tools" ? "no-tools" : (prevAttempt.outcome === "unavailable-shell-transport" ? "no-transport" : "empty")));
2002
+ var retryReason = workAttempt === 0 ? null : (prevAttempt.threw ? (/output was not JSON/.test(prevAttempt.error || "") ? "discarded" : "errored") : (prevAttempt.outcome === "missing-artifact-tools" ? "no-tools" : (prevAttempt.outcome === "unavailable-shell-transport" ? "no-transport" : "empty")));
2003
2003
  try {
2004
2004
  workerResult = await agent(
2005
2005
  workPromptBase + (workAttempt === 0 ? "" : buildTransportRetryTrailer(step.name, REPO_PATH, taskId, workAttempt, retryReason)),
package/workflows/docs.js CHANGED
@@ -329,13 +329,17 @@ while (i < STEPS.length) {
329
329
  "Then do the assignment below.\n\n";
330
330
  function buildTransportRetryTrailer(stepName, repoPath, taskId, attempt, reason) {
331
331
  // reason: "discarded" (the runtime threw the output away — the JSON-candidate
332
- // scan found {...}-shaped fragments it could not parse), "empty" (agent() returned without throwing but produced
333
- // nothing usable), "no-tools" (the worker's TOOL CHECK reported
334
- // artifact_tools: missing), or "no-transport" (the worker's TOOL CHECK
335
- // reported shell_transport: unavailable).
332
+ // scan found {...}-shaped fragments it could not parse), "errored" (the
333
+ // attempt errored — not a brace discard), "empty" (agent() returned
334
+ // without throwing but produced nothing usable), "no-tools" (the
335
+ // worker's TOOL CHECK reported artifact_tools: missing), or
336
+ // "no-transport" (the worker's TOOL CHECK reported shell_transport:
337
+ // unavailable).
336
338
  // The trailer tells the retry what to expect, not just to try again.
337
339
  var why = reason === "empty"
338
340
  ? "your previous attempt returned no usable output"
341
+ : reason === "errored"
342
+ ? "your previous attempt errored before producing a usable report (not a brace discard — diagnose the error and report the true state in plain prose)"
339
343
  : reason === "no-tools"
340
344
  ? "your previous attempt's TOOL CHECK reported artifact_tools: missing (this is a fresh launch, so run the TOOL CHECK's load step again before the work)"
341
345
  : reason === "no-transport"
@@ -367,12 +371,11 @@ function describeWorkAgentFailure(stepName, identity, attempts) {
367
371
  // Honest classification of a work-agent call that yielded no usable
368
372
  // report, with the per-attempt evidence preserved in the session notes.
369
373
  // Two distinct cases:
370
- // - agent() THREW: the runtime discarded output it could not machine-read
371
- // (e.g. prose tripping the JSON-candidate heuristic). The raw output is
372
- // gone — the workflow never received it — so the surviving error text is
373
- // recorded here instead. (An earlier comment claimed the raw output was
374
- // "preserved in the run record"; that was false — nothing preserved it —
375
- // and the claim is removed.)
374
+ // - agent() THREW: either the runtime discarded output it could not
375
+ // machine-read (prose tripping the JSON-candidate heuristic), or the
376
+ // agent errored before producing a report. The raw output is gone —
377
+ // the workflow never received it — so the surviving error text is
378
+ // recorded here instead.
376
379
  // - agent() RETURNED EMPTY without throwing: the child produced nothing
377
380
  // usable. Each attempt's outcome is the evidence.
378
381
  // attempts: [{threw, error, outcome}, ...], in order.
@@ -450,7 +453,7 @@ function describeWorkAgentFailure(stepName, identity, attempts) {
450
453
  for (var workAttempt = 0; workAttempt <= 2; workAttempt++) {
451
454
  var workKey = workAttempt === 0 ? workKeyBase : workRetryKey(step.name, (reworkCount > 0 ? "-r" + reworkCount : ""), workAttempt);
452
455
  var prevAttempt = workAttempt === 0 ? null : workAttempts[workAttempt - 1];
453
- var retryReason = workAttempt === 0 ? null : (prevAttempt.threw ? "discarded" : (prevAttempt.outcome === "missing-artifact-tools" ? "no-tools" : (prevAttempt.outcome === "unavailable-shell-transport" ? "no-transport" : "empty")));
456
+ var retryReason = workAttempt === 0 ? null : (prevAttempt.threw ? (/output was not JSON/.test(prevAttempt.error || "") ? "discarded" : "errored") : (prevAttempt.outcome === "missing-artifact-tools" ? "no-tools" : (prevAttempt.outcome === "unavailable-shell-transport" ? "no-transport" : "empty")));
454
457
  try {
455
458
  workerResult = await agent(
456
459
  workPromptBase + (workAttempt === 0 ? "" : buildTransportRetryTrailer(step.name, REPO_PATH, taskId, workAttempt, retryReason)),