@worca/app 1.0.0 → 1.2.0-rc.1

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 (143) hide show
  1. package/README.md +30 -9
  2. package/agents/clarify.meta.json +4 -4
  3. package/agents/decomposer.meta.json +5 -5
  4. package/agents/implementer.meta.json +15 -5
  5. package/agents/manualTestsChecklist.meta.json +5 -4
  6. package/agents/manualWebUiTesting.meta.json +9 -4
  7. package/agents/planReviewer.meta.json +12 -4
  8. package/agents/planner.meta.json +12 -5
  9. package/agents/refiner.meta.json +15 -4
  10. package/agents/reviewer.meta.json +14 -4
  11. package/agents/worca-cc-clarify.md +7 -0
  12. package/agents/worca-cc-code-reviewer.md +11 -6
  13. package/agents/worca-cc-decomposer.md +7 -0
  14. package/agents/worca-cc-implementer.md +9 -0
  15. package/agents/worca-cc-manual-tests-checklist.md +8 -5
  16. package/agents/worca-cc-manual-web-ui-testing.md +10 -6
  17. package/agents/worca-cc-plan-refiner.md +11 -6
  18. package/agents/worca-cc-plan-reviewer.md +10 -7
  19. package/agents/worca-cc-planner.md +9 -0
  20. package/agents/worca-cc-workspace-reviewer.md +11 -4
  21. package/agents/worca-cc-workspace-scanner.md +8 -4
  22. package/agents/workspaceReviewer.meta.json +15 -4
  23. package/agents/workspaceScanner.meta.json +5 -4
  24. package/package.json +8 -2
  25. package/skills/worca/SKILL.md +5 -5
  26. package/src/cli/render.mjs +148 -0
  27. package/src/cli/worca-cc.mjs +386 -56
  28. package/src/core/agent-gen.mjs +69 -31
  29. package/src/core/agent-registry.mjs +124 -144
  30. package/src/core/agent-store.mjs +164 -4
  31. package/src/core/artifacts.mjs +199 -23
  32. package/src/core/ask/attachment-kind.mjs +95 -0
  33. package/src/core/ask/catalog.mjs +111 -0
  34. package/src/core/ask/comment-deps.mjs +55 -0
  35. package/src/core/ask/events.mjs +545 -0
  36. package/src/core/ask/follow.mjs +113 -0
  37. package/src/core/ask/git-allowlist.mjs +226 -0
  38. package/src/core/ask/limits.mjs +57 -0
  39. package/src/core/ask/mcp-stdio.mjs +135 -0
  40. package/src/core/ask/models.mjs +125 -0
  41. package/src/core/ask/prompt.mjs +286 -0
  42. package/src/core/ask/proposal.mjs +170 -0
  43. package/src/core/ask/redact.mjs +30 -0
  44. package/src/core/ask/spawn.mjs +156 -0
  45. package/src/core/ask/store.mjs +438 -0
  46. package/src/core/ask/tool-deps.mjs +87 -0
  47. package/src/core/ask/tools.mjs +879 -0
  48. package/src/core/ask/turn.mjs +462 -0
  49. package/src/core/ask/worktree-deps.mjs +27 -0
  50. package/src/core/ask/worktrees.mjs +285 -0
  51. package/src/core/chat/command-router.mjs +28 -7
  52. package/src/core/chat/notifier.mjs +6 -1
  53. package/src/core/chat/renderers.mjs +15 -8
  54. package/src/core/claude-runner.mjs +541 -62
  55. package/src/core/config.mjs +310 -44
  56. package/src/core/cost-budget.mjs +29 -2
  57. package/src/core/db.mjs +773 -53
  58. package/src/core/diff-anchor.mjs +213 -0
  59. package/src/core/diff-comments.mjs +273 -0
  60. package/src/core/engine-select.mjs +32 -0
  61. package/src/core/failure-policy.mjs +201 -0
  62. package/src/core/git-info.mjs +49 -10
  63. package/src/core/graph/builtin-workflows.mjs +51 -0
  64. package/src/core/graph/executor.mjs +894 -0
  65. package/src/core/graph/registry-ports.mjs +12 -0
  66. package/src/core/graph/scheduler.mjs +1072 -0
  67. package/src/core/graph/seed-templates.mjs +318 -0
  68. package/src/core/host-guard.mjs +271 -0
  69. package/src/core/model-env.mjs +180 -8
  70. package/src/core/model-test.mjs +79 -0
  71. package/src/core/orchestrator.mjs +994 -4097
  72. package/src/core/overview-agent.mjs +15 -3
  73. package/src/core/phases.mjs +208 -537
  74. package/src/core/pipeline-delete.mjs +13 -2
  75. package/src/core/plugin-api.mjs +8 -3
  76. package/src/core/plugin-config.mjs +178 -28
  77. package/src/core/plugin-inventory.mjs +6 -2
  78. package/src/core/plugin-manifest.mjs +199 -11
  79. package/src/core/plugin-models.mjs +1 -0
  80. package/src/core/plugin-repo.mjs +16 -4
  81. package/src/core/plugin-shim-child.mjs +9 -3
  82. package/src/core/plugin-shim.mjs +80 -17
  83. package/src/core/plugin-store.mjs +236 -29
  84. package/src/core/plugin-workflows.mjs +90 -41
  85. package/src/core/preflight.mjs +135 -3
  86. package/src/core/projects.mjs +7 -5
  87. package/src/core/protocol.mjs +8 -35
  88. package/src/core/recoverable-error.mjs +1 -1
  89. package/src/core/run-harness.mjs +3934 -0
  90. package/src/core/run-manifest.mjs +5 -1
  91. package/src/core/settings.mjs +184 -13
  92. package/src/core/skills.mjs +10 -3
  93. package/src/core/source-bindings.mjs +175 -0
  94. package/src/core/sources.mjs +87 -25
  95. package/src/core/stats.mjs +25 -6
  96. package/src/core/title.mjs +51 -4
  97. package/src/core/workflows.mjs +358 -259
  98. package/src/core/workspace-scan.mjs +4 -0
  99. package/src/core/worktree.mjs +98 -7
  100. package/src/shared/graph/agent-meta.mjs +278 -0
  101. package/src/shared/graph/constants.mjs +105 -0
  102. package/src/shared/graph/geometry.mjs +157 -0
  103. package/src/shared/graph/layout.mjs +134 -0
  104. package/src/shared/graph/loops.mjs +130 -0
  105. package/src/shared/graph/manifest.mjs +257 -0
  106. package/src/shared/graph/ports.mjs +153 -0
  107. package/src/shared/graph/route.mjs +397 -0
  108. package/src/shared/graph/template.mjs +165 -0
  109. package/src/shared/graph/thumbnail.mjs +67 -0
  110. package/src/shared/graph/validate.mjs +491 -0
  111. package/src/shared/graph/verdict.mjs +41 -0
  112. package/ui/public/app.js +4240 -1682
  113. package/ui/public/ask-markdown.mjs +145 -0
  114. package/ui/public/ask-model.mjs +317 -0
  115. package/ui/public/ask-panel.mjs +2129 -0
  116. package/ui/public/chat-settings-view.mjs +6 -2
  117. package/ui/public/diff-view.mjs +66 -11
  118. package/ui/public/file-tree.mjs +305 -0
  119. package/ui/public/graph/composer.mjs +889 -0
  120. package/ui/public/graph/inspector.mjs +183 -0
  121. package/ui/public/graph/model.mjs +37 -0
  122. package/ui/public/graph/palette.mjs +144 -0
  123. package/ui/public/graph/run-decor.mjs +410 -0
  124. package/ui/public/graph/run-hosts.mjs +201 -0
  125. package/ui/public/graph/save-dialog.mjs +56 -0
  126. package/ui/public/graph/view.mjs +858 -0
  127. package/ui/public/guardrails-view.mjs +4 -2
  128. package/ui/public/hljs-loader.mjs +180 -0
  129. package/ui/public/index.html +311 -265
  130. package/ui/public/log-filter.mjs +22 -4
  131. package/ui/public/log-line.mjs +45 -19
  132. package/ui/public/models-view.mjs +171 -9
  133. package/ui/public/plugins-view.mjs +106 -4
  134. package/ui/public/source-pane.mjs +190 -8
  135. package/ui/public/stats-view.mjs +81 -1
  136. package/ui/public/style.css +1487 -229
  137. package/ui/public/syntax-highlight.mjs +270 -0
  138. package/ui/public/thinking-orb.mjs +110 -0
  139. package/ui/server.mjs +1894 -104
  140. package/src/core/channels.mjs +0 -302
  141. package/src/core/runners.mjs +0 -167
  142. package/src/core/workflow-validator.mjs +0 -185
  143. package/ui/public/composer-core.mjs +0 -211
@@ -0,0 +1,3934 @@
1
+ // src/core/run-harness.mjs
2
+ // The engine-agnostic run harness: everything a pipeline run needs regardless of
3
+ // which engine sequences the work — construction, the run()/resume() shells,
4
+ // run root + worktrees, guardrails, run context, cost limits, recovery, user
5
+ // asks, git checkpoints, results, the step ledger + clocks, logs, artifacts,
6
+ // events, persistence and the heartbeat.
7
+ //
8
+ // Engines subclass it and implement six hooks (bottom of the class):
9
+ // _resolveTopology, _engineRun, _enginePrePausePoint, _engineRehydrate,
10
+ // _bookend (implemented here), _initRunners (no-op here). The v1 engine is
11
+ // src/core/orchestrator.mjs (class Orchestrator extends RunHarness).
12
+ //
13
+ // It is an EventEmitter. Consumers (CLI, UI) subscribe to events and drive
14
+ // interaction via answer()/stop().
15
+
16
+ import { EventEmitter } from 'node:events';
17
+ import { spawn } from 'node:child_process';
18
+ import { homedir } from 'node:os';
19
+ import { fileURLToPath } from 'node:url';
20
+ import { join, basename, resolve, sep, relative } from 'node:path';
21
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
22
+ import { readFile, writeFile, readdir, mkdir, realpath } from 'node:fs/promises';
23
+
24
+ import { generateTitle } from './title.mjs';
25
+ import {
26
+ createPipeline, updatePipelineTitle, appendAudit, writeState, artifactPaths, slugify, today,
27
+ recordArtifact, writeClarify, readPipelineExtras, claimPipelineOwnership, touchHeartbeat,
28
+ clearPipelineOwnership, HEARTBEAT_INTERVAL_MS, upsertSubAgent,
29
+ } from './artifacts.mjs';
30
+ import { diffNameStatus, diffNumstat, diffPatch } from './git-info.mjs';
31
+ import {
32
+ assembleResults, persistResults, persistDiffPatch, buildPerProject, rollupSummary,
33
+ retainedWorkPatchName,
34
+ } from './results.mjs';
35
+ import { resolveTaskInput, retryWriteback } from './sources.mjs';
36
+ import { projectKey, projectStorePath, workspaceStorePath } from './store.mjs';
37
+ import { worcaHome } from './projects.mjs';
38
+ import {
39
+ runRootMode, getProjectsRoot,
40
+ pipelineCostLimitUsd, totalCostLimitUsd, costLimitResetPeriod,
41
+ } from './settings.mjs';
42
+ import { readCostCapOverride, totalWindowSpendUsd, costWindowStart, recordCostDelta } from './cost-budget.mjs';
43
+ import {
44
+ writeRunManifest, readRunManifest, updateRunManifest, rmGuarded, rescueModifiedMounts,
45
+ scanStrayEntries, copyRunManifestTo, removeInjectedPaths, stripClaudeMdFence,
46
+ RETAIN_REASONS,
47
+ } from './run-manifest.mjs';
48
+ import { assembleRunContext, renderContextAudit, MCP_GRANT_MODE } from './run-context.mjs';
49
+ import { createRunLogWriter, RUN_LOG_FILE, RUN_LOG_KIND } from './run-log.mjs';
50
+ import {
51
+ detectTools, detectToolsPerProject, runGraphifyUpdate, worktreeGraphInstruction,
52
+ probeClaudeCapabilities, explainUnspawnableClaude,
53
+ } from './preflight.mjs';
54
+ import { fanoutCap, mapWithCap } from './fanout.mjs';
55
+ import { resolveStepModels, observeModelCost, resolveModelCost, modelCostConfig } from './config.mjs';
56
+ import { readGuardrailSet } from './guardrail-store.mjs';
57
+ import { unionGuardrails, guardrailsToPermissionRules, mergePermissionRules } from './guardrails.mjs';
58
+ import { collectRequiredSkills, validateSkills, injectSkills, pluginSkillDirs } from './skills.mjs';
59
+ import { loadAgentRegistry, DEFAULT_AGENTS_DIR } from './agent-registry.mjs';
60
+ import {
61
+ createWorktree, removeWorktree, suggestBranchName, sanitizeBranchName, resolveDefaultBranch,
62
+ isValidSourceRef, snapshotWorktreePatch,
63
+ } from './worktree.mjs';
64
+ import { readPluginsLock, pluginCurrentDir } from './plugins-lock.mjs'; // §9.4 disabled-plugin hint
65
+ import { classifyError } from './recoverable-error.mjs';
66
+ import {
67
+ resolveFailure, isTerminal, markTerminal, answerFromDecision,
68
+ REASON, pauseConsequences, describePauseReason,
69
+ } from './failure-policy.mjs';
70
+
71
+ // worca-cc repo root; holds skills/. fileURLToPath, never URL.pathname: the
72
+ // latter is `/C:/…` on Windows and %-encoded everywhere (see DEFAULT_AGENTS_DIR
73
+ // in agent-registry.mjs, which is the single source for the built-in agents dir).
74
+ const REPO_ROOT = fileURLToPath(new URL('../../', import.meta.url));
75
+
76
+ /**
77
+ * §9.4 message enrichment: does a DISABLED plugin ship this agent key? Scans
78
+ * lock entries with enabled === false, reading key fields from each plugin's
79
+ * current/agents/*.meta.json. Returns the plugin name or null. try/catch
80
+ * throughout: no resolvable home / no lock / broken current => null (callers
81
+ * fall back to the generic "not installed" message).
82
+ * @param {string} key
83
+ * @returns {string|null}
84
+ */
85
+ function findDisabledPluginFor(key) {
86
+ try {
87
+ const lock = readPluginsLock();
88
+ for (const name of Object.keys(lock).sort()) {
89
+ if (!lock[name] || lock[name].enabled !== false) continue;
90
+ const dir = join(pluginCurrentDir(name), 'agents');
91
+ let files;
92
+ try { files = readdirSync(dir); } catch { continue; }
93
+ for (const f of files) {
94
+ if (!f.endsWith('.meta.json')) continue;
95
+ try {
96
+ if (JSON.parse(readFileSync(join(dir, f), 'utf8'))?.key === key) return name;
97
+ } catch { /* malformed sidecar: skip */ }
98
+ }
99
+ }
100
+ } catch { /* no home / unreadable lock */ }
101
+ return null;
102
+ }
103
+
104
+ /**
105
+ * `attr` marking a log line whose text came from a subprocess's stderr.
106
+ *
107
+ * ONE convention for every subprocess worca spawns — the agent CLI (framed
108
+ * line-by-line by claude-runner), git (`_git`), and graphify. It records the
109
+ * origin CHANNEL, never the severity: each call site keeps the level it already
110
+ * had, because these git/graphify lines are worca's own summaries of a failure,
111
+ * not raw stderr echoes. Frozen and shared: `_log` only reads from `attr`.
112
+ */
113
+ export const ERR_STREAM = Object.freeze({ stream: 'err' });
114
+
115
+ /** The `: <stderr>` suffix for a failed subprocess result, or '' when it said
116
+ * nothing. runGraphifyUpdate already returns its child's stderr and the log
117
+ * line used to drop it — tagging a line as stderr-derived while discarding the
118
+ * stderr would make the tag a lie. Clipped: a build failure can be verbose. */
119
+ function errDetail(res, max = 200) {
120
+ const text = (res?.stderr || '').trim().replace(/\s+/g, ' ');
121
+ return text ? `: ${clip(text, max)}` : '';
122
+ }
123
+
124
+ /** attr for a log line whose text embeds subprocess output: ERR_STREAM only
125
+ * when the subprocess actually said something on stderr. A `|| 'exit N'`
126
+ * fallback carries no stderr bytes — tagging it would make the tag a lie
127
+ * (the same rule errDetail documents for the text itself). */
128
+ export function errStreamAttr(stderrText, extra = null) {
129
+ if (!(stderrText && String(stderrText).trim())) return extra;
130
+ return extra ? { ...extra, ...ERR_STREAM } : ERR_STREAM;
131
+ }
132
+
133
+ /** Round a USD amount to 4 decimals (tenth-of-a-cent) to avoid float drift. */
134
+ export function roundUsd(n) {
135
+ return Math.round((Number(n) || 0) * 1e4) / 1e4;
136
+ }
137
+
138
+ /**
139
+ * Sum per-step costUsd into the pipeline total, rounded ONCE so the total is
140
+ * exactly Σ steps (avoids the drift of independently rounding a separate running
141
+ * total on every add). Absent/NaN step costs are ignored.
142
+ * @param {Array<{costUsd?:number}>} steps
143
+ * @returns {number}
144
+ */
145
+ export function sumStepCosts(steps) {
146
+ let sum = 0;
147
+ for (const s of Array.isArray(steps) ? steps : []) {
148
+ if (Number.isFinite(s?.costUsd)) sum += s.costUsd;
149
+ }
150
+ return roundUsd(sum);
151
+ }
152
+
153
+ /**
154
+ * Sum per-step active processing time (ms) into the pipeline total. Only the
155
+ * FINALIZED activeMs is summed here; a still-running step's tail is added live
156
+ * by consumers (liveActiveMs / the UI). Absent/NaN values are ignored. No
157
+ * rounding (durations are integer ms).
158
+ * @param {Array<{activeMs?:number}>} steps
159
+ * @returns {number}
160
+ */
161
+ export function sumStepActive(steps) {
162
+ let sum = 0;
163
+ for (const s of Array.isArray(steps) ? steps : []) {
164
+ if (Number.isFinite(s?.activeMs)) sum += s.activeMs;
165
+ }
166
+ return sum;
167
+ }
168
+
169
+ export function isAbort(err) {
170
+ // NAME only. Every abort/stop throw in this codebase stamps name='AbortError'
171
+ // (see stop()/_checkAbort/claude-runner); sniffing the message here also
172
+ // matched real CLI failures containing "aborted"/"stopped" and swallowed
173
+ // their terminal error line, recovery, and decomposed failure detection.
174
+ return !!err && err.name === 'AbortError';
175
+ }
176
+
177
+ /** Pause sentinel: thrown to unwind _dispatch when pause() was requested. */
178
+ export function pauseErr() {
179
+ const e = new Error('paused');
180
+ e.name = 'PauseError';
181
+ return e;
182
+ }
183
+
184
+ export function isPause(err) {
185
+ return !!err && err.name === 'PauseError';
186
+ }
187
+
188
+ /** Fail-safe JSON.parse for nullable DB text columns; null on absent/bad JSON. */
189
+ export function safeParse(text) {
190
+ try {
191
+ return text ? JSON.parse(text) : null;
192
+ } catch {
193
+ return null;
194
+ }
195
+ }
196
+
197
+ export function firstLine(text) {
198
+ if (!text) return '';
199
+ for (const line of String(text).split(/\r?\n/)) {
200
+ const t = line.replace(/^#+\s*/, '').trim();
201
+ if (t) return t;
202
+ }
203
+ return '';
204
+ }
205
+
206
+ export function rel(base, p) {
207
+ if (!p) return '';
208
+ const b = resolve(base);
209
+ const full = resolve(p);
210
+ // Native separator: resolve() yields backslashes on Windows, where a '/'
211
+ // comparison never matched and every tool-call log line carried the full path.
212
+ return full.startsWith(b + sep) ? full.slice(b.length + 1) : full;
213
+ }
214
+
215
+ /** Collapse whitespace and truncate to n chars with an ellipsis. */
216
+ export function clip(text, n) {
217
+ if (!text) return '';
218
+ const s = String(text).replace(/\s+/g, ' ').trim();
219
+ return s.length > n ? s.slice(0, n - 1) + '…' : s;
220
+ }
221
+
222
+ // ── shared pure helpers (agent-event/telemetry block) ─────────────────────────
223
+
224
+ export function numOr(v, d) {
225
+ const n = Number(v);
226
+ return Number.isFinite(n) && n > 0 ? n : d;
227
+ }
228
+
229
+ /** JSON round-trip clone; drops functions/undefined. Bus channels and resolved
230
+ * plan nodes are plain data, so this is lossless for them. */
231
+ export function jsonClone(v) {
232
+ return v == null ? null : JSON.parse(JSON.stringify(v));
233
+ }
234
+
235
+ /**
236
+ * Describe the tool calls in a stream-json `assistant` event as readable
237
+ * one-liners (e.g. `Read src/app.js`, `Bash npm test`). Returns [] for events
238
+ * with no tool_use blocks — tool_result echoes, the system init event — so the
239
+ * caller drops them instead of logging a contentless envelope type.
240
+ */
241
+ // Max chars for the sub-agent label inside the "[role ▸ label]" tag. Deliberately
242
+ // shorter than toolTarget's 60-char Task clip: that 60 governs the parent's own
243
+ // "→ Task <desc>" debug line, which has a whole row to itself; this 40 governs the
244
+ // label embedded inside "[role ▸ label]", which shares a single flex row (web) and
245
+ // sits inline in the terminal, so it must stay compact. The two clips are
246
+ // independent on purpose — a long description may render at ≤60 on the parent line
247
+ // and ≤40 inside the child tag.
248
+ const SUBAGENT_LABEL_MAX = 40;
249
+
250
+ /**
251
+ * Which model id prices a sub-agent. The child runs inside the parent node's CLI
252
+ * invocation — same endpoint, same price — so the parent's dispatched model is the
253
+ * default. A Task input MAY name its own model; that only changes the price when
254
+ * the named model carries an explicit cost override of its own (a bare alias like
255
+ * 'haiku' resolves to nothing and must not drop the parent's override).
256
+ * @param {unknown} inputModel the Task/Agent tool_use input's `model`, if any
257
+ * @param {string|undefined} parentModel the parent node's dispatched model
258
+ * @returns {string|null}
259
+ */
260
+ function subAgentCostModel(inputModel, parentModel) {
261
+ const own = typeof inputModel === 'string' && inputModel.trim() ? inputModel.trim() : null;
262
+ try { if (own && modelCostConfig(own)) return own; } catch { /* catalog read is best-effort */ }
263
+ return parentModel ?? null;
264
+ }
265
+
266
+ /**
267
+ * Record id -> short description for every Task/Agent tool_use block in a
268
+ * MAIN-agent event, so a sub-agent's later events (which carry that id as
269
+ * parent_tool_use_id) can be labeled by the job they were given. Safe when
270
+ * `raw` is a string (non-JSON runner line): raw?.message?.content is undefined.
271
+ */
272
+ function registerSubAgents(raw, labels) {
273
+ const content = raw?.message?.content;
274
+ if (!Array.isArray(content)) return;
275
+ for (const c of content) {
276
+ if (c?.type === 'tool_use' && (c.name === 'Task' || c.name === 'Agent') && c.id && !labels.has(c.id)) {
277
+ const desc = clip(c.input?.description || c.input?.prompt, SUBAGENT_LABEL_MAX);
278
+ if (desc) labels.set(c.id, desc); // empty desc left unset → fallback assigns sub-agent-N
279
+ }
280
+ }
281
+ }
282
+
283
+ function describeToolUses(raw, projectDir) {
284
+ const content = raw?.message?.content;
285
+ if (!Array.isArray(content)) return [];
286
+ const calls = [];
287
+ for (const c of content) {
288
+ if (c?.type === 'tool_use' && typeof c.name === 'string') {
289
+ const target = toolTarget(c.name, c.input, projectDir);
290
+ calls.push(target ? `${c.name} ${target}` : c.name);
291
+ }
292
+ }
293
+ return calls;
294
+ }
295
+
296
+ /**
297
+ * Describe tool_result blocks in a stream-json event as short outcome one-liners
298
+ * (`result ok <id8>` / `result error <id8>`). Scans message.content for
299
+ * {type:'tool_result', tool_use_id, is_error?}. Returns [] when `raw` is a string
300
+ * (non-JSON runner line) or carries no tool_result blocks (assistant turns, the
301
+ * init event), so the caller adds no line. The 8-char tool_use_id prefix matches
302
+ * worca's contract and is enough to correlate a result with its call within one
303
+ * turn. Mirrors describeToolUses: the `← ` arrow prefix is added by the caller.
304
+ */
305
+ function describeToolResults(raw) {
306
+ const content = raw?.message?.content;
307
+ if (!Array.isArray(content)) return [];
308
+ const lines = [];
309
+ for (const b of content) {
310
+ if (b?.type !== 'tool_result') continue;
311
+ const id = typeof b.tool_use_id === 'string' ? b.tool_use_id.slice(0, 8) : '?';
312
+ lines.push(`result ${b.is_error ? 'error' : 'ok'} ${id}`);
313
+ }
314
+ return lines;
315
+ }
316
+
317
+ /** A short, human-readable target for a tool call (file, command, pattern…). */
318
+ function toolTarget(name, input, projectDir) {
319
+ if (!input || typeof input !== 'object') return '';
320
+ switch (name) {
321
+ case 'Read':
322
+ case 'Write':
323
+ case 'Edit':
324
+ case 'MultiEdit':
325
+ case 'NotebookEdit':
326
+ return rel(projectDir, input.file_path || input.path || input.notebook_path || '');
327
+ case 'Bash':
328
+ return clip(input.command, 80);
329
+ case 'Grep':
330
+ return input.pattern
331
+ ? `"${input.pattern}"${input.path ? ' ' + rel(projectDir, input.path) : ''}`
332
+ : '';
333
+ case 'Glob':
334
+ return input.pattern || '';
335
+ case 'Task':
336
+ case 'Agent':
337
+ return clip(input.description || input.prompt, 60);
338
+ case 'WebFetch':
339
+ case 'WebSearch':
340
+ return clip(input.url || input.query, 60);
341
+ default:
342
+ return '';
343
+ }
344
+ }
345
+
346
+ // ── Skill / MCP-tool capture (for the Sub-agents dropdown pills) ──────────────
347
+ // Pills surface ONLY named skills (the Skill tool) and MCP server tools
348
+ // (mcp__<server>__<tool>). Core file/bash/search/web tools and the sub-agent
349
+ // spawn tools (Task/Agent) are NOT skills. Labels are kind-tagged strings —
350
+ // "skill:<name>" / "mcp:<server>:<tool>" (or "mcp:<server>" when a name carries
351
+ // no tool token) — so the set dedups cleanly and the UI styles the kinds without
352
+ // a second field. Capped per agent, and the cap is SURFACED (see mergeSkills).
353
+ //
354
+ // §7.1: 64, raised from 24 because per-tool granularity multiplies distinct
355
+ // labels (one MCP server can contribute a dozen tools to one agent).
356
+ export const SKILLS_MAX = 64;
357
+
358
+ /** The overflow SENTINEL that makes the cap visible instead of silent: an
359
+ * `overflow:<n>` entry rides inside the same V6 `skills` array (zero schema
360
+ * change; to storage it is one more opaque string) and the UI renders it as a
361
+ * muted `+N more` pill. Because it rides the array it re-enters mergeSkills as
362
+ * part of `existing` on every later merge, so the merge is sentinel-aware. */
363
+ const OVERFLOW_RE = /^overflow:(\d+)$/;
364
+
365
+ /** Display server token for an MCP tool name `mcp__<server>__<tool>`: strip a
366
+ * leading `plugin_`, then collapse consecutive duplicate words. */
367
+ function mcpServerLabel(name) {
368
+ const parts = String(name).split('__');
369
+ let server = (parts[1] || '').trim();
370
+ if (!server) return '';
371
+ server = server.replace(/^plugin_/, '');
372
+ const words = server.split('_').filter(Boolean);
373
+ const collapsed = words.filter((w, i) => w !== words[i - 1]); // playwright_playwright -> playwright
374
+ return collapsed.join('_') || server;
375
+ }
376
+
377
+ /** Kind-tagged pill label for ONE tool_use block, or '' if it is not a skill /
378
+ * MCP tool. The Skill slug key is read defensively (the one stream-json detail
379
+ * not pinned by a fixture). */
380
+ export function skillLabel(name, input) {
381
+ if (typeof name !== 'string') return '';
382
+ if (name === 'Skill') {
383
+ const raw = input && typeof input === 'object'
384
+ ? (input.skill ?? input.name ?? input.command ?? input.skill_name) : '';
385
+ const slug = typeof raw === 'string' ? raw.trim() : '';
386
+ return slug ? `skill:${slug}` : '';
387
+ }
388
+ if (name.startsWith('mcp__')) {
389
+ // §7.1: keep the TOOL token — `mcp__<server>__<tool>` -> `mcp:<server>:<tool>`.
390
+ // A tool token may itself contain `__`, so rejoin everything past the server
391
+ // (`mcp__srv__deep__nested` -> tool `deep__nested`). §5.5's `__`-normalization
392
+ // is what keeps the server segment unambiguous for every merged server.
393
+ const server = mcpServerLabel(name);
394
+ if (!server) return '';
395
+ const tool = name.split('__').slice(2).join('__');
396
+ return tool ? `mcp:${server}:${tool}` : `mcp:${server}`; // legacy shape when no tool token
397
+ }
398
+ return ''; // Read/Write/Edit/Bash/Grep/Glob/Task/Agent/WebFetch/WebSearch/… excluded
399
+ }
400
+
401
+ /** All kind-tagged skill labels in ONE stream-json envelope (deduped within the
402
+ * turn, order-preserving). */
403
+ function extractSkillLabels(raw) {
404
+ const content = raw?.message?.content;
405
+ if (!Array.isArray(content)) return [];
406
+ const out = [];
407
+ const seen = new Set();
408
+ for (const c of content) {
409
+ if (c?.type !== 'tool_use') continue;
410
+ const label = skillLabel(c.name, c.input);
411
+ if (label && !seen.has(label)) { seen.add(label); out.push(label); }
412
+ }
413
+ return out;
414
+ }
415
+
416
+ // ── graphify CLI-invocation counter ──────────────────────────────────────────
417
+ // Counts how many times a Bash command INVOKES the `graphify` CLI, as opposed to
418
+ // merely mentioning the word (reading graphify-out/, grepping for "graphify", rm
419
+ // graphify-out). Match `graphify` only at a COMMAND position: string start, after a
420
+ // shell separator (; | & && || newline or subshell `(`), or after leading VAR=val
421
+ // env assignments — optionally path-prefixed (~/.local/bin/graphify) — and followed
422
+ // by whitespace or end-of-string, so `graphify-out` (next char `-`) never matches.
423
+ // Known gaps (rare; documented, not counted): `npx graphify`, `python -m graphify`,
424
+ // `sh -c "graphify …"` — graphify there is an argument, not the command word.
425
+ const GRAPHIFY_CMD_RE = /(?:^|[;&|\n(]|&&|\|\|)\s*(?:\w+=\S+\s+)*(?:[^\s;&|()]*\/)?graphify(?=\s|$)/g;
426
+
427
+ /** How many graphify CLI invocations the Bash tool_use blocks of ONE stream-json
428
+ * envelope contain (0 when none / not a tool turn). Pure + module-scoped. */
429
+ function countGraphifyBashCalls(raw) {
430
+ const content = raw?.message?.content;
431
+ if (!Array.isArray(content)) return 0;
432
+ let n = 0;
433
+ for (const c of content) {
434
+ if (c?.type !== 'tool_use' || c.name !== 'Bash') continue;
435
+ const cmd = c.input?.command;
436
+ if (typeof cmd !== 'string') continue;
437
+ const m = cmd.match(GRAPHIFY_CMD_RE);
438
+ if (m) n += m.length;
439
+ }
440
+ return n;
441
+ }
442
+
443
+ /**
444
+ * Union `incoming` into `existing` (order-preserving, deduped, capped) with the
445
+ * §7.1 overflow-sentinel semantics, so the cap is a SURFACED truncation and not a
446
+ * silent gap. Returns the NEW array when it grew, else null (caller skips
447
+ * persist/emit — unchanged contract).
448
+ *
449
+ * 1. STRIP every `overflow:<n>` from `existing`, remembering the largest `n` as a
450
+ * monotonic floor. Sentinels arriving in `incoming` (a snapshot-rebuild merge
451
+ * of two persisted arrays) are likewise never labels: skipped in the union,
452
+ * their `n` folded into the same floor.
453
+ * 2. UNION real labels, capping at SKILLS_MAX counting REAL labels only — the
454
+ * sentinel never consumes a cap slot. Count the DISTINCT incoming labels the
455
+ * cap rejected this merge.
456
+ * 3. overflow = floor + rejected; when > 0 append EXACTLY ONE sentinel, LAST.
457
+ * 4. "Grew" = more real labels than before, OR a larger overflow count.
458
+ */
459
+ export function mergeSkills(existing, incoming) {
460
+ const inc = Array.isArray(incoming) ? incoming : [];
461
+ if (!inc.length) return null;
462
+ const base = Array.isArray(existing) ? existing : [];
463
+
464
+ // (1) Strip `existing`'s sentinels; its largest n is the floor to carry forward.
465
+ const real = [];
466
+ let wasOverflow = 0; // what `existing` itself recorded (the growth baseline)
467
+ for (const x of base) {
468
+ const m = OVERFLOW_RE.exec(String(x));
469
+ if (m) { wasOverflow = Math.max(wasOverflow, Number(m[1])); continue; }
470
+ real.push(x);
471
+ }
472
+ let floor = wasOverflow;
473
+
474
+ // (2) Union real labels; the cap counts real labels only.
475
+ const seen = new Set(real);
476
+ const rejected = new Set();
477
+ const out = real.slice();
478
+ for (const x of inc) {
479
+ const m = OVERFLOW_RE.exec(String(x));
480
+ if (m) { floor = Math.max(floor, Number(m[1])); continue; } // a sentinel, never a label
481
+ if (seen.has(x) || rejected.has(x)) continue; // dedup, incl. repeated rejects
482
+ if (out.length >= SKILLS_MAX) { rejected.add(x); continue; }
483
+ seen.add(x); out.push(x);
484
+ }
485
+
486
+ // (3) Exactly one sentinel, always last.
487
+ const realAfter = out.length;
488
+ const overflow = floor + rejected.size;
489
+ if (overflow > 0) out.push(`overflow:${overflow}`);
490
+
491
+ // (4) Growth is either a new real label or a risen overflow count.
492
+ return (realAfter > real.length || overflow > wasOverflow) ? out : null;
493
+ }
494
+
495
+ /** clip(), but keeping HEAD and TAIL with an ellipsis between when over budget.
496
+ * For runner exit details the frame ("claude exited with code N") leads and
497
+ * the terminal cause sits at the END — the runner tail-caps for that reason —
498
+ * so a head-only clip discards exactly the cause. Tail gets the larger share. */
499
+ export function clipMiddle(text, n) {
500
+ if (!text) return '';
501
+ const s = String(text).replace(/\s+/g, ' ').trim();
502
+ if (s.length <= n) return s;
503
+ const head = Math.floor((n - 1) / 3);
504
+ return s.slice(0, head) + '…' + s.slice(-(n - 1 - head));
505
+ }
506
+
507
+ /** Longest pause detail we persist/broadcast (the run log keeps the full text). */
508
+ export const PAUSE_DETAIL_MAX = 400;
509
+
510
+ /** The human detail a converted failure pauses with: the WHOLE message, whitespace-
511
+ * collapsed and middle-clipped — the runner frames failures as "claude exited with
512
+ * code N: <cause>" with the cause at the END, so a head-only clip would drop it.
513
+ * Never empty: every consumer prints it verbatim. */
514
+ export function errorDetail(err, max = PAUSE_DETAIL_MAX) {
515
+ const message = err == null ? '' : (err.message ?? String(err));
516
+ return clipMiddle(message, max) || 'unknown error';
517
+ }
518
+
519
+ /** A snapshot row the scheduler marked 'error' is TERMINAL to reattach() — the node
520
+ * would never re-fire and a resumed run would quiesce to a false done. Under the
521
+ * errors-pause policy such a row can only come from a failure that bypassed the
522
+ * adapter's conversion. failExecution's fail-fast abort also settles every
523
+ * in-flight sibling as 'skipped' (TERMINAL too), so when an error row is present the
524
+ * skipped rows are its collateral and are re-armed with it; a snapshot WITHOUT an
525
+ * error row is returned by identity (its skipped rows are legitimate). */
526
+ export function scrubErrorRows(snapshot) {
527
+ if (!snapshot || !Array.isArray(snapshot.execs)) return snapshot ?? null;
528
+ if (!snapshot.execs.some((e) => e && e.status === 'error')) return snapshot;
529
+ const execs = snapshot.execs.map((e) => {
530
+ if (!e || (e.status !== 'error' && e.status !== 'skipped')) return e;
531
+ const { error: _dropped, ...rest } = e;
532
+ return { ...rest, status: 'paused' };
533
+ });
534
+ return { ...snapshot, execs };
535
+ }
536
+
537
+ /**
538
+ * Normalize an answer payload from answer()/auto into [{id, choice}].
539
+ * Accepts { answers:[{id,choice}] } or a bare array. Fills any missing
540
+ * questions with their first option so downstream never sees gaps.
541
+ */
542
+ export function normalizeClarifyAnswer(payload, questions) {
543
+ const arr = Array.isArray(payload?.answers)
544
+ ? payload.answers
545
+ : Array.isArray(payload)
546
+ ? payload
547
+ : [];
548
+ const byId = new Map();
549
+ for (const a of arr) {
550
+ if (a && a.id != null) byId.set(String(a.id), String(a.choice ?? ''));
551
+ }
552
+ return (questions || []).map((q) => ({
553
+ id: q.id,
554
+ choice: byId.has(q.id)
555
+ ? byId.get(q.id)
556
+ : (q.options && q.options.find((o) => o && o.trim())) || '',
557
+ }));
558
+ }
559
+
560
+ export class RunHarness extends EventEmitter {
561
+ constructor(opts) {
562
+ super();
563
+ this.opts = opts || {};
564
+
565
+ // ── Workspace mode (opt-in; absent => single-project, every path unchanged) ──
566
+ // A workspace run targets 2+ member projects (sorted by projectKey). The scalar
567
+ // projectDir/workDir below point at the PRIMARY (members[0]) so every existing
568
+ // call site that reads them keeps working; per-project data lives in the maps.
569
+ this.workspace = this.opts.workspace || null;
570
+ this.isWorkspace = !!this.workspace;
571
+ this.workspaceKey = this.workspace?.key || null;
572
+ // Single-project runs synthesize a ONE-element member array so every
573
+ // downstream map (workDirs / branchInfos / checkpointRefs / state.branches) has
574
+ // exactly one shape in both modes. projectKey is worktree-location-independent
575
+ // and falls back to the resolved path for a non-git dir (store.mjs), so it is
576
+ // the same value the first _persist already derives — no new identity. The
577
+ // synthesized projectName is pinned to basename(resolve(projectDir)) so it can
578
+ // never leak `undefined` into a branch slug.
579
+ this.members = Array.isArray(this.workspace?.projects)
580
+ ? this.workspace.projects
581
+ .slice()
582
+ .sort((a, b) => (a.projectKey < b.projectKey ? -1 : a.projectKey > b.projectKey ? 1 : 0))
583
+ : (() => {
584
+ const dir = resolve(this.opts.projectDir || process.cwd());
585
+ return [{ projectKey: projectKey(dir), projectName: basename(dir), projectDir: dir }];
586
+ })();
587
+ this.memberByKey = new Map(this.members.map((m) => [m.projectKey, m]));
588
+ this.workDirs = new Map(); // projectKey -> worktree checkout dir
589
+ this.checkpointRefs = {}; // projectKey -> pre-run commit
590
+ this.branchInfos = new Map(); // projectKey -> createWorktree() result
591
+ this.toolInstructions = new Map(); // projectKey -> per-project graph instruction
592
+ this.workspaceDescription = ''; // frozen at run start (after createPipeline)
593
+
594
+ // primaryCwd: the lowest-projectKey member in workspace mode, else the scalar
595
+ // projectDir. resolve() keeps the single-project behavior byte-identical.
596
+ this.projectDir = this.isWorkspace
597
+ ? resolve(this.members[0].projectDir)
598
+ : resolve(this.opts.projectDir || process.cwd());
599
+ this.claude = {
600
+ bin: this.opts.claude?.bin,
601
+ permissionMode: this.opts.claude?.permissionMode || 'acceptEdits',
602
+ model: this.opts.claude?.model,
603
+ mock: !!this.opts.claude?.mock,
604
+ };
605
+ // The mock runner routes EVERY dontAsk spawn to the Ask Worca mock (claude-runner.mjs
606
+ // runMock, rule R-F), so a mock pipeline role under dontAsk writes no artifact and
607
+ // the run dies at its first artifact read with no hint why. Fail at construction
608
+ // instead (review of PR #376). WORCA_MOCK counts: the runner honours the env too.
609
+ if (this.claude.permissionMode === 'dontAsk'
610
+ && (this.claude.mock || /^(1|true|yes|on)$/i.test(String(process.env.WORCA_MOCK ?? process.env.ORCH_MOCK ?? '')))) {
611
+ throw new Error('permissionMode "dontAsk" is reserved for the Ask Worca runner in mock mode — a mock pipeline role spawned with it would take the ask mock and write no artifact');
612
+ }
613
+ this.agentsDir = this.opts.agentsDir || DEFAULT_AGENTS_DIR;
614
+ this.auto = !!this.opts.auto;
615
+ this.stepModels = null; // { planner:{model,effort}, refiner:{...}, ... } | null until run()
616
+ // Guardrails: resolved by _resolveGuardrails() from run() AND resume(); null
617
+ // until then, so dispatcher tests that bypass run() get claudeOpts without
618
+ // the fields (legacy parity).
619
+ this.guardrails = null;
620
+ this.guardrailPermissionRules = null;
621
+ this.guardrailHonorByKey = null;
622
+ // Which saved workflow topology to run (default reproduces today's pipeline) and
623
+ // the runner registry the dispatcher consults (overridable for tests).
624
+ this.workflowId = this.opts.workflowId || 'wf_default';
625
+ // Which guardrail set governs this run (guardrails are selected PER RUN;
626
+ // there is no per-project guardrails dimension). 'permissive' = the empty
627
+ // policy = byte-identical legacy spawn, so callers that never pass the
628
+ // option (CLI, tests, pre-picker API bodies) keep today's behavior exactly.
629
+ this.guardrailsId = this.opts.guardrailsId || 'permissive';
630
+ // Engine hook: the v1 runner registry (see Orchestrator._initRunners).
631
+ this._initRunners(this.opts);
632
+
633
+ // Worktree isolation: workDir is the per-pipeline checkout. Until
634
+ // _setupRunRoot() runs, it mirrors projectDir so the existing tests/paths
635
+ // (dispatcher tests that bypass run()) behave identically.
636
+ this.workDir = this.projectDir;
637
+ this.branchOpts = {
638
+ source: (this.opts.branch && this.opts.branch.source) || null,
639
+ feature: (this.opts.branch && this.opts.branch.feature) || null,
640
+ };
641
+ this.branchInfo = null;
642
+ // ── Run root (§5.2). All three are assigned in _setupRunRoot() (or rehydrated
643
+ // by resume() from the RECORDED mode, never the live flag). Under `legacy`
644
+ // runRoot stays null and runCwd is the worktree, so every legacy path is
645
+ // byte-identical to today. workDir keeps its name but is now only "the single
646
+ // project's worktree, or the primary's, for back-compat readers".
647
+ this.runRoot = null;
648
+ this.runCwd = null;
649
+ this.runRootMode = null;
650
+ // §8.8 exclusion set: { <projectKey>|'runRoot': [{ path, source, kind }] }.
651
+ // Permanently {} under legacy; on a detached run Phase 3 fills it from
652
+ // assembleRunContext and rehydrates it from run.json on resume.
653
+ this.injectedPaths = {};
654
+ // §5.4-§5.6 generated context. All three stay null/[] under legacy, which is
655
+ // what keeps every legacy spawn argv byte-identical (§10 rollback contract).
656
+ this.runContext = null;
657
+ this.mcpConfigPath = null; // <runRoot>/mcp.json -> --mcp-config
658
+ this.mcpServerGrants = []; // `mcp__<server>` per merged server (V1 branch (a))
659
+
660
+ this.abort = new AbortController();
661
+ this.pauseRequested = false;
662
+ this.pauseAbort = new AbortController(); // aborts ONLY node children on pause
663
+ this.pauseReason = null; // WHY the run paused: 'cost_pipeline'|'cost_total'|'error'|<usage-limit line>|null
664
+ this.pauseDetail = null; // the human detail behind pauseReason ('error': the clipped message)
665
+ this._setupDone = false; // run()/resume() flip this right before _engineRun (setup replay)
666
+ this._rehydrated = true; // resume() clears this until the paused run is rehydrated (the 'resume' site)
667
+ this._modeRecorded = false; // resume(): the row recorded a run-root mode (a setup-incomplete point may not)
668
+ this._pauseGate = null; // gate context snapshot when paused at a gate
669
+ this._resumeNodeSessions = null; // nodeId -> sessionId map, set by resume() (Task 5)
670
+ this.resumeOpts = this.opts.resume || null; // { row, resumePoint, steps } from readPipelineForResume
671
+ this.pendingQuestion = null; // { id, resolve, reject, kind }
672
+ this._recovery = null; // class -> in-flight Promise<'retry'|'pause'> (same-class dedupe)
673
+ this._askTail = null; // serializes _ask: ONE prompt open at a time (recovery + step questions)
674
+ this._recoverySeq = 0; // monotonic id source for recovery prompts (determinism-safe)
675
+ this.agentPrompts = null;
676
+ this.toolInstruction = '';
677
+ // Cap for the in-worktree graphify build (macOS has no timeout(1)).
678
+ // Resolution order: constructor option → WORCA_GRAPH_TIMEOUT_MS env → 120s.
679
+ const _gt = Number(this.opts.graphBuildTimeoutMs ?? process.env.WORCA_GRAPH_TIMEOUT_MS);
680
+ this.graphBuildTimeoutMs = Number.isFinite(_gt) && _gt > 0 ? _gt : 120000;
681
+ this.checkpointRef = null;
682
+ this.registry = null; // ▲ v3: set in run(); used by _dispatch's D4 validation
683
+ this.extrasFiles = []; // attached files copied into <pipeline>/extras (set in _dispatch)
684
+ this.pipeline = null; // { id, dir, promptText }
685
+ this.logWriter = createRunLogWriter(); // buffered NDJSON persistence of the `log` stream
686
+ this.baseName = null;
687
+ this.planDatePrefix = null; // DD-MM-YY captured once so -vN versions share it
688
+
689
+ // Sub-agent live-log labels: parent_tool_use_id -> label shown after "▸".
690
+ // Tool-use ids are unique per claude process, so entries never collide across
691
+ // runs/cycles; bounded by the number of sub-agents in a pipeline, so no reset.
692
+ this._subAgentLabels = new Map();
693
+ // Monotonic ordinal for sub-agents whose Task description was never captured,
694
+ // so their fallback tag (sub-agent-N) is an honest "Nth undescribed sub-agent",
695
+ // independent of how many described sub-agents share the map.
696
+ this._subAgentFallbackSeq = 0;
697
+
698
+ this.state = {
699
+ id: this.opts.pipelineId || null,
700
+ title: this.opts.title || null,
701
+ projectDir: this.projectDir,
702
+ status: 'idle',
703
+ phase: 'idle',
704
+ cycle: 0,
705
+ startedAt: null,
706
+ updatedAt: null,
707
+ steps: [],
708
+ stepper: null, // UI stepper manifest, snapshotted at run start (Task 2)
709
+ tools: null,
710
+ checkpointRef: null,
711
+ pipelineDir: null,
712
+ totalCostUsd: 0, // cumulative actual spend (sum of steps[].costUsd)
713
+ totalActiveMs: 0, // cumulative active processing time (sum of steps[].activeMs)
714
+ branch: null, // { source, feature, worktreeDir, reusedExisting } after _setupRunRoot
715
+ // Per-member maps, initialized HERE (not lazily) so getState()'s snapshot
716
+ // shape is stable across modes and targets. Without this a single-project
717
+ // detached run throws TypeError on the first this.state.branches[key] = … .
718
+ branches: {},
719
+ checkpointRefs: {},
720
+ pauseReason: null, // mirrors this.pauseReason so getState() (a deep clone of state) carries it live
721
+ pauseDetail: null, // mirrors this.pauseDetail
722
+ // Sub-agent lifecycle records (rides the existing `state` snapshot; mirrored to
723
+ // the sub_agents table). Each: { id, label, nodeId, stepIndex, cycle, stepKey,
724
+ // status, startedAt, finishedAt, durationMs?, tokens?, costUsd? };
725
+ // status ∈ 'running'|'finished'|'error'|'stopped'.
726
+ subAgents: [],
727
+ };
728
+ }
729
+
730
+ /** @returns {object} a deep-ish snapshot of current state. */
731
+ getState() {
732
+ return JSON.parse(JSON.stringify(this.state));
733
+ }
734
+
735
+ /**
736
+ * Resolve a pending question.
737
+ * @param {string} id
738
+ * @param {object} payload clarify: {answers:[{id,choice}]} ; gate: {decision}
739
+ */
740
+ answer(id, payload) {
741
+ const pq = this.pendingQuestion;
742
+ if (!pq || pq.id !== id) {
743
+ this._log('orchestrator', 'warn', `answer() ignored: no pending question with id ${id}`);
744
+ return false;
745
+ }
746
+ this.pendingQuestion = null;
747
+ pq.resolve(payload);
748
+ return true;
749
+ }
750
+
751
+ /** Abort the run; marks state stopped and kills any child via the signal. */
752
+ stop() {
753
+ if (this.state.status === 'done' || this.state.status === 'stopped') return;
754
+ this._setStatus('stopped');
755
+ try {
756
+ this.abort.abort();
757
+ } catch {
758
+ /* ignore */
759
+ }
760
+ // Unblock any awaiting question.
761
+ if (this.pendingQuestion) {
762
+ const pq = this.pendingQuestion;
763
+ this.pendingQuestion = null;
764
+ const err = new Error('stopped');
765
+ err.name = 'AbortError';
766
+ pq.reject(err);
767
+ }
768
+ }
769
+
770
+ /**
771
+ * Gracefully pause the run: kill in-flight node children (SIGTERM via the
772
+ * pause-only signal), unwind _dispatch, persist a resume point. The worktree is
773
+ * kept. Returns false unless the run is currently 'running'.
774
+ */
775
+ pause() {
776
+ if (this.state.status !== 'running') return false;
777
+ this.pauseRequested = true;
778
+ this._setStatus('pausing');
779
+ try {
780
+ this.pauseAbort.abort();
781
+ } catch {
782
+ /* ignore */
783
+ }
784
+ // Unblock any awaiting clarify/gate question with the pause sentinel.
785
+ if (this.pendingQuestion) {
786
+ const pq = this.pendingQuestion;
787
+ this.pendingQuestion = null;
788
+ pq.reject(pauseErr());
789
+ }
790
+ return true;
791
+ }
792
+
793
+ _checkPause() {
794
+ if (this.pauseRequested) throw pauseErr();
795
+ }
796
+
797
+ /**
798
+ * Record WHY the run is pausing: a machine-readable reason (the cost codes, 'error',
799
+ * or the usage-limit first line) plus an optional human detail. FIRST WRITER WINS —
800
+ * a pause kills its siblings and their unwinds must not overwrite the cause (the
801
+ * rule every _pauseFor site follows). Mirrored onto state so every
802
+ * `state` event and getState() carry it. @returns {boolean} true when recorded
803
+ */
804
+ _setPauseReason(reason, detail = null) {
805
+ if (this.pauseReason) return false;
806
+ this.pauseReason = String(reason);
807
+ this.pauseDetail = detail == null || detail === '' ? null : String(detail);
808
+ this.state.pauseReason = this.pauseReason;
809
+ this.state.pauseDetail = this.pauseDetail;
810
+ return true;
811
+ }
812
+
813
+ _clearPauseReason() {
814
+ this.pauseReason = null;
815
+ this.pauseDetail = null;
816
+ this.state.pauseReason = null;
817
+ this.state.pauseDetail = null;
818
+ }
819
+
820
+ /**
821
+ * Enact a 'pause' verdict (failure-policy.mjs) at ANY site — the one mechanism
822
+ * behind every forced pause: the ONE log line, the reason + detail (first writer
823
+ * wins), the audit line, then pause(). pause() sets pauseRequested BEFORE anything
824
+ * reaches the scheduler, so onSnapshot stays frozen at the last clean point, the
825
+ * failing row settles 'paused' (non-terminal) and reattach() re-invokes it on
826
+ * resume. Returns false — recording nothing — when a pause is already unwinding
827
+ * (the user's, or a sibling's: its reason stands) or a stop is in flight (never
828
+ * re-labelled: pause() would be a no-op on 'stopped' and the scheduler must keep
829
+ * seeing the stop). The caller throws pauseErr() itself where a throw is due.
830
+ * @param {string} reason a REASON code
831
+ * @param {Error|null} err the failure; null for a cap (`detail` carries the text)
832
+ * @param {{nc?:object|null, ctx?:object|null, label?:string, detail?:string|null, cls?:string|null}} [o]
833
+ * nc/ctx: the execution (orchestrator sites); label: the log source otherwise;
834
+ * cls: the error class behind a RECOVERABLE pause (kept in the log, audit and detail)
835
+ */
836
+ _pauseFor(reason, err, { nc = null, ctx = null, label = null, detail = null, cls = null } = {}) {
837
+ const where = label || nc?.key || ctx?.nodeId || 'orchestrator';
838
+ const meta = ctx ? { nodeId: ctx.nodeId, executionId: ctx.executionId, cycle: ctx.ordinal } : {};
839
+ const line = firstLine(err?.message || (err == null ? '' : String(err))) || 'unknown error';
840
+ const text = detail ?? (reason === REASON.ERROR ? errorDetail(err)
841
+ : reason === REASON.RECOVERABLE ? `${cls || 'recoverable'}: ${line}` : line);
842
+ if (reason === REASON.ERROR) {
843
+ // The ONE error-level line, written BEFORE the pause sentinel the caller
844
+ // throws next (a pause/abort is never logged as a failure).
845
+ this._log(where, 'error', `${ctx ? 'execution' : 'run'} failed: ${clipMiddle(err?.message || err, 500)}`,
846
+ { ...meta, ...(err?.stream ? { stream: err.stream } : {}) });
847
+ }
848
+ if (this.pauseRequested || this.state.status === 'stopped' || this.abort.signal.aborted) return false;
849
+ this._setPauseReason(reason, text);
850
+ let audit;
851
+ if (reason === REASON.ERROR) {
852
+ audit = ctx
853
+ ? `Pipeline **paused**: execution failed on ${where} — ${line}. Fix the cause, then resume.`
854
+ : `Pipeline **paused**: ${line}. Fix the cause, then resume.`;
855
+ } else if (reason === REASON.USAGE_LIMIT) {
856
+ this._log(where, 'warn', `${describePauseReason(reason)} — pausing for manual resume: ${text}`, meta);
857
+ audit = `Pipeline **paused**: session/usage limit on ${where} — ${text}. Resume after the reset.`;
858
+ } else if (reason === REASON.RECOVERABLE) {
859
+ this._log(where, 'warn', `recoverable ${cls || 'error'} error — pausing for manual resume: ${line}`,
860
+ { ...meta, ...(err?.stream ? { stream: err.stream } : {}) });
861
+ audit = `Pipeline **paused**: recoverable ${cls || 'error'} error on ${where} — ${line}. Resume to retry.`;
862
+ } else {
863
+ this._log(where, 'warn', `${text} — pausing for manual resume`, meta);
864
+ audit = `Pipeline **paused**: ${text}.`;
865
+ }
866
+ appendAudit(this.pipeline.dir, audit).catch(() => {});
867
+ this.pause();
868
+ return true;
869
+ }
870
+
871
+ /**
872
+ * Execute the full pipeline. Resolves with { status, pipelineDir } on success
873
+ * or stop; rejects only on unexpected internal errors (it emits 'error' too).
874
+ */
875
+ async run() {
876
+ try {
877
+ this.state.startedAt = new Date().toISOString();
878
+ this._setStatus('running');
879
+
880
+ // Resolve the workflow topology + per-node run-config and snapshot the UI
881
+ // stepper manifest BEFORE any blocking work (preflight/clarify). It depends
882
+ // only on workflowId + run-config + registry — none of clarify's output — so
883
+ // Running/History render the right nodes (and per-node model·effort) at once
884
+ // instead of the legacy default until clarify ends. resolveWorkflow reads
885
+ // projectDir (NOT the pipeline dir, which doesn't exist yet), so this is safe
886
+ // here. pipelineDir is null in this first event; it is persisted + re-emitted
887
+ // after createPipeline below.
888
+ const registry = loadAgentRegistry(this.agentsDir);
889
+ this.registry = registry; // ▲ v3: expose for run-start workflow validation (D4)
890
+ // Engine hook: resolve the run topology. v1 = resolveWorkflow + workspace
891
+ // fan-out forcing + the v1 stepper manifest; v2 = resolveGraph +
892
+ // buildGraphManifest. It yields the manifest the UI renders, the agent-key
893
+ // set the preflight and skills gates walk, and the workflow's id/name.
894
+ const topology = await this._resolveTopology(registry);
895
+ if (!topology?.manifest || !topology.agentKeys || !topology.workflow?.id) throw new Error('engine hook contract: _resolveTopology must return { manifest, agentKeys, workflow:{id,name} }');
896
+ // §9.4: hard-fail BEFORE the stepper is STAMPED / createPipeline / worktree
897
+ // (the manifest is built inside the hook, which tolerates unknown keys) —
898
+ // a missing agent key must never reach dispatch as an empty-prompt node.
899
+ this._preflightAgentKeys(topology.agentKeys);
900
+ this.state.stepper = topology.manifest;
901
+ this._emit('state', this.getState());
902
+
903
+ // 1) Load agent prompts + preflight tool detection (parallel; both safe).
904
+ this._bookend('preflight', 'start');
905
+ const [agentPrompts, tools, stepModels] = await Promise.all([
906
+ this._loadAgentPrompts(),
907
+ detectTools(this.projectDir),
908
+ resolveStepModels(this.projectDir, this.claude.model), // never throws
909
+ ]);
910
+ this.agentPrompts = agentPrompts;
911
+ this.toolInstruction = tools.instruction || '';
912
+ this.state.tools = tools;
913
+ this.stepModels = stepModels;
914
+ await this._resolveGuardrails();
915
+ this._log(
916
+ 'preflight',
917
+ 'info',
918
+ tools.tool
919
+ ? `Detected tool: ${tools.tool}${tools.kind ? ` (${tools.kind})` : ''}`
920
+ : 'No knowledge-graph tooling detected',
921
+ );
922
+
923
+ // 2) Resolve the task input through the source seam (sources.mjs) and create
924
+ // the pipeline directory + audit. Absent opts.source the legacy prompt/
925
+ // promptFile opts are wrapped into the equivalent descriptor — same text
926
+ // precedence as createPipeline's old inline resolution (non-empty inline
927
+ // prompt wins, else file), so feature-off prompt.md bytes and row values are
928
+ // identical. On a workspace run the pipeline is written to the WORKSPACE
929
+ // store (artifactPaths routes by workspaceKey) — all owned by createPipeline.
930
+ const source = this.opts.source
931
+ || (typeof this.opts.prompt === 'string' && this.opts.prompt
932
+ ? { type: 'prompt', prompt: this.opts.prompt }
933
+ : this.opts.promptFile
934
+ ? { type: 'markdown', promptFile: this.opts.promptFile }
935
+ : { type: 'prompt', prompt: '' });
936
+ const input = await resolveTaskInput(source, { projectDir: this.projectDir });
937
+ this.pipeline = await createPipeline(this.projectDir, {
938
+ promptText: input.promptText,
939
+ // ?? keeps the legacy both-set corner byte-identical: inline prompt wins the
940
+ // text, but a passed promptFile is STILL copied verbatim into prompt.md.
941
+ promptFile: input.promptFile ?? this.opts.promptFile,
942
+ sourceType: source.type,
943
+ sourceMeta: input.sourceMeta || null,
944
+ extras: this.opts.extras,
945
+ title: this.opts.title,
946
+ guardrailsId: this.guardrailsId,
947
+ ...(this.isWorkspace ? {
948
+ workspaceKey: this.workspaceKey,
949
+ workspaceId: this.workspace.id,
950
+ workspaceName: this.workspace.name,
951
+ workspaceDescription: this.workspace.description || '',
952
+ projects: this.members.map((m) => ({
953
+ projectKey: m.projectKey,
954
+ projectDir: m.projectDir,
955
+ projectName: m.projectName,
956
+ })),
957
+ } : {}),
958
+ });
959
+ this.state.id = this.pipeline.id;
960
+ this.state.pipelineDir = this.pipeline.dir;
961
+ this.logWriter.bind(this.pipeline.dir); // start persisting (flushes buffered preflight lines)
962
+ recordArtifact(this.pipeline.id, RUN_LOG_KIND, RUN_LOG_FILE); // index like prompt.md (sync; INSERT OR IGNORE)
963
+ // A11(b): carry the resolved prompt on the in-memory state too (createPipeline
964
+ // already INSERTs prompt and the curated UPSERT excludes it, so persistence is
965
+ // safe — this keeps the live state object self-consistent for any reader).
966
+ this.state.prompt = this.pipeline.promptText;
967
+ // Same reasoning for the run's guardrail selection: createPipeline INSERTed
968
+ // guardrails_id and the curated UPSERT excludes it (creation-immutable), so
969
+ // mirroring it onto the live state only keeps rowToState round-trips honest.
970
+ this.state.guardrailsId = this.guardrailsId;
971
+ // Workspace: mirror the §5.2 superset onto the live state and FREEZE the
972
+ // description now (read from the pipeline's frozen state.json snapshot, never
973
+ // re-read from workspaces.json), so later registry edits never alter this run.
974
+ if (this.isWorkspace) {
975
+ // Freeze from the on-disk snapshot createPipeline wrote (the capped,
976
+ // point-in-time copy) — never re-read from workspaces.json mid-run.
977
+ this.workspaceDescription = await readFile(
978
+ join(this.pipeline.dir, 'workspace-description.md'), 'utf8',
979
+ ).catch(() => this.workspace.description || '');
980
+ this.state.target = 'workspace';
981
+ this.state.workspaceId = this.workspace.id;
982
+ this.state.workspaceKey = this.workspaceKey;
983
+ this.state.workspaceName = this.workspace.name;
984
+ this.state.workspaceDescription = this.workspaceDescription;
985
+ this.state.projectKeys = this.members.map((m) => m.projectKey);
986
+ this.state.projects = this.members.map((m) => ({
987
+ projectKey: m.projectKey,
988
+ projectDir: resolve(m.projectDir),
989
+ projectName: m.projectName,
990
+ }));
991
+ this.state.checkpointRefs = {};
992
+ this.state.branches = {};
993
+ }
994
+ if (!this.state.title) this.state.title = basename(this.pipeline.dir);
995
+ // The title set above (firstMeaningfulLine(prompt) or the dir basename) is
996
+ // PROVISIONAL: shown instantly. Kick off the real LLM title without blocking
997
+ // run start. Skip on a resumed run — it already carries the previously-generated
998
+ // row.title (loaded by resume()). this.resumeOpts (= this.opts.resume) is the
999
+ // resume signal; resume() never reaches this run() site anyway (belt-and-suspenders).
1000
+ // The kickoff itself fires AFTER _setupRunRoot() below, so generateTitle's cwd
1001
+ // can be this.runCwd (§2.1 row 3) — at this point runCwd is still null.
1002
+ this.state.titleProvisional = true;
1003
+ this.baseName = this._deriveBaseName(this.pipeline.promptText, this.state.title);
1004
+ // Capture the date prefix ONCE so every plan -vN and the review file share
1005
+ // the v1 date even if the run crosses midnight.
1006
+ this.planDatePrefix = today();
1007
+ // Persist the plan/review name linkage so a later delete can find the shared
1008
+ // markdown exactly (state.artifacts is not persisted; names are the only link).
1009
+ this.state.baseName = this.baseName;
1010
+ this.state.datePrefix = this.planDatePrefix;
1011
+ await this._persist();
1012
+ this._startHeartbeat(); // claim ownership + begin liveness heartbeat (crash detection)
1013
+ this._artifact('pipeline', this.pipeline.dir);
1014
+ await appendAudit(this.pipeline.dir, `Pipeline created (id ${this.pipeline.id}).`);
1015
+ if (tools.tool) {
1016
+ await appendAudit(
1017
+ this.pipeline.dir,
1018
+ `Preflight: using **${tools.tool}**${tools.kind ? ` (${tools.kind})` : ''}.`,
1019
+ );
1020
+ }
1021
+
1022
+ // 3) Ensure a git repo + checkpoint commit (per member on a workspace run).
1023
+ if (this.isWorkspace) await this._ensureGitCheckpointAll();
1024
+ else await this._ensureGitCheckpoint();
1025
+ this._bookend('preflight', 'done');
1026
+ this._checkAbort();
1027
+
1028
+ // 3b) Set up the run root + the per-pipeline worktree(s). All subsequent
1029
+ // claude spawns cwd into this.runCwd (the run root on a detached workspace
1030
+ // run, else the primary's worktree); per-member fan-out sub-agents work in
1031
+ // this.workDirs. Artifacts route via the workspace store.
1032
+ await this._setupRunRoot();
1033
+ // The provisional title (firstMeaningfulLine(prompt) or the dir basename) is
1034
+ // shown instantly; kick off the real LLM title without blocking run start, now
1035
+ // that runCwd exists so no worca-cc process is started inside the user's live
1036
+ // checkout (§2.1 row 3). Skip on a resumed run — it already carries the
1037
+ // previously-generated row.title (loaded by resume()). this.resumeOpts (=
1038
+ // this.opts.resume) is the resume signal; resume() never reaches this run()
1039
+ // site anyway (belt-and-suspenders).
1040
+ if (!this.resumeOpts) this._kickoffTitleGeneration();
1041
+ this._checkAbort();
1042
+
1043
+ // 3c) Build the knowledge graph INSIDE each worktree so agents can query it.
1044
+ if (this.isWorkspace) await this._buildWorktreeGraphAll();
1045
+ else await this._buildWorktreeGraph();
1046
+ this._checkAbort();
1047
+
1048
+ // 3d) Resolve + validate declared agent skills (hard gate, UNCHANGED in
1049
+ // semantics), then assemble the run context for EVERY detached run —
1050
+ // including the zero-declared-skills case, which is every shipped
1051
+ // workflow today (`grep requiresSkills agents/` → zero hits).
1052
+ const requiredSkills = collectRequiredSkills(this.registry, topology.agentKeys);
1053
+ let resolvedSkills = new Map(); // ← HOISTED; empty Map on the default workflow
1054
+ if (requiredSkills.length) {
1055
+ const skillCtx = { repoRoot: REPO_ROOT, projectDir: this.projectDir, pluginDirs: pluginSkillDirs() };
1056
+ resolvedSkills = validateSkills(requiredSkills, skillCtx); // throws => caught => the run PAUSES (D6); the setup replay re-gates on resume
1057
+ if (this.runRootMode !== 'detached') {
1058
+ // LEGACY delivery, byte-identical to today: inject ONLY into real isolated
1059
+ // worktrees, never the main projectDir, so a copy can never pollute the
1060
+ // user's working tree.
1061
+ const candidates = this.isWorkspace ? [...this.workDirs.values()] : [this.workDir];
1062
+ const worktrees = candidates.filter((d) => d && d !== this.projectDir);
1063
+ const injected = await injectSkills(resolvedSkills, { targets: worktrees });
1064
+ if (injected.length) {
1065
+ await appendAudit(
1066
+ this.pipeline.dir,
1067
+ `Skills: injected ${injected.join(', ')} into ${worktrees.length} worktree(s).`,
1068
+ );
1069
+ }
1070
+ }
1071
+ }
1072
+ this._checkAbort();
1073
+
1074
+ // 3e) Context assembly — UNCONDITIONAL on detached runs. Gated ONLY on the
1075
+ // recorded mode, NEVER on requiredSkills.length (nesting it back under that
1076
+ // guard would silently void R1(a)-(d) and R2 on every default pipeline while
1077
+ // leaving npm test and both mock smokes green), and NOT gated on mock either:
1078
+ // it is pure fs work whose outputs the smokes assert. Under detached,
1079
+ // bundle/plugin delivery happens inside assembleRunContext's mount (§5.6
1080
+ // entry class 3), so the legacy injectSkills branch above is correctly skipped.
1081
+ // The assembly also emits §8.21's per-member "project sub-agents are not
1082
+ // discoverable at a run-root cwd" warning (run log + run.json.warnings) and the
1083
+ // matching roster note in the generated CLAUDE.md — derived there, from each
1084
+ // member's worktree, so resume's re-assembly reproduces all three carriers
1085
+ // instead of dropping them when it rewrites `warnings`.
1086
+ if (this.runRootMode === 'detached') {
1087
+ await this._assembleContext(resolvedSkills);
1088
+ }
1089
+ this._checkAbort();
1090
+ // D7: every setup step above is done — a pause from here on has nothing to
1091
+ // replay, so _completePaused strips any `setupIncomplete` stamp instead.
1092
+ this._setupDone = true;
1093
+
1094
+ // 4) (Clarify now runs as the first graph node — see _runClarifyNode.)
1095
+
1096
+ // 5) Dispatch the resolved workflow (already snapshotted into state.stepper
1097
+ // at run start). Persist now that this.pipeline exists, and re-emit the
1098
+ // full state (with pipelineDir) for any client that connected mid-preflight.
1099
+ await this._persist();
1100
+ this._emit('state', this.getState());
1101
+ await appendAudit(this.pipeline.dir, `Workflow: **${topology.workflow.name}** (${topology.workflow.id}).`);
1102
+ const dispatched = await this._engineRun({ resume: null });
1103
+ this._checkAbort();
1104
+ if (dispatched === 'paused') return await this._completePaused();
1105
+
1106
+ // 9) Done.
1107
+ this._setStatus('done');
1108
+ this.state.resumePoint = null; // finished rows are not resumable (clears the boundary trail)
1109
+ this._bookend('done', 'done');
1110
+ await this._persist();
1111
+ await appendAudit(this.pipeline.dir, `Pipeline finished with status **done**.`);
1112
+ await this._buildResults(); // refs + worktree still live here
1113
+ await this._reportToSource(); // task-source write-back (never throws, spec §7.5)
1114
+ this._emit('done', { status: 'done', pipelineDir: this.pipeline.dir });
1115
+ return { status: 'done', pipelineDir: this.pipeline.dir };
1116
+ } catch (err) {
1117
+ if ((isPause(err) || this.state.status === 'pausing') && this.state.status !== 'stopped') {
1118
+ // A plain error that landed while a user pause was unwinding (a setup step that
1119
+ // failed under the pause): the user's pause keeps the reason, the run log keeps
1120
+ // the failure. _completePaused stamps setupIncomplete when setup never finished.
1121
+ if (!isPause(err) && !isAbort(err)) {
1122
+ this._log('orchestrator', 'error', `failed while pausing: ${clipMiddle(err?.message || err, 500)}`, err?.stream ? ERR_STREAM : null);
1123
+ }
1124
+ if (this.pipeline) {
1125
+ if (!this.state.resumePoint) {
1126
+ // Paused before the engine started (preflight/worktree): the engine
1127
+ // decides what a pre-dispatch resume point looks like.
1128
+ this.state.resumePoint = this._enginePrePausePoint();
1129
+ }
1130
+ return await this._completePaused();
1131
+ }
1132
+ // No pipeline yet: nothing to resume; treat as stopped.
1133
+ this._setStatus('stopped');
1134
+ this._emit('done', { status: 'stopped', pipelineDir: null });
1135
+ return { status: 'stopped', pipelineDir: null };
1136
+ }
1137
+ if (isAbort(err) || this.state.status === 'stopped') {
1138
+ this._setStatus('stopped');
1139
+ // Stopped runs are not resumable: never persist a resume point (e.g. one
1140
+ // _dispatch assigned before stop won the race) alongside a torn-down worktree.
1141
+ this.state.resumePoint = null;
1142
+ if (this.pipeline) {
1143
+ await this._persist().catch(() => {});
1144
+ await appendAudit(this.pipeline.dir, `Pipeline **stopped**.`).catch(() => {});
1145
+ // The diff artifact must survive a non-done terminal path too: the work done
1146
+ // up to this point IS committed onto the kept feature branch by the teardown
1147
+ // in the finally below, so History has to be able to show it. Safe HERE and
1148
+ // only here — the checkpoint refs and the worktree are still live until that
1149
+ // teardown runs. Best-effort by construction (its own try/catch logs a warn
1150
+ // and never rethrows), and a no-op when the run stopped before any checkpoint
1151
+ // existed. The terminal `done` event is emitted AFTER it so the History row never
1152
+ // paints as "no diff captured" for the tick before the artifact lands.
1153
+ await this._buildResults({ stage: true });
1154
+ await this._reportToSource(); // statusToResult('stopped') -> 'failed' (design PR12: no longer success-only)
1155
+ }
1156
+ this._emit('done', {
1157
+ status: 'stopped',
1158
+ pipelineDir: this.pipeline?.dir || null,
1159
+ });
1160
+ return { status: 'stopped', pipelineDir: this.pipeline?.dir || null };
1161
+ }
1162
+ if (this.pipeline) {
1163
+ // The SETUP / SHELL site (failure-policy.mjs): a failure once the row exists.
1164
+ try {
1165
+ const paused = await this._pauseForFailure(err);
1166
+ if (paused) return paused;
1167
+ } catch (err2) {
1168
+ // Last resort: the pause bookkeeping itself failed. Fall through to today's
1169
+ // error shape (persist/audit/results/write-back) rather than reject run().
1170
+ // The finally tears the checkout down on 'error' — never leave a
1171
+ // point that names it (today's error branch does not clear it; the stop branch does).
1172
+ this._log('orchestrator', 'error', `pause bookkeeping failed: ${err2?.message || err2} — ending the run as a launch error`);
1173
+ this.state.resumePoint = null;
1174
+ }
1175
+ }
1176
+ // No row yet (topology, preflight, tool detection): the LAUNCH site. Its only
1177
+ // enactable verdict is a terminal error — there is nothing to resume into.
1178
+ else this._launchVerdict(err);
1179
+ this._setStatus('error');
1180
+ const message = err?.message || String(err);
1181
+ this._emit('error', { message });
1182
+ if (this.pipeline) {
1183
+ await this._persist().catch(() => {});
1184
+ await appendAudit(this.pipeline.dir, `Pipeline **error**: ${message}`).catch(() => {});
1185
+ // The diff artifact must survive a non-done terminal path too: the work done
1186
+ // up to this point IS committed onto the kept feature branch by the teardown
1187
+ // in the finally below, so History has to be able to show it. Safe HERE and
1188
+ // only here — the checkpoint refs and the worktree are still live until that
1189
+ // teardown runs. Best-effort by construction (its own try/catch logs a warn
1190
+ // and never rethrows), and a no-op when the run stopped before any checkpoint
1191
+ // existed. The terminal `done` event is emitted AFTER it so the History row never
1192
+ // paints as "no diff captured" for the tick before the artifact lands.
1193
+ await this._buildResults({ stage: true });
1194
+ await this._reportToSource(); // statusToResult('error') -> 'failed' (design PR12: no longer success-only)
1195
+ }
1196
+ this._emit('done', {
1197
+ status: 'error',
1198
+ pipelineDir: this.pipeline?.dir || null,
1199
+ });
1200
+ return { status: 'error', pipelineDir: this.pipeline?.dir || null, error: message };
1201
+ } finally {
1202
+ this._stopHeartbeat(); // clear timer + NULL owner columns (done/stopped/launch-error/paused)
1203
+ // C1: tear the run root + worktree(s) down on done/stopped/launch-error — the branch is
1204
+ // always kept (every member's, on a workspace run), only the disposable checkout
1205
+ // is removed. But NEVER on a pause: the checkout (with any uncommitted agent
1206
+ // work) and the run root are the things we resume into (§8.13).
1207
+ if (this.state.status !== 'paused' && this.state.status !== 'pausing') {
1208
+ await this._teardownRunRoot().catch(() => {});
1209
+ }
1210
+ await this.logWriter.close().catch(() => {}); // flush + stop timer (last, to capture teardown logs)
1211
+ }
1212
+ }
1213
+
1214
+ /**
1215
+ * Continue a paused pipeline from its persisted resume point. Mirrors run()'s
1216
+ * shell but skips createPipeline / checkpoint / worktree / graph setup — those
1217
+ * artifacts exist from the original run, unless the point is stamped
1218
+ * `setupIncomplete` (D7 replay), which re-runs whatever setup never finished.
1219
+ * Resolves like run().
1220
+ */
1221
+ async resume() {
1222
+ const saved = this.resumeOpts;
1223
+ if (!saved?.row || !saved?.resumePoint) throw new Error('resume(): no saved pipeline provided');
1224
+ const { row, resumePoint: rp, steps } = saved;
1225
+ if (row.status !== 'paused' && row.status !== 'interrupted') {
1226
+ throw new Error(`resume(): pipeline is "${row.status}", not resumable`);
1227
+ }
1228
+ // Defense in depth: an archived run's worktree/run root were reclaimed, so
1229
+ // resuming it would rebuild nothing and write into a reaped tree.
1230
+ if (row.archived_at) throw new Error('resume(): pipeline is archived');
1231
+ // Engine hook: rejects a resume point that is not this engine's, and yields
1232
+ // the engine-specific fields the shell below rehydrates from. It runs at dev's
1233
+ // version-gate position: before any state is rehydrated and OUTSIDE the try,
1234
+ // so a throw rejects resume() without touching the row. Awaited so an engine
1235
+ // may be async; v1's synchronous return is awaited unchanged.
1236
+ const rehydrated = await this._engineRehydrate(rp);
1237
+ if (!rehydrated || typeof rehydrated.audit !== 'string' || !Array.isArray(rehydrated.memberWorktrees)) throw new Error('engine hook contract: _engineRehydrate must return { checkpointRef, memberWorktrees:[], audit }');
1238
+ // D7: the point tells us whether run() ever finished its setup. Until the replay
1239
+ // below re-runs it, a pause here must re-stamp the flag (_completePaused reads it).
1240
+ this._setupDone = rp.setupIncomplete !== true;
1241
+ // The 'resume' site (failure-policy.mjs): until the paused run is rehydrated —
1242
+ // identity, worktrees, guardrails, prompts — a failure cannot be parked again
1243
+ // (the point on disk is all there is) and ends the run.
1244
+ this._rehydrated = false;
1245
+ try {
1246
+ // ── rehydrate identity + state ──
1247
+ this.state.id = row.id;
1248
+ this.state.title = row.title;
1249
+ this.state.startedAt = row.started_at;
1250
+ this.state.prompt = row.prompt;
1251
+ this.state.stepper = safeParse(row.stepper);
1252
+ this.state.tools = safeParse(row.tools);
1253
+ this.state.branch = safeParse(row.branch);
1254
+ this.state.steps = (steps || []).map((s) => ({ ...s, runningSince: null }));
1255
+ this.baseName = row.base_name;
1256
+ this.planDatePrefix = row.date_prefix;
1257
+ this.pipeline = { id: row.id, dir: rp.pipelineDir, promptText: row.prompt || '' };
1258
+ this.state.pipelineDir = rp.pipelineDir;
1259
+ this.logWriter.bind(rp.pipelineDir);
1260
+ recordArtifact(row.id, RUN_LOG_KIND, RUN_LOG_FILE);
1261
+ this.stepModels = rp.stepModels || null;
1262
+ this.workflowId = rp.workflowId || this.workflowId;
1263
+ // The saved point carries the pause that produced it; a resumed run is running.
1264
+ this._clearPauseReason();
1265
+ // Rehydrate the run's selection BEFORE re-resolving so resume enforces the
1266
+ // LATEST saved set definition (missing set -> warn + Permissive, inside
1267
+ // _resolveGuardrails). Legacy resume points without the field fall back to
1268
+ // the constructor default ('permissive'). Keep state in sync for re-persist.
1269
+ this.guardrailsId = rp.guardrailsId || this.guardrailsId;
1270
+ this.state.guardrailsId = this.guardrailsId;
1271
+ await this._resolveGuardrails();
1272
+ // Restore the EFFECTIVE instruction from the resume point — by dispatch time
1273
+ // run() has replaced the detect-time tools.instruction with the in-worktree
1274
+ // graph-build outcome (worktreeGraphInstruction() or ''). Falling back to
1275
+ // tools.instruction would tell resumed agents a graph exists that the original
1276
+ // run suppressed. (Fallback keeps old-shape resume points working.)
1277
+ this.toolInstruction = typeof rp.toolInstruction === 'string' ? rp.toolInstruction : (this.state.tools?.instruction || '');
1278
+
1279
+ // ── run-root mode: read the RECORDED value, never the live flag (§10) ──
1280
+ // Single-project rides state.branch.runRootMode (the pipelines.branch JSON
1281
+ // column); workspace rides workspace_meta.runRootMode (real only because of the
1282
+ // artifacts.mjs whitelist fold). Absent ⇒ 'legacy', correct for every
1283
+ // pre-change row. A run can therefore never be resumed into a mode it was not
1284
+ // started in, no matter when the default flips or rolls back.
1285
+ const meta = safeParse(row.workspace_meta);
1286
+ const recordedRaw = this.isWorkspace ? meta?.runRootMode : this.state.branch?.runRootMode;
1287
+ this._modeRecorded = !!recordedRaw; // a setup-incomplete point may carry none
1288
+ const recordedMode = recordedRaw || 'legacy';
1289
+ this.runRootMode = recordedMode === 'detached' ? 'detached' : 'legacy';
1290
+ // Re-stamp BEFORE the first persist so a resumed workspace run re-persists the
1291
+ // pin rather than dropping it (toPipelineRow reads it off state every persist).
1292
+ this.state.runRootMode = this.runRootMode;
1293
+ /** The persisted manifest, read once on a detached resume (re-assembly below). */
1294
+ let resumeManifest = null;
1295
+ if (this.runRootMode === 'detached') {
1296
+ this.runRoot = join(worcaHome(), 'runs', row.id);
1297
+ // Rehydrate the §8.8 injected set from the manifest FIRST, so teardown still
1298
+ // excludes/rescues/cleans even if re-assembly is skipped or degrades; the
1299
+ // re-assembly result then overwrites it.
1300
+ resumeManifest = await readRunManifest(this.runRoot);
1301
+ if (resumeManifest?.injectedPaths && typeof resumeManifest.injectedPaths === 'object') {
1302
+ this.injectedPaths = resumeManifest.injectedPaths;
1303
+ }
1304
+ }
1305
+
1306
+ // ── worktree re-attach (single-project; workspace below) ──
1307
+ const wt = this.state.branch?.worktreeDir;
1308
+ if (wt && !existsSync(wt)) throw new Error(`worktree missing: ${wt} — cannot resume`);
1309
+ if (wt) {
1310
+ this.workDir = wt;
1311
+ this.branchInfo = {
1312
+ worktreeDir: wt,
1313
+ branch: this.state.branch.feature,
1314
+ sourceBranch: this.state.branch.source,
1315
+ reusedExisting: true,
1316
+ };
1317
+ if (!this.isWorkspace) {
1318
+ // Unified shapes must hold on resume too: one workDirs entry + one
1319
+ // checkpointRefs entry, so _buildResults / _reposCtx / _teardownRunRoot
1320
+ // read the same shape they do on a fresh run.
1321
+ const onlyKey = this.members[0]?.projectKey;
1322
+ if (onlyKey) {
1323
+ this.workDirs.set(onlyKey, wt);
1324
+ this.branchInfos.set(onlyKey, this.branchInfo);
1325
+ this.checkpointRefs[onlyKey] = rehydrated.checkpointRef;
1326
+ this.state.branches = { ...(this.state.branches || {}), [onlyKey]: { ...this.state.branch } };
1327
+ this.state.checkpointRefs = { ...this.checkpointRefs };
1328
+ }
1329
+ }
1330
+ }
1331
+ this.checkpointRef = rehydrated.checkpointRef;
1332
+ // §5.3: cwd for every spawn. Detached workspace runs start at the neutral run
1333
+ // root; everything else at the recorded worktree — identical to a legacy run
1334
+ // that never paused.
1335
+ this.runCwd = (this.runRootMode === 'detached' && this.isWorkspace)
1336
+ ? this.runRoot
1337
+ : (wt || null);
1338
+
1339
+ // ── workspace rehydration (no-op on single-project) ──
1340
+ if (this.isWorkspace && meta) {
1341
+ this.workspaceDescription = meta.workspaceDescription || '';
1342
+ this.checkpointRefs = meta.checkpointRefs || {};
1343
+ for (const p of rehydrated.memberWorktrees) {
1344
+ if (p.projectKey && p.worktreeDir) {
1345
+ if (!existsSync(p.worktreeDir)) throw new Error(`worktree missing: ${p.worktreeDir} — cannot resume`);
1346
+ this.workDirs.set(p.projectKey, p.worktreeDir);
1347
+ this.toolInstructions.set(p.projectKey, p.graphInstruction || '');
1348
+ // Re-arm teardown: _teardownWorktreeAll returns immediately on an empty
1349
+ // branchInfos map, so without this a resumed workspace run reaching
1350
+ // done/stopped/error would leak every member worktree and never run
1351
+ // _commitWork (resumed work silently absent from the feature branches).
1352
+ // Shape mirrors createWorktree()'s result as registered by _setupRunRoot.
1353
+ this.branchInfos.set(p.projectKey, {
1354
+ worktreeDir: p.worktreeDir,
1355
+ branch: meta.branches?.[p.projectKey]?.feature,
1356
+ sourceBranch: meta.branches?.[p.projectKey]?.source,
1357
+ reusedExisting: true,
1358
+ });
1359
+ }
1360
+ }
1361
+ Object.assign(this.state, {
1362
+ target: 'workspace', workspaceId: meta.workspaceId, workspaceKey: this.workspaceKey,
1363
+ workspaceName: meta.workspaceName, workspaceDescription: this.workspaceDescription,
1364
+ projectKeys: meta.projectKeys || [], projects: meta.projects || [],
1365
+ checkpointRefs: this.checkpointRefs, branches: meta.branches || {},
1366
+ });
1367
+ }
1368
+
1369
+ // ── prompts/registry (cheap, local) ──
1370
+ this.registry = loadAgentRegistry(this.agentsDir);
1371
+ this.agentPrompts = await this._loadAgentPrompts();
1372
+
1373
+ this.state.resumePoint = null; // consumed; cleared on the next persist
1374
+ this._setStatus('running');
1375
+ await this._persist();
1376
+ this._startHeartbeat();
1377
+ await appendAudit(this.pipeline.dir, rehydrated.audit);
1378
+ this._emit('state', this.getState());
1379
+ this._rehydrated = true;
1380
+
1381
+ // ── setup replay (D7): a converted setup failure paused this run before its
1382
+ // checkout / graph / skills gate existed. Re-run exactly what run() never
1383
+ // finished. Placed HERE: _setupRunRoot persists, and the row must already
1384
+ // read 'running' (above), never the constructor's 'idle'.
1385
+ let replayedSkills = null;
1386
+ if (rp.setupIncomplete === true) {
1387
+ this.state.titleProvisional = rp.titleProvisional === true;
1388
+ replayedSkills = await this._replaySetup();
1389
+ // The replay (re)wrote the run manifest; the re-assembly below reads it.
1390
+ if (this.runRootMode === 'detached') resumeManifest = await readRunManifest(this.runRoot);
1391
+ }
1392
+ this._setupDone = true;
1393
+
1394
+ // ── §5.2 detached resume: idempotent re-assembly (self-healing) ──
1395
+ // Only when the RECORDED mode is 'detached', and NEVER with a resolvedSkills
1396
+ // variable — that path does not exist here: resume never runs
1397
+ // collectRequiredSkills/validateSkills (it loads registry + channelDefs +
1398
+ // agentPrompts only, and a mid-run resume carries a frozen rp.plan). The
1399
+ // `name -> {source, path, requiredBy}` map persisted at first assembly is the
1400
+ // substitute. Assembly is a pure function of members + settings + graph
1401
+ // outcomes + that map, so a missing CLAUDE.md / mcp.json / skill mount
1402
+ // self-heals byte-identically. Workspace graph instructions were rehydrated
1403
+ // from the bus channel above; single-project runs leave the map empty exactly
1404
+ // as on a fresh run, and the generator tolerates a missing instruction per
1405
+ // member. A member real dir deleted while paused degrades per §8.20 — a
1406
+ // missing SOURCE never throws (a missing worktree still hard-fails, above).
1407
+ if (this.runRootMode === 'detached') {
1408
+ await this._assembleContext(replayedSkills ?? (resumeManifest?.skillResolutions ?? new Map()));
1409
+ // AFTER the assembly: it rewrites run.json.warnings wholesale, so recording
1410
+ // this first would drop it from the durable ledger.
1411
+ if (!resumeManifest && !replayedSkills) {
1412
+ await this._recordRunWarning(
1413
+ 'run.json was missing or unparseable, so the bundle/plugin skills this run mounted ' +
1414
+ 'could not be restored to the skill mount; real-dir and root skills were re-mounted ' +
1415
+ 'normally. An agent that declares `requiresSkills` may not find its skill.',
1416
+ );
1417
+ }
1418
+ }
1419
+
1420
+ const dispatched = await this._engineRun({ resume: rp, rehydrated });
1421
+ this._checkAbort();
1422
+ if (dispatched === 'paused') return await this._completePaused();
1423
+
1424
+ this._setStatus('done');
1425
+ this.state.resumePoint = null; // finished rows are not resumable (clears the boundary trail)
1426
+ this._bookend('done', 'done');
1427
+ await this._persist();
1428
+ await appendAudit(this.pipeline.dir, `Pipeline finished with status **done**.`);
1429
+ await this._buildResults(); // refs + worktree still live here
1430
+ await this._reportToSource(); // task-source write-back (never throws, spec §7.5)
1431
+ this._emit('done', { status: 'done', pipelineDir: this.pipeline.dir });
1432
+ return { status: 'done', pipelineDir: this.pipeline.dir };
1433
+ } catch (err) {
1434
+ if ((isPause(err) || this.state.status === 'pausing') && this.state.status !== 'stopped') {
1435
+ // A plain error that landed while a user pause was unwinding (a replayed setup
1436
+ // step that failed under the pause): the user's pause keeps the reason, the run
1437
+ // log keeps the failure. _completePaused re-stamps setupIncomplete from _setupDone.
1438
+ if (!isPause(err) && !isAbort(err)) {
1439
+ this._log('orchestrator', 'error', `failed while pausing: ${clipMiddle(err?.message || err, 500)}`, err?.stream ? ERR_STREAM : null);
1440
+ }
1441
+ if (this.pipeline) {
1442
+ if (!this.state.resumePoint) this.state.resumePoint = rp; // re-arm the consumed point: a paused row must stay resumable
1443
+ return await this._completePaused();
1444
+ }
1445
+ }
1446
+ if (isAbort(err) || this.state.status === 'stopped') {
1447
+ this._setStatus('stopped');
1448
+ // Stopped runs are not resumable: never persist a resume point alongside
1449
+ // a torn-down worktree (mirrors run()'s stopped branch).
1450
+ this.state.resumePoint = null;
1451
+ if (this.pipeline) {
1452
+ await this._persist().catch(() => {});
1453
+ await appendAudit(this.pipeline.dir, `Pipeline **stopped**.`).catch(() => {});
1454
+ // The diff artifact must survive a non-done terminal path too: the work done
1455
+ // up to this point IS committed onto the kept feature branch by the teardown
1456
+ // in the finally below, so History has to be able to show it. Safe HERE and
1457
+ // only here — the checkpoint refs and the worktree are still live until that
1458
+ // teardown runs. Best-effort by construction (its own try/catch logs a warn
1459
+ // and never rethrows), and a no-op when the run stopped before any checkpoint
1460
+ // existed. The terminal `done` event is emitted AFTER it so the History row never
1461
+ // paints as "no diff captured" for the tick before the artifact lands.
1462
+ await this._buildResults({ stage: true });
1463
+ await this._reportToSource(); // statusToResult('stopped') -> 'failed' (design PR12: no longer success-only)
1464
+ }
1465
+ this._emit('done', { status: 'stopped', pipelineDir: this.pipeline?.dir || null });
1466
+ return { status: 'stopped', pipelineDir: this.pipeline?.dir || null };
1467
+ }
1468
+ if (this.pipeline) {
1469
+ // The SETUP / SHELL site (failure-policy.mjs). `rp` is the point this resume
1470
+ // consumed — the fallback when the engine holds none.
1471
+ try {
1472
+ const paused = await this._pauseForFailure(err, rp);
1473
+ if (paused) return paused;
1474
+ } catch (err2) {
1475
+ // Last resort: the pause bookkeeping itself failed. Fall through to today's
1476
+ // error shape (persist/audit/results/write-back) rather than reject resume().
1477
+ // The finally tears the checkout down on 'error' — never leave a
1478
+ // point that names it (today's error branch does not clear it; the stop branch does).
1479
+ this._log('orchestrator', 'error', `pause bookkeeping failed: ${err2?.message || err2} — ending the run as a launch error`);
1480
+ this.state.resumePoint = null;
1481
+ }
1482
+ }
1483
+ this._setStatus('error');
1484
+ const message = err?.message || String(err);
1485
+ this._emit('error', { message });
1486
+ if (this.pipeline) {
1487
+ await this._persist().catch(() => {});
1488
+ await appendAudit(this.pipeline.dir, `Pipeline **error**: ${message}`).catch(() => {});
1489
+ // The diff artifact must survive a non-done terminal path too: the work done
1490
+ // up to this point IS committed onto the kept feature branch by the teardown
1491
+ // in the finally below, so History has to be able to show it. Safe HERE and
1492
+ // only here — the checkpoint refs and the worktree are still live until that
1493
+ // teardown runs. Best-effort by construction (its own try/catch logs a warn
1494
+ // and never rethrows), and a no-op when the run stopped before any checkpoint
1495
+ // existed. The terminal `done` event is emitted AFTER it so the History row never
1496
+ // paints as "no diff captured" for the tick before the artifact lands.
1497
+ await this._buildResults({ stage: true });
1498
+ await this._reportToSource(); // statusToResult('error') -> 'failed' (design PR12: no longer success-only)
1499
+ }
1500
+ this._emit('done', { status: 'error', pipelineDir: this.pipeline?.dir || null });
1501
+ return { status: 'error', pipelineDir: this.pipeline?.dir || null, error: message };
1502
+ } finally {
1503
+ this._stopHeartbeat(); // clear timer + NULL owner columns (done/stopped/launch-error/paused)
1504
+ // Same teardown as run()'s finally — wiring only run()'s would keep legacy
1505
+ // teardown on every detached run that finishes after a resume (including every
1506
+ // crash-interrupted run, §8.12's primary scenario): run root leaked until the
1507
+ // next boot, no stray scan, no injected-path cleanup.
1508
+ if (this.state.status !== 'paused' && this.state.status !== 'pausing') {
1509
+ await this._teardownRunRoot().catch(() => {});
1510
+ }
1511
+ await this.logWriter.close().catch(() => {}); // flush + stop timer (last, to capture teardown logs)
1512
+ }
1513
+ }
1514
+
1515
+ /** Single-project branch resolution — VERBATIM _setupWorktree semantics.
1516
+ * Deliberately NOT _resolveMemberBranches, which would (a) suffix an explicit
1517
+ * feature with `-<projectName slug>` (breaking test/orchestrator-worktree.test.mjs,
1518
+ * 'explicit featureBranch is honored verbatim'), (b) derive suggested names from
1519
+ * `opts.title + projectName` (suggestBranchName is title-first, so derived names
1520
+ * would come from the project name instead of the prompt), and (c) silently swap
1521
+ * an invalid --source for the default branch, where createWorktree's M1 gate must
1522
+ * keep failing loudly. */
1523
+ async _resolveSingleBranches() {
1524
+ const source = this.branchOpts.source || (await resolveDefaultBranch(this.projectDir));
1525
+ const featureRaw = this.branchOpts.feature
1526
+ ? sanitizeBranchName(this.branchOpts.feature)
1527
+ : suggestBranchName({ prompt: this.pipeline.promptText,
1528
+ title: this.opts.title || null,
1529
+ pipelineId: this.pipeline.id });
1530
+ return { source, featureRaw };
1531
+ }
1532
+
1533
+ /**
1534
+ * Set up the run root + every member worktree (§5.2 step 5). Replaces
1535
+ * _setupWorktree / _setupWorktreeAll with ONE path for both targets.
1536
+ *
1537
+ * Detached-only in this step: run-root/`repos/` creation, the baseDir/checkoutName
1538
+ * inputs to createWorktree, and the manifest write. EVERYTHING else — branch
1539
+ * resolution, workDirs/branchInfos/state.branches registration, the scalar
1540
+ * mirrors, the mode stamp, the persist + emit — runs identically in both modes.
1541
+ */
1542
+ async _setupRunRoot({ replay = false } = {}) {
1543
+ this.state.branches = this.state.branches || {}; // belt-and-braces for resumed/legacy shapes
1544
+ // A resume REPLAY keeps the mode the row recorded — never the live flag — unless
1545
+ // the paused run never got far enough to record one (then this IS run()'s read).
1546
+ if (!replay || !this._modeRecorded) this.runRootMode = runRootMode(); // §10 flag, read ONCE, here, per pipeline
1547
+ this.state.runRootMode = this.runRootMode; // top-level pin → workspace_meta (artifacts.mjs)
1548
+ const detached = this.runRootMode === 'detached';
1549
+ this.runRoot = detached ? join(worcaHome(), 'runs', this.pipeline.id) : null;
1550
+ const reposBase = detached ? join(this.runRoot, 'repos') : null;
1551
+ if (detached) await mkdir(reposBase, { recursive: true });
1552
+
1553
+ this._log('worktree', 'info', `Resolving source/feature branches for ${this.members.length} member(s)…`);
1554
+ // Settle EVERY member before propagating any failure — carried VERBATIM from
1555
+ // _setupWorktreeAll. mapWithCap is Promise.all: it rejects the instant one
1556
+ // member throws and would abandon an in-flight sibling whose worktree
1557
+ // materializes AFTER run()'s finally has snapshotted branchInfos — an orphaned
1558
+ // checkout on disk. Under legacy that orphan sits INSIDE the user's repo with
1559
+ // the legacy sweep disabled, i.e. permanent. The partial-setup test
1560
+ // (test/orchestrator-workspace.test.mjs) guards exactly this.
1561
+ const setupFailures = [];
1562
+ await mapWithCap(this.members, fanoutCap(), async (m) => {
1563
+ // Replay: a member whose checkout survived the pause is already re-attached by
1564
+ // resume() (workDirs/branchInfos/state.branches); `git worktree add` onto the
1565
+ // live dir would fail. (This skips the per-member "Worktree `<key>`" audit line
1566
+ // for kept members — say so in the run log instead.)
1567
+ const kept = replay ? this.workDirs.get(m.projectKey) : null;
1568
+ if (kept && existsSync(kept)) {
1569
+ this._log('orchestrator', 'info', `setup replay: ${m.projectKey} keeps its checkout ${kept}`);
1570
+ return;
1571
+ }
1572
+ try {
1573
+ const { source, featureRaw } = this.isWorkspace
1574
+ ? await this._resolveMemberBranches(m) // unchanged (member-suffixed names)
1575
+ : await this._resolveSingleBranches(); // single: today's exact semantics
1576
+ const info = await createWorktree({
1577
+ projectDir: resolve(m.projectDir), // the REAL dir: git runs here
1578
+ pipelineId: this.pipeline.id,
1579
+ // detached ⇒ <runRoot>/repos/<projectKey>, uniqueness from the run root.
1580
+ // legacy ⇒ both omitted, so worktree.mjs falls back to its retained
1581
+ // default <projectDir>/.worca-cc/worktrees/<pipelineId> (§10).
1582
+ ...(detached ? { baseDir: reposBase, checkoutName: m.projectKey } : {}),
1583
+ sourceBranch: source,
1584
+ featureBranch: featureRaw,
1585
+ signal: this.abort.signal,
1586
+ });
1587
+ // Register EAGERLY (Map.set is synchronous) so teardown always sees it.
1588
+ this.workDirs.set(m.projectKey, info.worktreeDir);
1589
+ this.branchInfos.set(m.projectKey, info);
1590
+ this.state.branches[m.projectKey] = { source: info.sourceBranch, feature: info.branch,
1591
+ worktreeDir: info.worktreeDir,
1592
+ reusedExisting: info.reusedExisting };
1593
+ const reuseNote = info.reusedExisting ? ' (resumed existing branch)' : '';
1594
+ await appendAudit(this.pipeline.dir,
1595
+ `Worktree \`${m.projectKey}\`: \`${info.branch}\` (off \`${info.sourceBranch}\`)${reuseNote} at \`${info.worktreeDir}\`.`,
1596
+ ).catch(() => {}); // per-member audit
1597
+ } catch (err) {
1598
+ setupFailures.push(err);
1599
+ }
1600
+ });
1601
+ if (setupFailures.length) {
1602
+ throw setupFailures[0] instanceof Error ? setupFailures[0] : new Error(String(setupFailures[0]));
1603
+ }
1604
+
1605
+ const primary = this.members[0]; // members sorted by projectKey; single: the only one
1606
+ this.workDir = this.workDirs.get(primary.projectKey); // back-compat scalar (display, PR route)
1607
+ this.branchInfo = this.branchInfos.get(primary.projectKey);
1608
+ this.runCwd = (detached && this.isWorkspace)
1609
+ ? this.runRoot // neutral cwd (§5.8)
1610
+ : this.workDirs.get(primary.projectKey); // single-project detached, or either mode under legacy
1611
+ if (!this.isWorkspace) {
1612
+ // Single: the mode pin rides state.branch (pipelines.branch column).
1613
+ this.state.branch = { ...this.state.branches[primary.projectKey], runRootMode: this.runRootMode };
1614
+ } else {
1615
+ // Workspace: the scalar mirror is KEPT for display/back-compat readers. NOTE
1616
+ // the precise consumer set: workspace pipeline-delete iterates state.branches
1617
+ // per member; it is the SINGLE-project delete path that reads state.branch.
1618
+ // The pin rides workspace_meta.runRootMode via this.state.runRootMode + the
1619
+ // artifacts.mjs whitelist delta.
1620
+ this.state.branch = { ...this.state.branches[primary.projectKey] };
1621
+ }
1622
+ // Minimal manifest, written HERE: the boot sweep and pipeline-delete need member
1623
+ // real dirs + worktree paths from the very first detached run, before any context
1624
+ // field exists. Legacy runs have no run root and therefore no manifest — the
1625
+ // sweeps fall back to the DB columns.
1626
+ if (detached) await writeRunManifest(this.runRoot, {
1627
+ pipelineId: this.pipeline.id,
1628
+ runRootMode: this.runRootMode,
1629
+ isWorkspace: this.isWorkspace,
1630
+ members: this.members.map((m) => ({
1631
+ projectKey: m.projectKey, projectName: m.projectName,
1632
+ projectDir: resolve(m.projectDir), // the REAL repo — what `git worktree remove` needs
1633
+ worktreeDir: this.workDirs.get(m.projectKey),
1634
+ })),
1635
+ });
1636
+ await this._persist();
1637
+ this._emit('state', this.getState());
1638
+ }
1639
+
1640
+ /**
1641
+ * Resolve THE run's guardrails: the per-run selected set (this.guardrailsId,
1642
+ * default 'permissive') IS the policy — member project configs are NOT read
1643
+ * (per-project guardrails were removed; one set applies uniformly to every
1644
+ * member). Built-ins resolve from GUARDRAIL_PRESETS at read time; user sets
1645
+ * from the store at read time. Called from run() AND resume() — resume
1646
+ * re-reads the set by id, so a set edited while paused is enforced at its
1647
+ * LATEST definition. A missing/deleted set fails OPEN to the Permissive
1648
+ * (empty) policy with a loud warn — never an abort.
1649
+ */
1650
+ async _resolveGuardrails() {
1651
+ let set = await readGuardrailSet(this.guardrailsId || 'permissive');
1652
+ if (!set) {
1653
+ this._log('guardrails', 'warn',
1654
+ `guardrail set "${this.guardrailsId}" not found; running with the Permissive (empty) policy`);
1655
+ set = await readGuardrailSet('permissive'); // virtual built-in: always resolves
1656
+ }
1657
+ // One UNIFORM honor value for every member: the run set's honorProjectSettings
1658
+ // gates the per-member repo-settings deny lift. The map SHAPE is unchanged
1659
+ // (run-context.mjs's honorByKey consumer is untouched); only its values are
1660
+ // uniform now — there is no per-member saved preference anymore.
1661
+ const honor = set.settings.honorProjectSettings !== false;
1662
+ this.guardrailHonorByKey = new Map(this.members.map((m) => [m.projectKey, honor]));
1663
+ // unionGuardrails over the ONE-element list keeps the tested normalization
1664
+ // path (fresh arrays, de-dupe, a non-scrubbing set's dormant allowlist
1665
+ // drops — enforcement gates allowlist on envScrub anyway): the run's set is
1666
+ // the whole union. Its envAllowlist is NOT stripped — it IS the policy;
1667
+ // there is no member policy to relax against.
1668
+ this.guardrails = unionGuardrails([set.settings]);
1669
+ this.guardrailPermissionRules = guardrailsToPermissionRules(this.guardrails);
1670
+ }
1671
+
1672
+ /**
1673
+ * §5.2 step 7 / §6 Phase 3: assemble the run context at the run root and wire its
1674
+ * outputs into every consumer. Called from run() (after the skills gate, with the
1675
+ * HOISTED resolutions) and from resume() (with the resolutions persisted in
1676
+ * run.json, since resume never re-runs collectRequiredSkills/validateSkills).
1677
+ *
1678
+ * Detached-only: every caller is already gated on the RECORDED mode. It is
1679
+ * deliberately NOT gated on requiredSkills.length or on mock mode — assembly is
1680
+ * pure fs work whose outputs the mock smokes assert, and nesting it under the
1681
+ * skills gate would silently void R1(a)-(d) and R2 on every default pipeline.
1682
+ * @param {Map<string,object>|object} resolvedSkills possibly EMPTY (the default workflow)
1683
+ */
1684
+ async _assembleContext(resolvedSkills) {
1685
+ // Warnings this run root ALREADY reported (from the pre-pause segment of a
1686
+ // resumed run). Assembly is idempotent, so it re-derives the same lines every
1687
+ // time; re-logging them would double every context warning — including §8.21's —
1688
+ // in the run log at each resume. run.json is the cross-instance record, so it is
1689
+ // what "already reported" means (a resumed run is a NEW orchestrator object, so an
1690
+ // in-memory Set could not see the earlier segment). Read BEFORE the assembly,
1691
+ // which rewrites `warnings` wholesale.
1692
+ const alreadyReported = new Set(
1693
+ this.runRoot ? ((await readRunManifest(this.runRoot))?.warnings ?? []) : [],
1694
+ );
1695
+ const rc = await assembleRunContext({
1696
+ runRoot: this.runRoot,
1697
+ members: this.members.map((m) => ({
1698
+ ...m,
1699
+ worktreeDir: this.workDirs.get(m.projectKey),
1700
+ // §5.4 requires the roster to carry each member's branch + checkpoint ref;
1701
+ // the generator omits either cell when it is absent (e.g. a resume whose
1702
+ // branchInfos were rehydrated without one).
1703
+ branch: this.branchInfos.get(m.projectKey)?.branch || null,
1704
+ checkpointRef: this.checkpointRefs?.[m.projectKey] || null,
1705
+ })),
1706
+ projectsRoot: getProjectsRoot(),
1707
+ isWorkspace: this.isWorkspace,
1708
+ requiredSkillResolutions: resolvedSkills, // possibly empty — a valid, common input
1709
+ graphInstructions: this.toolInstructions,
1710
+ homeDir: homedir(),
1711
+ honorByKey: this.guardrailHonorByKey,
1712
+ });
1713
+ this.runContext = rc;
1714
+ if (rc?.projectPermissions) {
1715
+ this.guardrailPermissionRules = mergePermissionRules(this.guardrailPermissionRules, rc.projectPermissions);
1716
+ }
1717
+ // Audit (spec bullet): the resolved effective policy, compact, into run.json.
1718
+ // Written HERE because runRoot exists only on detached runs and this is the
1719
+ // one site where this.guardrails and the FINAL (post-lift) rule set are both
1720
+ // in scope on run() AND resume(). updateRunManifest merges the patch
1721
+ // (run-manifest.mjs:79-82), and a resume re-writes the same values
1722
+ // idempotently. denyCount includes the lifted repo deny rules, whose exact
1723
+ // list Task 6 already persisted as run.json.projectPermissions. Legacy runs
1724
+ // have no run.json, so no audit record — run.json is a detached-run artifact.
1725
+ // guardrailsId names the selected set (id only — sets are mutable and resolve
1726
+ // by reference, so this is not a content snapshot).
1727
+ await updateRunManifest(this.runRoot, {
1728
+ guardrails: {
1729
+ envScrub: !!this.guardrails?.envScrub,
1730
+ denyCount: this.guardrailPermissionRules?.deny?.length || 0,
1731
+ protectedCount: this.guardrails?.protectedPaths?.length || 0,
1732
+ guardrailsId: this.guardrailsId,
1733
+ },
1734
+ });
1735
+ this.injectedPaths = rc.injectedPaths; // feeds _excludePathspecs / teardown / rescue (§8.8)
1736
+ this.mcpConfigPath = rc.mcpConfigPath;
1737
+ // V1-gated (§4.1 outcome table). Branch (a) PASSED on this CLI (server wildcard),
1738
+ // so one grant per merged server; the 'per-tool' / 'none' branches would leave
1739
+ // this empty and rely on the frontmatter union alone.
1740
+ this.mcpServerGrants = MCP_GRANT_MODE === 'server'
1741
+ ? rc.mcpServerNames.map((s) => `mcp__${s}`)
1742
+ : [];
1743
+ // Durable per §5.2's ledger rules: the run log survives teardown, and the
1744
+ // warnings are already inside run.json (written by the assembly — which is also
1745
+ // what makes them survive a resume, since it rewrites the array from scratch).
1746
+ // Record-once semantics across a pause boundary: a line this run root already
1747
+ // reported is not repeated in the log.
1748
+ for (const w of rc.warnings) {
1749
+ if (alreadyReported.has(w)) continue;
1750
+ this._log('context', 'warn', w);
1751
+ }
1752
+ await appendAudit(this.pipeline.dir, renderContextAudit(rc)).catch(() => {});
1753
+ await this._recordCapabilities();
1754
+ return rc;
1755
+ }
1756
+
1757
+ /**
1758
+ * §8.18 / gate V5: parse `claude --help` ONCE per run and assert `--mcp-config`.
1759
+ * On absence, degrade gracefully — skip the flag, warn loudly naming the required
1760
+ * version — rather than failing the run: R1(a)/(c) still hold via the cwd and
1761
+ * ancestor mechanisms, but R1(b) is degraded and is REPORTED as degraded. V5
1762
+ * passed on the development machine; this stays shipped as version-drift
1763
+ * insurance for other machines.
1764
+ *
1765
+ * Mock runs never spawn `claude`, so the probe is skipped there (it would add a
1766
+ * subprocess to every test for an answer no mock run can act on) and recorded as
1767
+ * unprobed.
1768
+ */
1769
+ async _recordCapabilities() {
1770
+ if (!this.runRoot) return;
1771
+ if (this.claude.mock) {
1772
+ await updateRunManifest(this.runRoot, {
1773
+ capabilities: { mcpGrants: MCP_GRANT_MODE, mcpConfig: null, version: null, probed: false },
1774
+ });
1775
+ return;
1776
+ }
1777
+ const caps = await probeClaudeCapabilities(this.claude.bin);
1778
+ if (caps.version === null) {
1779
+ // No `claude --version` at all. The first node fails loudly anyway; when the
1780
+ // cause is the Windows npm shim, record the actionable reason NOW so the
1781
+ // run's warnings carry it instead of only a spawn ENOENT at the first node.
1782
+ const hint = explainUnspawnableClaude(this.claude.bin);
1783
+ if (hint) await this._recordRunWarning(hint);
1784
+ }
1785
+ if (!caps.mcpConfig && this.mcpConfigPath) {
1786
+ await this._recordRunWarning(
1787
+ `this \`claude\` build does not advertise --mcp-config (version ${caps.version || 'unknown'}); ` +
1788
+ 'worca-cc needs >= 2.1.220 to deliver project/root MCP servers. Skipping the flag — ' +
1789
+ "R1(b) is DEGRADED for this run: the merged servers in mcp.json are NOT available to any agent.",
1790
+ );
1791
+ this.mcpConfigPath = null;
1792
+ this.mcpServerGrants = [];
1793
+ }
1794
+ await updateRunManifest(this.runRoot, {
1795
+ capabilities: { mcpGrants: MCP_GRANT_MODE, ...caps, probed: true },
1796
+ });
1797
+ }
1798
+
1799
+ /**
1800
+ * Resolve the worktree source/feature branch pair for ONE member (D2). The named
1801
+ * source (run-level or per-member) is used only when it resolves to a real commit
1802
+ * IN THAT member's repo; otherwise the member's own default branch. The feature is
1803
+ * the run-level featureBranch suffixed with the project slug (so members never
1804
+ * collide on one branch name), or a suggested name when none was given.
1805
+ * @param {{projectDir,projectKey,projectName,branch?:{source?,feature?}}} m
1806
+ * @returns {Promise<{source:string, featureRaw:string}>}
1807
+ */
1808
+ async _resolveMemberBranches(m) {
1809
+ const dir = resolve(m.projectDir);
1810
+ const named = (m.branch && m.branch.source) || this.branchOpts.source || null;
1811
+ const source = (named && (await isValidSourceRef(dir, named)))
1812
+ ? named
1813
+ : await resolveDefaultBranch(dir);
1814
+ const feature = (m.branch && m.branch.feature) || this.branchOpts.feature || null;
1815
+ const featureRaw = feature
1816
+ ? sanitizeBranchName(`${feature}-${slugify(m.projectName)}`)
1817
+ : suggestBranchName({
1818
+ prompt: this.pipeline.promptText,
1819
+ title: `${this.opts.title || ''} ${m.projectName}`.trim() || null,
1820
+ pipelineId: this.pipeline.id,
1821
+ });
1822
+ return { source, featureRaw };
1823
+ }
1824
+
1825
+ /**
1826
+ * Build a graphify AST graph INSIDE the worktree so agents (which run with
1827
+ * cwd=workDir) can query it. graphify-out/ is gitignored, so it never reaches
1828
+ * the reviewer diff, the kept-branch commit, or survives teardown.
1829
+ *
1830
+ * Fail-safe — never throws. Skipped when: mock mode (keeps `npm run smoke`
1831
+ * offline); no worktree was created; or the graphify binary is not on PATH.
1832
+ * On build failure/timeout the run proceeds with no graph instruction.
1833
+ */
1834
+ async _buildWorktreeGraph() {
1835
+ if (this.claude.mock) return; // mock runs never use the graph (intentionally silent)
1836
+ if (this.workDir === this.projectDir) {
1837
+ this._log('graph', 'debug', 'No worktree (workDir===projectDir); skipping in-worktree graph build.');
1838
+ return; // building "in the worktree" would write into main
1839
+ }
1840
+ if (this.state.tools?.kind !== 'cli') {
1841
+ this.toolInstruction = '';
1842
+ this._log('graph', 'info', 'graphify CLI not on PATH; skipping in-worktree graph build');
1843
+ return;
1844
+ }
1845
+ this._log('graph', 'info', 'Building graphify graph in worktree (AST-only, no LLM)…');
1846
+ const res = await runGraphifyUpdate({
1847
+ dir: this.workDir,
1848
+ cwd: this.workDir,
1849
+ timeoutMs: this.graphBuildTimeoutMs,
1850
+ });
1851
+ if (res.ok) {
1852
+ this.toolInstruction = worktreeGraphInstruction();
1853
+ this._log('graph', 'info', 'graphify graph built in worktree.');
1854
+ await appendAudit(this.pipeline.dir, 'Preflight: built graphify graph in worktree (AST-only).').catch(() => {});
1855
+ } else {
1856
+ this.toolInstruction = '';
1857
+ this._log(
1858
+ 'graph',
1859
+ 'warn',
1860
+ `graphify build ${res.timedOut ? 'timed out' : 'failed'}; proceeding without graph grounding`
1861
+ + errDetail(res),
1862
+ errStreamAttr(res?.stderr),
1863
+ );
1864
+ }
1865
+ }
1866
+
1867
+ /**
1868
+ * Workspace graph builds (D4): build a graphify graph inside EACH member worktree
1869
+ * in parallel (cap 4), storing this.toolInstructions[projectKey]. Fail-safe per
1870
+ * §5.8: a member whose detectTools.kind !== 'cli' or whose build fails/times out
1871
+ * degrades to '' (source-reading) WITHOUT aborting the others. Skipped wholesale
1872
+ * in mock mode (keeps `npm run smoke` offline + deterministic), matching the
1873
+ * single-project _buildWorktreeGraph mock guard.
1874
+ */
1875
+ async _buildWorktreeGraphAll() {
1876
+ if (this.claude.mock) return; // mock runs never use the graph (intentionally silent)
1877
+ const dirs = this.members.map((m) => resolve(m.projectDir));
1878
+ const toolsByDir = await detectToolsPerProject(dirs); // never throws
1879
+ await mapWithCap(this.members, 4, async (m) => {
1880
+ const workDir = this.workDirs.get(m.projectKey);
1881
+ const info = toolsByDir.get(resolve(m.projectDir));
1882
+ if (!workDir || workDir === resolve(m.projectDir)) {
1883
+ this.toolInstructions.set(m.projectKey, '');
1884
+ return;
1885
+ }
1886
+ if (info?.kind !== 'cli') {
1887
+ this.toolInstructions.set(m.projectKey, '');
1888
+ this._log('graph', 'info', `graphify CLI not on PATH for ${m.projectKey}; skipping graph build`);
1889
+ return;
1890
+ }
1891
+ const res = await runGraphifyUpdate({ dir: workDir, cwd: workDir, timeoutMs: this.graphBuildTimeoutMs });
1892
+ if (res.ok) {
1893
+ this.toolInstructions.set(m.projectKey, worktreeGraphInstruction());
1894
+ this._log('graph', 'info', `graphify graph built in ${m.projectKey} worktree.`);
1895
+ await appendAudit(this.pipeline.dir, `Preflight: built graphify graph for ${m.projectKey} (AST-only).`).catch(() => {});
1896
+ } else {
1897
+ this.toolInstructions.set(m.projectKey, '');
1898
+ this._log('graph', 'warn',
1899
+ `graphify build for ${m.projectKey} ${res.timedOut ? 'timed out' : 'failed'}; degrading to source-reading`
1900
+ + errDetail(res),
1901
+ errStreamAttr(res?.stderr));
1902
+ }
1903
+ });
1904
+ }
1905
+
1906
+ /**
1907
+ * Tear down the per-pipeline worktree (C1). Retention policy:
1908
+ * - Remove the checkout and keep the feature branch after a successful (or
1909
+ * unnecessary) commit. If git status/add/commit fails, retain the checkout
1910
+ * so its uncommitted work remains recoverable.
1911
+ * Always force:true — agents have edited files, so the non-force path would
1912
+ * refuse and leak. Idempotent; safe to call when setup never ran.
1913
+ */
1914
+ async _teardownWorktree() {
1915
+ const info = this.branchInfo;
1916
+ if (!info || !info.worktreeDir) return;
1917
+ this.branchInfo = null; // guard against a double teardown
1918
+ // Commit the agent's work onto the feature branch BEFORE removal. Without
1919
+ // this, removeWorktree(force:true) discards the working tree and the kept
1920
+ // branch carries no changes (the staging in _stageWorkingTree is intent-to-add
1921
+ // for the reviewer's diff only — it never creates a commit). On error/stop this
1922
+ // is what captures the partial work made up to that point.
1923
+ const commit = await this._commitWork(info);
1924
+ const retained = await this._recordCommitFailure(commit, { info, branchRecord: this.state.branch });
1925
+ if (retained) {
1926
+ await this._snapshotRetained(info);
1927
+ this.workDir = this.projectDir;
1928
+ await this._persist().catch(() => {});
1929
+ return;
1930
+ }
1931
+ // branch:null — the branch is always kept (done/error/stopped alike); only the
1932
+ // disposable checkout is removed.
1933
+ const res = await removeWorktree({
1934
+ projectDir: this.projectDir,
1935
+ worktreeDir: info.worktreeDir,
1936
+ branch: null,
1937
+ force: true,
1938
+ });
1939
+ for (const s of res.steps.filter((x) => !x.ok)) {
1940
+ this._log('worktree', 'warn', `teardown ${s.step} failed: ${s.stderr || 'unknown error'}`, errStreamAttr(s.stderr));
1941
+ }
1942
+ if (this.pipeline) {
1943
+ await appendAudit(
1944
+ this.pipeline.dir,
1945
+ `Worktree removed at \`${info.worktreeDir}\` (kept branch \`${info.branch}\`).`,
1946
+ ).catch(() => {});
1947
+ }
1948
+ // Reflect the post-teardown reality in state for any late observer.
1949
+ if (this.state.branch) {
1950
+ this.state.branch.worktreeRemoved = true;
1951
+ this.state.branch.branchKept = true;
1952
+ }
1953
+ this.workDir = this.projectDir;
1954
+ await this._persist().catch(() => {});
1955
+ }
1956
+
1957
+ /**
1958
+ * Workspace teardown (C1, N times): per member, commit its work onto its feature
1959
+ * branch (in its own repo), remove its checkout, and KEEP the branch — done,
1960
+ * error, or stopped alike. Each member's SHA + survival flags are recorded on
1961
+ * state.branches[projectKey]. Idempotent (guards against a double teardown by
1962
+ * clearing branchInfos); best-effort (never throws). Iterated serially so the
1963
+ * teardown commits don't contend on interleaved git index locks across repos.
1964
+ */
1965
+ async _teardownWorktreeAll() {
1966
+ if (this.branchInfos.size === 0) return;
1967
+ const entries = [...this.branchInfos.entries()]; // [projectKey, info]
1968
+ this.branchInfos = new Map(); // guard against a double teardown
1969
+ let anyRetained = false;
1970
+ for (const [projectKey_, info] of entries) {
1971
+ if (!info || !info.worktreeDir) continue;
1972
+ const branchRecord = (this.state.branches && this.state.branches[projectKey_]) || null;
1973
+ const commit = await this._commitWork(info, branchRecord);
1974
+ if (await this._recordCommitFailure(commit, { key: projectKey_, info, branchRecord })) {
1975
+ anyRetained = true;
1976
+ await this._snapshotRetained(info, projectKey_);
1977
+ this.workDirs.delete(projectKey_);
1978
+ continue;
1979
+ }
1980
+ const res = await removeWorktree({
1981
+ projectDir: resolve(this.memberByKey.get(projectKey_)?.projectDir || this.projectDir),
1982
+ worktreeDir: info.worktreeDir,
1983
+ branch: null, // always keep the branch
1984
+ force: true,
1985
+ });
1986
+ for (const s of res.steps.filter((x) => !x.ok)) {
1987
+ this._log('worktree', 'warn', `teardown ${projectKey_} ${s.step} failed: ${s.stderr || 'unknown error'}`, errStreamAttr(s.stderr));
1988
+ }
1989
+ if (this.pipeline) {
1990
+ await appendAudit(
1991
+ this.pipeline.dir,
1992
+ `Worktree \`${projectKey_}\` removed at \`${info.worktreeDir}\` (kept branch \`${info.branch}\`).`,
1993
+ ).catch(() => {});
1994
+ }
1995
+ if (branchRecord) {
1996
+ branchRecord.worktreeRemoved = true;
1997
+ branchRecord.branchKept = true;
1998
+ }
1999
+ this.workDirs.delete(projectKey_);
2000
+ }
2001
+ // Keep the scalar mirror coherent for late observers — but never claim a
2002
+ // retained checkout was removed (the detached twin guards the same way,
2003
+ // via !retainedMembers.length).
2004
+ if (this.state.branch && !anyRetained) {
2005
+ this.state.branch.worktreeRemoved = true;
2006
+ this.state.branch.branchKept = true;
2007
+ }
2008
+ this.branchInfo = null;
2009
+ this.workDir = this.projectDir;
2010
+ await this._persist().catch(() => {});
2011
+ }
2012
+
2013
+ /**
2014
+ * The ONLY owner of normal-path teardown, wired into BOTH terminal `finally`
2015
+ * blocks (run()'s and resume()'s — the latter is an identical bare per-member
2016
+ * teardown today, so wiring only run()'s would keep legacy teardown on every
2017
+ * detached run finishing after a resume, i.e. every crash-interrupted run).
2018
+ * Still skipped entirely when the run paused (§8.13) — the caller guards.
2019
+ *
2020
+ * Under `legacy` this delegates to today's _teardownWorktree / _teardownWorktreeAll
2021
+ * verbatim and does nothing else. Under `detached`, per member, in NORMATIVE order:
2022
+ * 1. modified-mount rescue (§8.20) — read-only, so it survives any later failure
2023
+ * 2. strip every claudeMdSection fenced block (must precede the commit — that
2024
+ * file is deliberately NOT in the exclusion pathspecs)
2025
+ * 3. _commitWork with the §8.8 exclusion set (+ status recheck, hook retry)
2026
+ * 4. remove this worktree's remaining injected paths
2027
+ * 5. removeWorktree(force:true) — the branch is ALWAYS kept
2028
+ * then, at the run-root level: (6) the same rescue for run-root mounts, (7) the
2029
+ * §8.11 stray scan, (8) the run.json durability copy, (9) guarded rm -rf (§8.13).
2030
+ */
2031
+ async _teardownRunRoot() {
2032
+ if (this.runRootMode !== 'detached') {
2033
+ if (this.isWorkspace) await this._teardownWorktreeAll();
2034
+ else await this._teardownWorktree();
2035
+ return;
2036
+ }
2037
+ const pipelineDir = this.pipeline?.dir || null;
2038
+ const entries = [...this.branchInfos.entries()]; // [projectKey, info]
2039
+ this.branchInfos = new Map(); // guard against a double teardown
2040
+ const retainedMembers = [];
2041
+ for (const [key, info] of entries) {
2042
+ if (!info || !info.worktreeDir) continue;
2043
+ const wt = info.worktreeDir;
2044
+ const injected = this.injectedPaths?.[key] ?? [];
2045
+ // (1) rescue FIRST — read-only, so a later step failing cannot lose the edit.
2046
+ const rescued = await rescueModifiedMounts({
2047
+ baseDir: wt, entries: injected, pipelineDir, scope: key, pipelineId: this.pipeline?.id,
2048
+ });
2049
+ for (const w of rescued) await this._recordRunWarning(w);
2050
+ // (2) strip the worca-cc-managed CLAUDE.md fence BEFORE the commit.
2051
+ for (const e of injected) {
2052
+ if (e?.kind !== 'claudeMdSection' || !e.path) continue;
2053
+ try {
2054
+ const file = join(wt, e.path);
2055
+ const before = await readFile(file, 'utf8');
2056
+ const after = stripClaudeMdFence(before, this.pipeline?.id);
2057
+ if (after !== before) await writeFile(file, after, 'utf8');
2058
+ } catch { /* best-effort: a missing file needs no strip */ }
2059
+ }
2060
+ // (3) commit onto the kept branch, excluding every injected path.
2061
+ // Single-project rows persist state.branch; workspace rows persist the
2062
+ // per-member map inside workspace_meta. Updating state.branches for a
2063
+ // single run would be in-memory-only on the DB round trip.
2064
+ const branchRecord = this.isWorkspace
2065
+ ? ((this.state.branches && this.state.branches[key]) || null)
2066
+ : this.state.branch;
2067
+ const commit = await this._commitWork(
2068
+ info, branchRecord, { excludePathspecs: this._excludePathspecs(key) },
2069
+ );
2070
+ const retained = await this._recordCommitFailure(commit, { key, info, branchRecord });
2071
+ // (4) remove what worca-cc injected, so nothing can be committed dangling or
2072
+ // outlive the run root.
2073
+ await removeInjectedPaths(wt, injected);
2074
+ if (retained) {
2075
+ await this._snapshotRetained(info, key);
2076
+ retainedMembers.push({
2077
+ projectKey: key,
2078
+ worktreeDir: wt,
2079
+ branch: info.branch,
2080
+ step: commit.step,
2081
+ message: commit.message,
2082
+ at: branchRecord?.commitFailed?.at || new Date().toISOString(),
2083
+ });
2084
+ this.workDirs.delete(key);
2085
+ continue;
2086
+ }
2087
+ // (5) remove the checkout; the branch is always kept.
2088
+ const res = await removeWorktree({
2089
+ projectDir: resolve(this.memberByKey.get(key)?.projectDir || this.projectDir),
2090
+ worktreeDir: wt,
2091
+ branch: null,
2092
+ force: true,
2093
+ });
2094
+ for (const s of res.steps.filter((x) => !x.ok)) {
2095
+ this._log('worktree', 'warn', `teardown ${key} ${s.step} failed: ${s.stderr || 'unknown error'}`, errStreamAttr(s.stderr));
2096
+ }
2097
+ if (this.pipeline) {
2098
+ await appendAudit(
2099
+ this.pipeline.dir,
2100
+ `Worktree \`${key}\` removed at \`${wt}\` (kept branch \`${info.branch}\`).`,
2101
+ ).catch(() => {});
2102
+ }
2103
+ if (branchRecord) {
2104
+ branchRecord.worktreeRemoved = true;
2105
+ branchRecord.branchKept = true;
2106
+ }
2107
+ this.workDirs.delete(key);
2108
+ }
2109
+ // Keep the scalar mirror coherent for late observers.
2110
+ if (this.state.branch && !retainedMembers.length) {
2111
+ this.state.branch.worktreeRemoved = true;
2112
+ this.state.branch.branchKept = true;
2113
+ }
2114
+ this.branchInfo = null;
2115
+ this.workDir = this.projectDir;
2116
+
2117
+ if (this.runRoot) {
2118
+ // (6) run-root mounts (the workspace skill mount) — `.claude/` is whitelisted
2119
+ // by the §8.11 known set, so only this rescue can catch edits inside it.
2120
+ const rootRescued = await rescueModifiedMounts({
2121
+ baseDir: this.runRoot, entries: this.injectedPaths?.runRoot ?? [], pipelineDir,
2122
+ scope: 'runRoot', pipelineId: this.pipeline?.id,
2123
+ });
2124
+ for (const w of rootRescued) await this._recordRunWarning(w);
2125
+ // (7) §8.11 stray scan — nothing outside the known set is silently lost.
2126
+ const strays = await scanStrayEntries({ runRoot: this.runRoot, pipelineDir });
2127
+ for (const w of strays) await this._recordRunWarning(w);
2128
+ // Persist the retention decision before copying the manifest. The copy is
2129
+ // the durable explanation after a normal teardown removes the run root.
2130
+ await updateRunManifest(this.runRoot, {
2131
+ retain: retainedMembers.length ? {
2132
+ reason: RETAIN_REASONS.COMMIT_FAILED,
2133
+ at: retainedMembers[0].at,
2134
+ members: retainedMembers,
2135
+ } : null,
2136
+ });
2137
+ // (8) §5.2 durable ledger: the run root is about to disappear.
2138
+ await copyRunManifestTo(this.runRoot, pipelineDir);
2139
+ // (9) guarded removal (§8.13).
2140
+ if (retainedMembers.length) {
2141
+ this._log('worktree', 'warn',
2142
+ `Run root retained at ${this.runRoot} because ${retainedMembers.length} worktree commit(s) failed.`);
2143
+ } else {
2144
+ const removal = await rmGuarded(this.runRoot, {
2145
+ worcaHome: worcaHome(), pipelineId: this.pipeline?.id,
2146
+ });
2147
+ if (removal.removed) {
2148
+ this._log('worktree', 'info', `Run root removed at ${this.runRoot}.`);
2149
+ } else {
2150
+ this._log('worktree', 'warn', `run root NOT removed: ${removal.reason}`);
2151
+ }
2152
+ }
2153
+ }
2154
+ await this._persist().catch(() => {});
2155
+ }
2156
+
2157
+ /**
2158
+ * Append a warning to BOTH durable sinks (§5.2's ledger rules): the run log (which
2159
+ * survives teardown inside the pipeline artifact dir) and `run.json.warnings` (the
2160
+ * live manifest, copied out before removal). Never throws.
2161
+ */
2162
+ async _recordRunWarning(text, attr = null) {
2163
+ this._log('worktree', 'warn', text, attr);
2164
+ if (!this.runRoot) return;
2165
+ try {
2166
+ const cur = (await readRunManifest(this.runRoot)) || {};
2167
+ const warnings = Array.isArray(cur.warnings) ? cur.warnings : [];
2168
+ await updateRunManifest(this.runRoot, { warnings: [...warnings, text] });
2169
+ } catch { /* best-effort */ }
2170
+ }
2171
+
2172
+ /**
2173
+ * Best-effort durable copy of the retained work, written the moment retention
2174
+ * is decided — a crash or manual deletion before an explicit discard must not
2175
+ * leave the checkout as the only copy. Failure (or a clean tree) keeps the
2176
+ * worktree as the source of truth (same failure class as the commit itself).
2177
+ */
2178
+ async _snapshotRetained(info, key = null) {
2179
+ const pipelineDir = this.pipeline?.dir;
2180
+ if (!pipelineDir || !info?.worktreeDir) return;
2181
+ const name = retainedWorkPatchName(this.isWorkspace ? key : null);
2182
+ const snap = await snapshotWorktreePatch(info.worktreeDir, join(pipelineDir, name));
2183
+ if (snap.ok && snap.file) {
2184
+ recordArtifact(this.pipeline.id, 'retained-work-patch', name);
2185
+ this._log('git', 'info', `Retained-work recovery patch saved: ${name}`);
2186
+ } else if (snap.ok) {
2187
+ this._log('git', 'info', 'Retained-work snapshot skipped: nothing uncommitted to save.');
2188
+ } else {
2189
+ this._log('git', 'warn',
2190
+ `retained-work patch not saved (git ${snap.step}: ${snap.message}); the worktree is the only copy`,
2191
+ snap.fromStderr ? ERR_STREAM : null);
2192
+ }
2193
+ }
2194
+
2195
+ /**
2196
+ * Stamp a failed teardown commit on its persisted branch record and emit both
2197
+ * human-readable durable traces. Returns true when the caller must keep the
2198
+ * checkout containing the uncommitted work.
2199
+ */
2200
+ async _recordCommitFailure(result, { key = null, info, branchRecord } = {}) {
2201
+ if (result?.ok !== false) return false;
2202
+ const message = result.message || `git ${result.step || 'commit'} failed`;
2203
+ const record = {
2204
+ code: RETAIN_REASONS.COMMIT_FAILED,
2205
+ step: result.step,
2206
+ message,
2207
+ at: new Date().toISOString(),
2208
+ };
2209
+ let target = branchRecord;
2210
+ if (!target) {
2211
+ // Synthesize the record: retention must ALWAYS be visible to
2212
+ // retainedWorkFor/archive/discard, not only to a human reading warnings.
2213
+ // branchRecord came FROM state.branches[key] / state.branch, so a null one
2214
+ // means that slot is empty — this never overwrites a non-null record.
2215
+ target = { feature: info?.branch || null, worktreeDir: info?.worktreeDir || null };
2216
+ if (this.isWorkspace && key != null) {
2217
+ this.state.branches[key] = target;
2218
+ } else {
2219
+ this.state.branch = target;
2220
+ }
2221
+ await this._recordRunWarning(
2222
+ `${key ? `${key}: ` : ''}commit failed at git ${result.step} (${message}) with no branch record; ` +
2223
+ `synthesized one for the retained worktree at ${info?.worktreeDir || '(unknown)'}`,
2224
+ result.fromStderr ? ERR_STREAM : null,
2225
+ );
2226
+ }
2227
+ target.commitFailed = record;
2228
+ target.worktreeRemoved = false;
2229
+ target.branchKept = true;
2230
+ const prefix = key ? `${key}: ` : '';
2231
+ this._log('git', 'warn',
2232
+ `${prefix}commit failed at git ${result.step} (${message}) — KEEPING the worktree at ${info?.worktreeDir}`,
2233
+ result.fromStderr ? ERR_STREAM : null);
2234
+ if (this.pipeline) {
2235
+ await appendAudit(this.pipeline.dir,
2236
+ `Commit FAILED for \`${info?.branch || '(unknown)'}\` at git ${result.step}: ${message}. ` +
2237
+ `Worktree RETAINED at \`${info?.worktreeDir || '(unknown)'}\`.`).catch(() => {});
2238
+ }
2239
+ // Persist NOW. The callers' later _persist() is best-effort/swallowed; the
2240
+ // retention stamp must not ride on it (F2's crash window). _persist() also
2241
+ // swallows internally, so call the writer directly to observe a real failure.
2242
+ try {
2243
+ await writeState(this.pipeline?.dir ?? null, this.state);
2244
+ } catch (e) {
2245
+ this._log('git', 'error',
2246
+ `retention stamp could not be persisted (${e?.message || e}); ` +
2247
+ 'the run.json retain record is the only durable copy');
2248
+ }
2249
+ return true;
2250
+ }
2251
+
2252
+ /**
2253
+ * Commit every change in the worktree onto the feature branch so the kept
2254
+ * branch actually carries the agent's work after the worktree is removed.
2255
+ * Best-effort: never throws; returns a discriminated result. Skips
2256
+ * cleanly when the working tree is clean (no diff from the checkpoint), which
2257
+ * is the truthful "no change needed" outcome. Records the SHA on state.branch.
2258
+ * @param {{worktreeDir:string, branch:string}} info the branch being kept
2259
+ * @param {object} [branchRecord] the state branch object to stamp .commit onto
2260
+ * (defaults to the scalar this.state.branch; a workspace member passes its own
2261
+ * state.branches[projectKey] so per-member SHAs are recorded distinctly).
2262
+ * @param {{excludePathspecs?:string[]}} [opts] §8.8 exclusion set for this
2263
+ * worktree. With the DEFAULT empty array — every legacy run — the method keeps
2264
+ * today's bare `git add -A` byte-identically (§10 rollback contract).
2265
+ * @returns {Promise<{ok:true,committed:boolean,sha:string|null}|
2266
+ * {ok:false,step:'status'|'add'|'commit',message:string,fromStderr:boolean}>}
2267
+ * `fromStderr` records whether `message` embeds real stderr bytes (vs. the
2268
+ * `exit N` fallback), so the caller's warn can tag its provenance truthfully.
2269
+ */
2270
+ async _commitWork(info, branchRecord = this.state.branch, { excludePathspecs = [] } = {}) {
2271
+ const cwd = info?.worktreeDir;
2272
+ if (!cwd) return { ok: true, committed: false, sha: null };
2273
+ // ignoreAbort on every call: teardown runs after stop/error has aborted the
2274
+ // signal, so binding it would no-op these commands and lose the partial work.
2275
+ const gitOpts = { cwd, ignoreAbort: true };
2276
+ const status = await this._git(['status', '--porcelain'], gitOpts);
2277
+ if (!status.ok) {
2278
+ if (!existsSync(cwd)) {
2279
+ // The checkout is gone: there is no work to retain, and stamping
2280
+ // commitFailed would create an unclearable phantom retention (F15).
2281
+ this._log('git', 'warn', `commit skipped: worktree missing at ${cwd}`);
2282
+ return { ok: true, committed: false, sha: null };
2283
+ }
2284
+ const message = status.stderr.trim() || `exit ${status.code}`;
2285
+ this._log('git', 'warn', `commit skipped: git status failed: ${message}`, errStreamAttr(status.stderr));
2286
+ return { ok: false, step: 'status', message, fromStderr: !!status.stderr.trim() };
2287
+ }
2288
+ if (!status.stdout.trim()) {
2289
+ this._log('git', 'info', 'No changes to commit (working tree clean).');
2290
+ return { ok: true, committed: false, sha: null };
2291
+ }
2292
+ const add = excludePathspecs.length
2293
+ ? await this._git(['add', '-A', '--', '.', ...excludePathspecs], gitOpts)
2294
+ : await this._git(['add', '-A'], gitOpts);
2295
+ if (!add.ok) {
2296
+ const message = add.stderr.trim() || `exit ${add.code}`;
2297
+ this._log('git', 'warn', `commit skipped: git add failed: ${message}`, errStreamAttr(add.stderr));
2298
+ return { ok: false, step: 'add', message, fromStderr: !!add.stderr.trim() };
2299
+ }
2300
+ // §8.8 status recheck: with mounts present the porcelain gate above is never
2301
+ // clean, so a run whose agent changed nothing would attempt a commit that fails
2302
+ // with "nothing to commit". Re-check what actually got staged.
2303
+ if (excludePathspecs.length) {
2304
+ const staged = await this._git(['diff', '--cached', '--quiet'], gitOpts);
2305
+ if (staged.ok) { // exit 0 => nothing staged
2306
+ this._log('git', 'info', 'No changes to commit (working tree clean).');
2307
+ return { ok: true, committed: false, sha: null };
2308
+ }
2309
+ }
2310
+ const title = this.state.title || this.baseName || 'changes';
2311
+ const msg = `worca: ${title}${this.pipeline ? `\n\nPipeline ${this.pipeline.id}` : ''}`;
2312
+ // Plain commit first (uses the repo's configured identity); fall back to a
2313
+ // local identity so a repo with no user.name/email still commits — mirrors
2314
+ // _ensureGitCheckpoint's belt-and-braces.
2315
+ let commit = await this._git(['commit', '-m', msg], gitOpts);
2316
+ if (!commit.ok) {
2317
+ commit = await this._git(
2318
+ ['-c', 'user.email=orchestrator@local', '-c', 'user.name=orchestrator', 'commit', '-m', msg],
2319
+ gitOpts,
2320
+ );
2321
+ }
2322
+ if (!commit.ok && excludePathspecs.length) {
2323
+ // §8.8 (detached runs — the same scope as the exclusion set): a failing hook
2324
+ // must never silently delete an agent's work. Teardown removeWorktree(force:true)s
2325
+ // the checkout right after a successful commit, so this commit is the ONLY thing
2326
+ // that carries the work onto the kept branch. A diff artifact does now survive
2327
+ // every terminal path (run()/resume() build results on stopped and error too),
2328
+ // but that is a read-only snapshot in the store — not a branch to check out,
2329
+ // rebase or push. Detached worktrees make hook failure MORE likely (§8.1:
2330
+ // husky/lint-staged resolve through an ancestor node_modules today and do not
2331
+ // detached). Retry ONCE with hooks disabled for that invocation only, logging
2332
+ // both facts.
2333
+ const hookErr = commit.stderr.trim() || `exit ${commit.code}`;
2334
+ this._log('git', 'warn', `commit failed with hooks enabled: ${hookErr}`, errStreamAttr(commit.stderr));
2335
+ const retry = await this._git(
2336
+ ['-c', 'core.hooksPath=', '-c', 'user.email=orchestrator@local', '-c', 'user.name=orchestrator',
2337
+ 'commit', '-m', msg],
2338
+ gitOpts,
2339
+ );
2340
+ if (retry.ok) {
2341
+ this._log('git', 'warn', 'retried the commit with hooks BYPASSED (core.hooksPath=) so the agent work is not lost');
2342
+ await this._recordRunWarning(
2343
+ `commit hooks failed (${hookErr}); retried with hooks bypassed so the work was not lost.`,
2344
+ );
2345
+ commit = retry;
2346
+ }
2347
+ }
2348
+ if (!commit.ok) {
2349
+ const message = commit.stderr.trim() || `exit ${commit.code}`;
2350
+ this._log('git', 'warn', `commit failed: ${message}`, errStreamAttr(commit.stderr));
2351
+ return { ok: false, step: 'commit', message, fromStderr: !!commit.stderr.trim() };
2352
+ }
2353
+ const ref = await this._git(['rev-parse', 'HEAD'], gitOpts);
2354
+ const sha = ref.ok ? ref.stdout.trim() : null;
2355
+ if (branchRecord) branchRecord.commit = sha;
2356
+ if (sha && this.pipeline) {
2357
+ await appendAudit(
2358
+ this.pipeline.dir,
2359
+ `Committed agent work to \`${info.branch}\` at \`${sha.slice(0, 10)}\`.`,
2360
+ ).catch(() => {});
2361
+ }
2362
+ return { ok: true, committed: true, sha };
2363
+ }
2364
+
2365
+ /**
2366
+ * §9.4 preflight gate: every workflow node key must resolve in the MERGED
2367
+ * registry (builtin+user+plugin) BEFORE any node executes. This deliberately
2368
+ * supersedes the silent empty-prompt degradation for ALL origins (it was a
2369
+ * bug, not a feature) — resolveWorkflow keeps `reg[key] || {}` for library
2370
+ * callers; runs are gated HERE, covering run() and resume(). The thrown plain
2371
+ * Error lands in the caller's catch => status 'error' + message; the
2372
+ * recoverable-error gate surfaces it cleanly.
2373
+ * @param {Iterable<string>} agentKeys the run's distinct agent keys, in launch order
2374
+ */
2375
+ _preflightAgentKeys(agentKeys) {
2376
+ const reg = this.registry || {};
2377
+ const missing = [];
2378
+ const seen = new Set();
2379
+ for (const key of agentKeys || []) {
2380
+ if (!key || seen.has(key) || Object.hasOwn(reg, key)) continue;
2381
+ seen.add(key);
2382
+ const plugin = findDisabledPluginFor(key);
2383
+ missing.push(plugin
2384
+ ? `agent "${key}" comes from disabled plugin "${plugin}" — enable it`
2385
+ : `agent "${key}" is not installed (removed plugin?)`);
2386
+ }
2387
+ if (missing.length) {
2388
+ throw new Error(
2389
+ `Preflight failed: ${missing.length} workflow agent key(s) do not resolve:\n` +
2390
+ missing.map((m) => ` - ${m}`).join('\n'),
2391
+ );
2392
+ }
2393
+ }
2394
+
2395
+ /** Step-boundary budget gate. Reads settings + DB FRESH each boundary so a
2396
+ * raised limit or a window reset takes effect at the next step (F9). */
2397
+ _checkCostLimits() {
2398
+ if (!this.pipeline?.id) return; // pre-createPipeline: nothing to meter
2399
+ const pipeLimit = pipelineCostLimitUsd();
2400
+ // resume() rehydrates state.steps but not state.totalCostUsd, so the row
2401
+ // total reads $0 until the first cost event of the resumed run. Take the
2402
+ // larger of the two so a resumed over-cap pipeline cannot run one free step.
2403
+ const spentHere = Math.max(this.state.totalCostUsd || 0, sumStepCosts(this.state.steps));
2404
+ if (pipeLimit != null && spentHere >= pipeLimit
2405
+ && !readCostCapOverride(this.pipeline.id)) {
2406
+ this._capReached(REASON.COST_PIPELINE,
2407
+ `pipeline cost limit reached ($${spentHere.toFixed(2)} >= $${pipeLimit.toFixed(2)})`);
2408
+ }
2409
+ const totalLimit = totalCostLimitUsd();
2410
+ if (totalLimit != null) {
2411
+ const period = costLimitResetPeriod();
2412
+ const spent = totalWindowSpendUsd(costWindowStart(new Date(), period).getTime());
2413
+ if (spent >= totalLimit) {
2414
+ this._capReached(REASON.COST_TOTAL,
2415
+ `total cost limit reached ($${spent.toFixed(2)} >= $${totalLimit.toFixed(2)} this ${period === 'weekly' ? 'week' : 'month'})`);
2416
+ }
2417
+ }
2418
+ }
2419
+
2420
+ /** The BUDGET site (failure-policy.mjs): a cost cap was reached at a step
2421
+ * boundary. Unlike the catch-block sites this throws itself — its caller is
2422
+ * the boundary gate. The audit line is required: _completePaused suppresses
2423
+ * its generic audit whenever pauseReason is set. */
2424
+ _capReached(code, detail) {
2425
+ const verdict = resolveFailure({ site: 'budget', cls: code, auto: this.auto });
2426
+ if (verdict.outcome === 'pause') {
2427
+ this._pauseFor(verdict.reason, null, { detail });
2428
+ throw pauseErr();
2429
+ }
2430
+ throw markTerminal(new Error(detail));
2431
+ }
2432
+
2433
+ /**
2434
+ * Resolve the NODE-site verdict for a failed execution (failure-policy.mjs),
2435
+ * running the recovery round it calls for on the way: auto mode backs off before
2436
+ * a 'retry' (a pause fired DURING backoff still returns 'retry' — the caller
2437
+ * checks pauseRequested first and unwinds as THAT pause, so a user pause is never
2438
+ * followed by a wasted retry); interactive mode opens ONE shared prompt per error
2439
+ * class (same-class siblings await the same answer), serialized so only one
2440
+ * recovery prompt is open at a time (the gate holds a single pendingQuestion),
2441
+ * and re-resolves with the answer. The per-class dedupe map shares ONE answer
2442
+ * across siblings; a sibling that receives a pause verdict second finds
2443
+ * pauseRequested already set and unwinds as that same pause.
2444
+ * @returns {Promise<{outcome:'retry'|'pause'|'error', reason?:string}>}
2445
+ */
2446
+ async _recover({ node, cls, err, attempt }) {
2447
+ const verdict = resolveFailure({ site: 'node', cls, auto: this.auto, attempt });
2448
+ if (verdict.outcome !== 'retry' && verdict.outcome !== 'prompt') return verdict; // no recovery round
2449
+ this._log(node.key, 'warn', `recoverable ${cls} error: ${err.message}`, err?.stream ? ERR_STREAM : null);
2450
+ await appendAudit(this.pipeline.dir, `Recoverable **${cls}** error on ${node.key}: ${firstLine(err.message)}`).catch(() => {});
2451
+
2452
+ if (verdict.outcome === 'retry') {
2453
+ await this._backoff(attempt, this.pauseAbort.signal);
2454
+ return verdict;
2455
+ }
2456
+
2457
+ this._recovery ||= new Map();
2458
+ if (!this._recovery.has(cls)) {
2459
+ const p = this._enqueueRecoveryPrompt(cls, firstLine(err.message), verdict.options)
2460
+ .finally(() => { if (this._recovery) this._recovery.delete(cls); });
2461
+ this._recovery.set(cls, p);
2462
+ }
2463
+ const answer = await this._recovery.get(cls);
2464
+ return resolveFailure({ site: 'node', cls, auto: this.auto, attempt, answer });
2465
+ }
2466
+
2467
+ /** Open a recovery prompt for one class, serialized behind any in-flight
2468
+ * recovery prompt (the question gate has a single pendingQuestion slot, so
2469
+ * distinct classes must queue — see the clarify answer). The prompt carries the
2470
+ * row's options (what the give-up choice does); resolves the policy answer
2471
+ * 'retry' | 'giveup' (the legacy `{ decision: 'abort' }` wire value is a give-up). */
2472
+ _enqueueRecoveryPrompt(cls, message, options) {
2473
+ const run = () =>
2474
+ this._ask({
2475
+ id: `recovery-${cls}-${this._recoveryNonce()}`,
2476
+ kind: 'recovery',
2477
+ recovery: { cls, message, options },
2478
+ }).then((ans) => answerFromDecision(ans && ans.decision));
2479
+ return this._enqueueAsk(run);
2480
+ }
2481
+
2482
+ /** Serialize an _ask-producing thunk behind any in-flight prompt (the gate
2483
+ * holds a single pendingQuestion slot; recovery AND step questions share
2484
+ * this tail so parallel nodes can never clobber each other's prompt). */
2485
+ _enqueueAsk(run) {
2486
+ const prev = this._askTail || Promise.resolve();
2487
+ const next = prev.then(run, run);
2488
+ this._askTail = next.catch(() => {}); // tail must never reject the chain
2489
+ return next;
2490
+ }
2491
+
2492
+ /** Abort-aware backoff: base * 2^(attempt-1) ms, resolving early (and still
2493
+ * 'retry') if the pause-only signal fires so a pause is not delayed. */
2494
+ _backoff(attempt, signal) {
2495
+ const base = (() => {
2496
+ const n = Number(process.env.WORCA_RECOVERY_BACKOFF_MS);
2497
+ return Number.isFinite(n) && n >= 0 ? n : 1000;
2498
+ })();
2499
+ const ms = base * Math.pow(2, Math.max(0, attempt - 1));
2500
+ if (!ms) return Promise.resolve();
2501
+ return new Promise((res) => {
2502
+ const t = setTimeout(res, ms);
2503
+ t.unref?.();
2504
+ if (signal) {
2505
+ if (signal.aborted) { clearTimeout(t); res(); }
2506
+ else signal.addEventListener('abort', () => { clearTimeout(t); res(); }, { once: true });
2507
+ }
2508
+ });
2509
+ }
2510
+
2511
+ /** Monotonic id source for recovery prompts (no Date.now/random — replay-safe). */
2512
+ _recoveryNonce() {
2513
+ return ++this._recoverySeq;
2514
+ }
2515
+
2516
+ /**
2517
+ * Build the read-only `workspace` metadata channel handle (the bus value for the
2518
+ * workspace channel): the frozen description + the member set with each member's
2519
+ * worktree dir, checkpoint ref, and per-project graph instruction. Seeded once by
2520
+ * _dispatch and never re-published (CONV-6). Members are in sorted-projectKey order.
2521
+ */
2522
+ _workspaceChannel() {
2523
+ return {
2524
+ kind: 'metadata',
2525
+ workspaceDescription: this.workspaceDescription,
2526
+ projects: this.members.map((m) => ({
2527
+ projectKey: m.projectKey,
2528
+ projectName: m.projectName,
2529
+ worktreeDir: this.workDirs.get(m.projectKey),
2530
+ checkpointRef: this.checkpointRefs[m.projectKey],
2531
+ graphInstruction: this.toolInstructions.get(m.projectKey) || '',
2532
+ })),
2533
+ };
2534
+ }
2535
+
2536
+ /**
2537
+ * The per-member roster every node ctx carries (§5.8). Absolute `dir` always;
2538
+ * `relDir` is a RENDER-ONLY token, emitted on detached runs only. `checkpointRef`
2539
+ * is populated in BOTH modes (the _ensureGitCheckpoint mirror). Single mode's
2540
+ * toolInstructions map is empty in both modes, so graphInstruction degrades to ''
2541
+ * — every renderer must tolerate that.
2542
+ */
2543
+ _reposCtx() {
2544
+ return this.members.map((m) => ({
2545
+ projectKey: m.projectKey,
2546
+ projectName: m.projectName,
2547
+ dir: this.workDirs.get(m.projectKey) || null,
2548
+ relDir: this.runRootMode === 'detached' ? `repos/${m.projectKey}` : null, // render-only token
2549
+ checkpointRef: this.checkpointRefs[m.projectKey] || null,
2550
+ graphInstruction: this.toolInstructions.get(m.projectKey) || '',
2551
+ }));
2552
+ }
2553
+
2554
+ /**
2555
+ * Emit a question and await its resolution. Honors auto-mode.
2556
+ * Freezes the active-time clock while blocked on the user (active-time-only).
2557
+ * @returns {Promise<any>} the answer payload
2558
+ */
2559
+ async _ask({ id, kind, questions, issues, recovery, agent, nodeId, wireId, executionId, deliveryNo, holdNo }) {
2560
+ this._checkAbort();
2561
+ // No interactive prompt may OPEN on a pausing run. pause() rejects only the
2562
+ // prompt that is currently open; a queued ask (a parallel sibling's questions
2563
+ // or a recovery prompt behind the _askTail chain) would otherwise still fire
2564
+ // and emit a fresh 'question' on a pausing/paused run (stale gate in the UI,
2565
+ // readline prompt while the CLI exits). Unwind it as a pause instead — the
2566
+ // owning node marks 'paused', exactly like every other pause path.
2567
+ this._checkPause();
2568
+
2569
+ // Freeze the active-time clock(s) while we wait on the user (active-time-only).
2570
+ // EVERY running row, not just the first one found: concurrent executions are
2571
+ // normal on both engines, and a single-row freeze left the asking execution
2572
+ // counting the user's think time as active.
2573
+ const frozen = this._runningStepKeys();
2574
+ if (frozen.length) {
2575
+ for (const key of frozen) this._clockPause(key);
2576
+ this.state.totalActiveMs = sumStepActive(this.state.steps);
2577
+ this._emit('state', this.getState()); // UI freezes the live timer
2578
+ this._persist().catch(() => {});
2579
+ }
2580
+
2581
+ this._emit('question', {
2582
+ id, kind, questions, issues, recovery, agent, nodeId,
2583
+ ...(wireId != null ? { wireId } : {}), // v2 gates name their wire
2584
+ ...(executionId != null ? { executionId } : {}), // v2 asks name their execution
2585
+ // A gate's CYCLE and hold ordinal. The id is opaque (a re-hold suffixes
2586
+ // `-h<holdNo>`), so every consumer — the CLI header, the monitor, the audit
2587
+ // trail — reads these fields instead of parsing the id (MAJ-11).
2588
+ ...(deliveryNo != null ? { deliveryNo } : {}),
2589
+ ...(holdNo != null ? { holdNo } : {}),
2590
+ });
2591
+
2592
+ try {
2593
+ if (this.auto) {
2594
+ if (kind === 'recovery') {
2595
+ // Auto mode handles recovery in _recover before ever calling _ask;
2596
+ // this is a defensive fallback so an auto run can never hang. Giving up
2597
+ // pauses the run (errors never end one), so 'pause' is the answer.
2598
+ return { decision: 'pause' };
2599
+ }
2600
+ if (kind === 'clarify' || kind === 'questions') {
2601
+ this._log('orchestrator', 'info', `auto-answering ${kind} ${id}`);
2602
+ return {
2603
+ answers: (questions || []).map((q) => ({
2604
+ id: q.id,
2605
+ choice: (q.options && q.options.find((o) => o && o.trim())) || 'auto',
2606
+ })),
2607
+ };
2608
+ }
2609
+ this._log('orchestrator', 'info', `auto-answering gate ${id} -> continue`);
2610
+ return { decision: 'continue' };
2611
+ }
2612
+ return await new Promise((resolveP, rejectP) => {
2613
+ this.pendingQuestion = { id, kind, resolve: resolveP, reject: rejectP };
2614
+ });
2615
+ } finally {
2616
+ // Resume only the rows that are STILL running AND only while the run has not
2617
+ // gone terminal. stop() sets status before rejecting the pending promise, so
2618
+ // on a stop-while-blocked we must NOT resume (the terminal _setStatus already
2619
+ // folded every clock). Gates fire after a step's 'done', so `frozen` is
2620
+ // usually empty there and nothing resumes anyway.
2621
+ //
2622
+ // The `status === 'start'` guard is load-bearing: a row that reached its
2623
+ // terminal marker WHILE the prompt was open was already clock-paused by that
2624
+ // marker, and resuming it would set runningSince on a finished step that
2625
+ // nothing will ever pause again.
2626
+ const stillRunning = ['stopped', 'error', 'pausing', 'paused'].includes(this.state.status)
2627
+ ? []
2628
+ : frozen.filter((key) => this.state.steps.find((s) => s.key === key)?.status === 'start');
2629
+ if (stillRunning.length) {
2630
+ for (const key of stillRunning) this._clockResume(key);
2631
+ this._emit('state', this.getState());
2632
+ this._persist().catch(() => {});
2633
+ }
2634
+ }
2635
+ }
2636
+
2637
+ /** List the user's attached files copied into <pipeline>/extras/ (basename + abs
2638
+ * path), sorted for deterministic seeded-file content. Empty when none were
2639
+ * attached or the dir is absent. */
2640
+ async _collectExtras() {
2641
+ try {
2642
+ const dir = join(this.pipeline.dir, 'extras');
2643
+ const names = (await readdir(dir)).sort();
2644
+ return names.map((name) => ({ name, path: join(dir, name) }));
2645
+ } catch {
2646
+ return [];
2647
+ }
2648
+ }
2649
+
2650
+ /**
2651
+ * Ensure `dir` is its OWN git repo with at least one commit, and return its
2652
+ * checkpoint ref (HEAD), or null when none could be established. Pure of state
2653
+ * writes — the caller wires checkpointRef(s)/state. Single-project and each
2654
+ * workspace member call this with their own dir (D3: never an enclosing repo).
2655
+ * @param {string} dir
2656
+ * @returns {Promise<string|null>}
2657
+ */
2658
+ async _ensureGitCheckpointFor(dir) {
2659
+ // C2: `--is-inside-work-tree` is true even when dir merely sits *inside* an
2660
+ // enclosing repo (no .git of its own). Acting on that parent repo would
2661
+ // silently create worca-cc/* branches + checkpoint commits in the developer's
2662
+ // real repo. Require dir to BE the repo toplevel; if it isn't (no repo, or
2663
+ // only a parent repo), `git init` a dedicated repo here.
2664
+ const projReal = await realpath(dir).catch(() => resolve(dir));
2665
+ const top = await this._git(['rev-parse', '--show-toplevel'], { cwd: dir });
2666
+ let topReal = null;
2667
+ if (top.ok && top.stdout.trim()) {
2668
+ topReal = await realpath(top.stdout.trim()).catch(() => top.stdout.trim());
2669
+ }
2670
+ const isOwnRepo = topReal === projReal;
2671
+ if (!isOwnRepo) {
2672
+ if (topReal) {
2673
+ this._log(
2674
+ 'git',
2675
+ 'info',
2676
+ `${dir} is nested in repo ${topReal}; initializing a dedicated repo to isolate worktrees.`,
2677
+ );
2678
+ }
2679
+ await this._git(['init'], { cwd: dir });
2680
+ // Ensure an identity exists for the commit (local, non-destructive).
2681
+ await this._git(['config', 'user.email', 'orchestrator@local'], { cwd: dir });
2682
+ await this._git(['config', 'user.name', 'orchestrator'], { cwd: dir });
2683
+ }
2684
+ // Is there any commit yet?
2685
+ const head = await this._git(['rev-parse', 'HEAD'], { cwd: dir });
2686
+ if (!head.ok) {
2687
+ await this._git(['add', '-A'], { cwd: dir });
2688
+ const commit = await this._git([
2689
+ '-c',
2690
+ 'user.email=orchestrator@local',
2691
+ '-c',
2692
+ 'user.name=orchestrator',
2693
+ 'commit',
2694
+ '--allow-empty',
2695
+ '-m',
2696
+ 'orchestrator: initial checkpoint',
2697
+ ], { cwd: dir });
2698
+ if (!commit.ok) {
2699
+ this._log('git', 'warn', `initial commit failed: ${commit.stderr.trim()}`, errStreamAttr(commit.stderr));
2700
+ }
2701
+ }
2702
+ const ref = await this._git(['rev-parse', 'HEAD'], { cwd: dir });
2703
+ return ref.ok ? ref.stdout.trim() : null;
2704
+ }
2705
+
2706
+ /**
2707
+ * Layer 1: build + persist the deterministic results view while the worktree(s)
2708
+ * and checkpoint refs are still live. Best-effort: never throws into run().
2709
+ */
2710
+ async _buildResults({ stage = false } = {}) {
2711
+ if (!this.pipeline) return;
2712
+ try {
2713
+ // stage: the non-done terminal paths never reached the review loop's staging
2714
+ // (:2204, :2311), so `git add -A -N` has not run and the `git diff <checkpoint>`
2715
+ // below cannot see a file the agent CREATED — the kept branch would carry it
2716
+ // while the persisted patch showed nothing. ignoreAbort for the same reason
2717
+ // _commitWork pins it (:1804): stop() has already tripped this.abort, and a
2718
+ // bound signal kills the staging before git can touch the index.
2719
+ // INSIDE the try: the stopped path calls _buildResults from run()'s catch, so
2720
+ // anything that escaped here would reject run() itself.
2721
+ if (stage) await this._stageWorkingTree({ ignoreAbort: true });
2722
+ const reviews = readPipelineExtras(this.pipeline.id).reviews || [];
2723
+ // Unified iteration over workDirs + checkpointRefs — the ref map is filled in
2724
+ // BOTH modes (the _ensureGitCheckpoint mirror), so a single-project run reads
2725
+ // the same shape. The single-project OUTPUT shape stays byte-identical (one
2726
+ // results.json, one un-prefixed patch) via the members.length === 1 special case.
2727
+ const members = [];
2728
+ const patches = [];
2729
+ for (const [key, dir] of this.workDirs.entries()) {
2730
+ const base = this.checkpointRefs[key];
2731
+ if (!base) continue;
2732
+ // §8.8: the same exclusion set the commit uses, so results.json and
2733
+ // diff.patch agree with what _commitWork actually committed.
2734
+ const ex = this._excludePathspecs(key);
2735
+ const [ns, num, patch] = await Promise.all([
2736
+ diffNameStatus(dir, base, undefined, ex),
2737
+ diffNumstat(dir, base, undefined, ex),
2738
+ diffPatch(dir, base, undefined, ex),
2739
+ ]);
2740
+ const results = assembleResults({ nameStatus: ns, numstat: num, reviews });
2741
+ members.push({ projectKey: key, results });
2742
+ patches.push({ key, patch, listed: ns.length > 0 });
2743
+ }
2744
+ if (!members.length) return;
2745
+ // Nothing changed under the checkpoint. Persisting here would index a 0-byte
2746
+ // diff-patch.patch plus an all-zero results.json, and every downstream
2747
+ // "does this run have a diff?" test is an EXISTENCE test, not an emptiness
2748
+ // one: /diff answers 200-empty instead of 404 (ui/server.mjs:1994 tests
2749
+ // `text == null`), /recovery-patch serves an empty attachment (:1690), the
2750
+ // comments routes report patchAvailable:false and then 409 every create (:1882),
2751
+ // and History detail opens on the Diff tab to render "(no files changed)"
2752
+ // (app.js:11110 tests `d.results`). Write nothing — absent IS the truth, and
2753
+ // it is the state the UI's empty state already describes.
2754
+ const noPatch = patches.every((p) => !p.patch);
2755
+ // An EMPTY patch while name-status lists changes is a failed `git diff`
2756
+ // spawn (diffPatch returns '' on error), not a clean tree — say so, and
2757
+ // still persist the results the other two diffs produced.
2758
+ const listed = patches.some((p) => p.listed);
2759
+ if (noPatch && listed) this._log('results', 'warn', 'diff patch is empty although name-status lists changes — git diff failed; results.json is persisted without a patch');
2760
+ // Stopped/error paths (`stage`) with nothing changed write nothing — absent IS
2761
+ // the truth (above). The DONE path always persists results.json: it carries
2762
+ // the review-derived keyThingsToCheck/blockingIssues that the task-source
2763
+ // write-back (sources.mjs) and History read, so a review-only / plan-only /
2764
+ // no-op run must not lose them (review of PR #376). The 0-byte
2765
+ // diff-patch.patch is still never written on any path.
2766
+ if (noPatch && stage && !listed) return;
2767
+ if (members.length === 1 && !this.isWorkspace) {
2768
+ await persistResults(this.pipeline.dir, members[0].results);
2769
+ if (!noPatch) await persistDiffPatch(this.pipeline.dir, patches[0].patch);
2770
+ } else {
2771
+ const perProject = buildPerProject(members);
2772
+ const results = { summary: rollupSummary(perProject), perProject };
2773
+ await persistResults(this.pipeline.dir, results);
2774
+ if (!noPatch) await persistDiffPatch(this.pipeline.dir, patches.map((p) => `# ${p.key}\n${p.patch}`).join('\n\n'));
2775
+ }
2776
+ } catch (err) {
2777
+ this._log('results', 'warn', `results build failed: ${err.message}`);
2778
+ }
2779
+ }
2780
+
2781
+ /**
2782
+ * Task-source write-back (spec §7.5): report the finished run to the plugin
2783
+ * source that produced it. Runs on EVERY terminal path and ALWAYS after
2784
+ * _buildResults() — done (statusToResult -> 'completed'), stopped/launch-error
2785
+ * (-> 'failed'; chat-connectivity design PR12 closed the old success-only gap),
2786
+ * and error-pauses (-> 'needs-human', from _completePaused).
2787
+ * So the payload is the same SHAPE on all three: retryWriteback reads
2788
+ * results.json (sources.mjs:215), and a stopped/error run that persisted one now
2789
+ * carries the diffstat and "Key things to check" lines too. Only a run with
2790
+ * nothing to persist — no checkpoint, or an empty diff under it — falls back to
2791
+ * the thin status-only summary. NEVER throws and
2792
+ * never fails the run: a failure emits a warn `log` event and the results view
2793
+ * offers a manual retry via the same retryWriteback (Task 15 endpoint, Task 21
2794
+ * button). Prompt/markdown
2795
+ * runs skip inside retryWriteback before any work — feature-off runs pay
2796
+ * nothing here. Bounded by the shim's per-op timeout.
2797
+ */
2798
+ async _reportToSource() {
2799
+ if (!this.pipeline) return;
2800
+ try {
2801
+ const outcome = await retryWriteback(this.pipeline.id);
2802
+ if (outcome?.ok === false) {
2803
+ this._log('writeback', 'warn', `task-source write-back failed: ${outcome.error} — use "Report result" in the results view to retry`);
2804
+ } else if (outcome?.ok && !outcome.skipped) {
2805
+ await appendAudit(this.pipeline.dir, 'Result reported back to the task source.').catch(() => {});
2806
+ }
2807
+ } catch (err) {
2808
+ this._log('writeback', 'warn', `task-source write-back failed: ${err?.message || err}`);
2809
+ }
2810
+ }
2811
+
2812
+ /** Single-project checkpoint: own repo + commit, record the scalar ref + state. */
2813
+ async _ensureGitCheckpoint() {
2814
+ this.checkpointRef = await this._ensureGitCheckpointFor(this.projectDir);
2815
+ this.state.checkpointRef = this.checkpointRef;
2816
+ // Mirror into the per-member map so the unified _buildResults / _reposCtx
2817
+ // iteration reads ONE shape in both modes. Without this the unified iteration
2818
+ // hits `if (!base) continue` and silently writes empty results/diff on every
2819
+ // single-project run (§5.2 step 4).
2820
+ const onlyKey = this.members[0]?.projectKey;
2821
+ if (onlyKey) {
2822
+ this.checkpointRefs[onlyKey] = this.checkpointRef;
2823
+ this.state.checkpointRefs = { ...this.checkpointRefs };
2824
+ }
2825
+ if (this.checkpointRef) {
2826
+ await appendAudit(
2827
+ this.pipeline.dir,
2828
+ `Git checkpoint at \`${this.checkpointRef.slice(0, 10)}\`.`,
2829
+ );
2830
+ } else {
2831
+ this._log('git', 'warn', 'No git checkpoint ref could be established (continuing).');
2832
+ }
2833
+ }
2834
+
2835
+ /**
2836
+ * Workspace checkpoint: run _ensureGitCheckpointFor once per member (serial —
2837
+ * git is cheap and serial avoids interleaved index locks), record
2838
+ * this.checkpointRefs[projectKey], mirror the scalar this.checkpointRef to the
2839
+ * primary, and write state.checkpointRefs (+ scalar). Members are iterated in
2840
+ * sorted-projectKey order so the primary is members[0].
2841
+ */
2842
+ async _ensureGitCheckpointAll() {
2843
+ for (const m of this.members) {
2844
+ const ref = await this._ensureGitCheckpointFor(resolve(m.projectDir));
2845
+ this.checkpointRefs[m.projectKey] = ref;
2846
+ if (ref) {
2847
+ await appendAudit(
2848
+ this.pipeline.dir,
2849
+ `Git checkpoint for \`${m.projectKey}\` at \`${ref.slice(0, 10)}\`.`,
2850
+ ).catch(() => {});
2851
+ } else {
2852
+ this._log('git', 'warn', `No git checkpoint ref for ${m.projectKey} (continuing).`);
2853
+ }
2854
+ }
2855
+ const primaryKey = this.members[0]?.projectKey;
2856
+ this.checkpointRef = primaryKey ? this.checkpointRefs[primaryKey] : null;
2857
+ this.state.checkpointRef = this.checkpointRef;
2858
+ this.state.checkpointRefs = { ...this.checkpointRefs };
2859
+ await this._persist();
2860
+ }
2861
+
2862
+ /**
2863
+ * Stage every change in the working tree with intent-to-add so that newly
2864
+ * created (untracked) files show up in a plain `git diff` for the reviewer.
2865
+ * Uses `git add -A -N`: it records intent-to-add for new paths (making their
2866
+ * content visible to `git diff`) without actually creating a commit, so the
2867
+ * checkpoint commit remains the single diff base. Best-effort; never throws.
2868
+ * `ignoreAbort` is for the terminal-path callers only (_buildResults on stop /
2869
+ * error): every in-run caller must stay killable by stop().
2870
+ * @param {{ignoreAbort?:boolean}} [opts]
2871
+ */
2872
+ async _stageWorkingTree({ ignoreAbort = false } = {}) {
2873
+ // Stage EVERY member worktree (keyed — the pathspec lookup needs the projectKey)
2874
+ // so each per-project reviewer's `git diff` sees that project's agent edits.
2875
+ // Single-project runs have exactly one entry (populated in both modes). The
2876
+ // isWorkspace branch is gone: workDirs is the single shape. An empty map means
2877
+ // setup never ran, in which case staging must be a NO-OP — the old single arm
2878
+ // fell back to this.workDir, which pre-setup is the user's LIVE checkout.
2879
+ for (const [key, dir] of this.workDirs.entries()) {
2880
+ // §8.8: an empty exclusion set (every legacy run) reproduces today's argv
2881
+ // byte-identically — `--` with no trailing pathspec is a no-op for git add.
2882
+ const ex = this._excludePathspecs(key);
2883
+ const args = ex.length ? ['add', '-A', '-N', '--', '.', ...ex] : ['add', '-A', '-N'];
2884
+ const res = await this._git(args, { cwd: dir, ignoreAbort });
2885
+ if (!res.ok && res.stderr && res.stderr.trim()) {
2886
+ this._log('git', 'debug', `git add -A -N (${dir}): ${res.stderr.trim()}`, ERR_STREAM);
2887
+ }
2888
+ }
2889
+ }
2890
+
2891
+ /**
2892
+ * §8.8: the ONE pathspec set that keeps worca-cc's injected paths out of the
2893
+ * commit, the reviewer's intent-to-add staging, and all three result diffs.
2894
+ * `kind:'claudeMdSection'` entries are deliberately EXCLUDED from the set — their
2895
+ * file is the user's tracked CLAUDE.md, and a blanket `:(exclude)CLAUDE.md` would
2896
+ * silently strip the agent's legitimate edits (teardown strips the fence instead).
2897
+ * Returns [] under legacy and through Phase 2 (this.injectedPaths is always {}),
2898
+ * which is what makes every legacy argv byte-identical.
2899
+ * @param {string} projectKey
2900
+ * @returns {string[]}
2901
+ */
2902
+ _excludePathspecs(projectKey) {
2903
+ const entries = this.injectedPaths?.[projectKey] ?? [];
2904
+ if (!Array.isArray(entries) || !entries.length) return [];
2905
+ return entries
2906
+ .filter((e) => e && e.path && e.kind !== 'claudeMdSection')
2907
+ .map((e) => `:(exclude)${e.path}`);
2908
+ }
2909
+
2910
+ /**
2911
+ * Run a git command in the project dir. Never throws; returns
2912
+ * { ok, code, stdout, stderr }. Honors the abort signal.
2913
+ */
2914
+ _git(args, { cwd, ignoreAbort = false } = {}) {
2915
+ return new Promise((resolveP) => {
2916
+ let child;
2917
+ try {
2918
+ child = spawn('git', args, {
2919
+ cwd: cwd || this.projectDir,
2920
+ stdio: ['ignore', 'pipe', 'pipe'],
2921
+ // ignoreAbort: teardown commits run AFTER the run is aborted (stop/error);
2922
+ // binding the aborted signal here would kill them instantly and leave the
2923
+ // kept branch empty. Cleanup git must outlive the abort.
2924
+ signal: ignoreAbort ? undefined : this.abort.signal,
2925
+ });
2926
+ } catch (err) {
2927
+ resolveP({ ok: false, code: -1, stdout: '', stderr: err.message });
2928
+ return;
2929
+ }
2930
+ let stdout = '';
2931
+ let stderr = '';
2932
+ child.stdout?.on('data', (d) => (stdout += d.toString()));
2933
+ child.stderr?.on('data', (d) => (stderr += d.toString()));
2934
+ child.on('error', (err) =>
2935
+ resolveP({ ok: false, code: -1, stdout, stderr: stderr || err.message }),
2936
+ );
2937
+ child.on('close', (code) => resolveP({ ok: code === 0, code: code ?? -1, stdout, stderr }));
2938
+ });
2939
+ }
2940
+
2941
+ /** Bulk-load every registry agent's .md body keyed by agent key (fallback layer
2942
+ * for runners whose ctx has no node, e.g. the clarify pre-step; dispatched nodes
2943
+ * prefer node.agentPrompt via phases.resolveAgentBody). Registry-driven: built-in
2944
+ * AND user agents load from their own layer via meta.agentPath. */
2945
+ async _loadAgentPrompts() {
2946
+ const prompts = {};
2947
+ const registry = this.registry || loadAgentRegistry(this.agentsDir);
2948
+ for (const meta of Object.values(registry)) {
2949
+ if (!meta.agentPath) { prompts[meta.key] = ''; continue; }
2950
+ try {
2951
+ prompts[meta.key] = await readFile(meta.agentPath, 'utf8');
2952
+ } catch {
2953
+ prompts[meta.key] = ''; // missing agent file => empty body (fails safe)
2954
+ this._log('orchestrator', 'warn', `Agent prompt missing: ${rel(this.projectDir, meta.agentPath)}`);
2955
+ }
2956
+ }
2957
+ return prompts;
2958
+ }
2959
+
2960
+ async _writeClarifyAnswers(questions, answers) {
2961
+ // M1: clarify answers live ONLY in the clarify DB row (the authoritative store).
2962
+ // The dead FS clarify-answers.json (never read back; the single-round loop passes
2963
+ // prior answers in-memory) is gone. Enrich each answer with its question text so
2964
+ // the row + History UI render the full Q&A without a join.
2965
+ const byId = new Map(questions.map((q) => [q.id, q]));
2966
+ const enriched = answers.map((a) => ({
2967
+ id: a.id,
2968
+ question: byId.get(a.id)?.question || '',
2969
+ choice: a.choice,
2970
+ }));
2971
+ await writeClarify(this.pipeline.id, { answers: { answers: enriched } });
2972
+ return enriched;
2973
+ }
2974
+
2975
+ _deriveBaseName(promptText, title) {
2976
+ const fromTitle = title && title !== basename(this.pipeline?.dir || '') ? title : '';
2977
+ const source = fromTitle || firstLine(promptText) || 'feature';
2978
+ return slugify(source).slice(0, 40) || 'feature';
2979
+ }
2980
+
2981
+ _checkAbort() {
2982
+ if (this.abort.signal.aborted || this.state.status === 'stopped') {
2983
+ const err = new Error('stopped');
2984
+ err.name = 'AbortError';
2985
+ throw err;
2986
+ }
2987
+ }
2988
+
2989
+ _phase(phase, cycle, status, nodeId = null) {
2990
+ this.state.phase = phase;
2991
+ this.state.cycle = cycle;
2992
+ this._recordStep(phase, cycle, status, nodeId);
2993
+ this.state.updatedAt = new Date().toISOString();
2994
+ // No `phase` event: the v1 event vocabulary died with the v1 engine. The
2995
+ // state.phase/state.cycle SCALARS stay — they are harness-local (state
2996
+ // initialises phase:'idle', _recordCost falls back to them, and
2997
+ // test/run-harness-hooks pins the contract).
2998
+ this._emit('state', this.getState());
2999
+ // Persist on phase boundaries so history/audit stay fresh.
3000
+ this._persist().catch(() => {});
3001
+ }
3002
+
3003
+ _recordStep(phase, cycle, status, nodeId = null) {
3004
+ const key = cycle ? `${phase}#${cycle}` : phase;
3005
+ const now = new Date().toISOString();
3006
+ let step = this.state.steps.find((s) => s.key === key);
3007
+ if (!step) {
3008
+ step = { key, phase, cycle, status, startedAt: now, updatedAt: now, activeMs: 0, runningSince: null };
3009
+ // Attribute this phase's figures to a stepper node (clarify -> the plan
3010
+ // node) so the UI buckets it onto that cell. Totals are derived as Σ steps,
3011
+ // so labelling a step changes attribution only — it adds no ms/cost.
3012
+ if (nodeId) step.nodeId = nodeId;
3013
+ this.state.steps.push(step);
3014
+ } else {
3015
+ step.status = status;
3016
+ step.updatedAt = now;
3017
+ // Idempotent: a later marker (e.g. 'done') passes no nodeId and must not
3018
+ // clear the tag set at 'start'; never clobber an existing tag.
3019
+ if (nodeId && !step.nodeId) step.nodeId = nodeId;
3020
+ }
3021
+ if (status === 'start') {
3022
+ this._clockPauseAll(); // close out any prior running step
3023
+ this._clockResume(key); // start this phase's active clock
3024
+ } else {
3025
+ this._clockPause(key); // 'done' (or any terminal marker): finalize
3026
+ }
3027
+ // Keep the derived total in lockstep with the per-step figures (mirrors cost).
3028
+ this.state.totalActiveMs = sumStepActive(this.state.steps);
3029
+ }
3030
+
3031
+ /** Start (resume) the active-time clock for a step key, idempotently. */
3032
+ _clockResume(key) {
3033
+ const step = this.state.steps.find((s) => s.key === key);
3034
+ if (step && step.runningSince == null) step.runningSince = Date.now();
3035
+ }
3036
+
3037
+ /** Pause a step's clock, folding the elapsed run into activeMs. No-op if idle. */
3038
+ _clockPause(key) {
3039
+ const step = this.state.steps.find((s) => s.key === key);
3040
+ if (!step || step.runningSince == null) return;
3041
+ step.activeMs = (step.activeMs || 0) + Math.max(0, Date.now() - step.runningSince);
3042
+ step.runningSince = null;
3043
+ }
3044
+
3045
+ /** Pause every running step (defensive: only one runs at a time normally). */
3046
+ _clockPauseAll() {
3047
+ for (const s of this.state.steps) {
3048
+ if (s.runningSince != null) this._clockPause(s.key);
3049
+ }
3050
+ }
3051
+
3052
+ /** Keys of every step whose clock is currently running. v1's sequential path has
3053
+ * at most one; a parallel step group and every v2 run have one per in-flight
3054
+ * execution. */
3055
+ _runningStepKeys() {
3056
+ return this.state.steps.filter((s) => s.runningSince != null).map((s) => s.key);
3057
+ }
3058
+
3059
+ /** Live total = finalized activeMs (sumStepActive) + the running tail. Test/diagnostic. */
3060
+ liveActiveMs() {
3061
+ const now = Date.now();
3062
+ let sum = 0;
3063
+ for (const s of this.state.steps) {
3064
+ sum += (s.activeMs || 0) + (s.runningSince != null ? Math.max(0, now - s.runningSince) : 0);
3065
+ }
3066
+ return sum;
3067
+ }
3068
+
3069
+ _setStatus(status) {
3070
+ this.state.status = status;
3071
+ if (status === 'done' || status === 'stopped' || status === 'error' || status === 'paused') {
3072
+ this._clockPauseAll();
3073
+ this.state.totalActiveMs = sumStepActive(this.state.steps);
3074
+ }
3075
+ this.state.updatedAt = new Date().toISOString();
3076
+ this._emit('state', this.getState());
3077
+ }
3078
+
3079
+ _log(source, level, text, attr = null) {
3080
+ const evt = { source, level, text, ts: new Date().toISOString() };
3081
+ if (attr) {
3082
+ if (attr.nodeId != null) evt.nodeId = attr.nodeId;
3083
+ if (attr.executionId != null) evt.executionId = attr.executionId; // v2 (§5.7 / §8 log filter)
3084
+ if (attr.stepIndex != null) evt.stepIndex = attr.stepIndex;
3085
+ if (attr.cycle != null) evt.cycle = attr.cycle;
3086
+ if (attr.sub) evt.sub = true; // drives sub-agent web styling
3087
+ // Origin channel of the text: 'err' when it came from a subprocess's
3088
+ // stderr (agent CLI, git, graphify). Provenance, not severity — the level
3089
+ // says how bad it is, this says where it came from.
3090
+ if (attr.stream) evt.stream = attr.stream;
3091
+ }
3092
+ this._emit('log', evt);
3093
+ this.logWriter.push(evt); // persist the full stream (buffered; flushed on a timer)
3094
+ }
3095
+
3096
+ /**
3097
+ * @param {string} kind
3098
+ * @param {string} path
3099
+ * @param {{nodeId?:string, executionId?:string, port?:string|null}|null} [attr]
3100
+ * v2 attribution (§5.7). Omitted keys are omitted from the event, so every
3101
+ * 2-arg v1 call emits the byte-identical `{kind, path}` payload it always did.
3102
+ */
3103
+ _artifact(kind, path, attr = null) {
3104
+ const evt = { kind, path };
3105
+ if (attr) {
3106
+ if (attr.nodeId != null) evt.nodeId = attr.nodeId;
3107
+ if (attr.executionId != null) evt.executionId = attr.executionId;
3108
+ if (attr.port != null) evt.port = attr.port;
3109
+ }
3110
+ this._emit('artifact', evt);
3111
+ // Phase 3.9: ALSO index FS markdown/extra paths so pipeline-delete (Task 3.13)
3112
+ // can unlink the EXACT files later (best-effort; never blocks a run). Skip the
3113
+ // synthetic 'pipeline'/'clarify' kinds (clarify lives in the clarify table;
3114
+ // 'pipeline' is the dir itself). plan/review markdown live under
3115
+ // <store>/<key>/{plans,reviews} (store-root-relative); checklist/webui live in
3116
+ // the pipeline dir (dir-relative).
3117
+ if (!this.pipeline || !path || kind === 'pipeline' || kind === 'clarify' || kind === 'questions') return;
3118
+ let relPath = null;
3119
+ const pdir = this.pipeline.dir;
3120
+ if (path.startsWith(pdir + sep)) {
3121
+ relPath = relative(pdir, path); // dir-relative (checklist, webui)
3122
+ } else {
3123
+ const root = this.isWorkspace
3124
+ ? workspaceStorePath(this.workspaceKey)
3125
+ : projectStorePath(projectKey(this.projectDir));
3126
+ if (path.startsWith(root + sep)) relPath = relative(root, path); // store-rel (plan/review)
3127
+ }
3128
+ // Indexed with '/' on every OS: the row is a store-layout key, not a native
3129
+ // path (pipeline-delete re-roots 'plans/…' / 'reviews/…' under the store),
3130
+ // so a Windows-native 'reviews\\x.md' would silently miss that re-rooting.
3131
+ if (relPath) recordArtifact(this.pipeline.id, kind, relPath.split(sep).join('/'));
3132
+ }
3133
+
3134
+ /** Translate a low-level claude/mock event into a pipeline 'log' event. */
3135
+ _onAgentEvent(role, e, attr = null) {
3136
+ if (!e) return;
3137
+ // Sub-agent telemetry (feature-detected, gated by WORCA_SUBAGENT_HOOKS). A
3138
+ // surfaced PostToolUse:Agent hook-event carries the parent tool_use_id +
3139
+ // tool_response telemetry; enrich the matching record's columns, keyed by
3140
+ // tool_use_id (the canonical key — never agent_id). Returns early: a hook
3141
+ // event has no human text and no cost to attribute.
3142
+ if (e.type === 'hook-event') {
3143
+ this._recordSubAgentTelemetry(e.raw);
3144
+ return;
3145
+ }
3146
+ // Pause/Resume: stamp the claude session id on the step that spawned it, and
3147
+ // persist eagerly — a later pause (or even a crash) must find it in the DB.
3148
+ if (e.type === 'session' && typeof e.sessionId === 'string') {
3149
+ const key = attr?.stepKey;
3150
+ const step = key ? this.state.steps.find((s) => s.key === key) : null;
3151
+ if (step && step.sessionId !== e.sessionId) {
3152
+ step.sessionId = e.sessionId;
3153
+ this._persist().catch(() => {});
3154
+ }
3155
+ return;
3156
+ }
3157
+ // Agent stderr (`stream:'err'`), one framed line per event. Handled HERE,
3158
+ // beside the other envelope guards, because a stderr event carries no `raw`:
3159
+ // routing it through the cost block and the five lifecycle reducers below
3160
+ // only to have each no-op is noise. It is always main-stream (stderr has no
3161
+ // parent_tool_use_id), so the source is the plain role and `sub` is never set.
3162
+ //
3163
+ // Level is `warn`, not `error`: what actually lands here is mostly 429/529
3164
+ // retry text and subprocess chatter. Genuine failures arrive as a `result`
3165
+ // event with is_error on STDOUT — see the non-zero-exit path in
3166
+ // claude-runner.mjs — and are logged at `error` by the node failure handler.
3167
+ if (e.type === 'stderr') {
3168
+ const text = (e.text || '').trim();
3169
+ if (text) this._log(role, 'warn', text, { ...attr, stream: 'err' });
3170
+ return;
3171
+ }
3172
+ // Capture actual spend before anything returns early. The runner tags the
3173
+ // terminal stream-json `result` with costUsd (Claude's total_cost_usd; 0 in
3174
+ // mock). Fall back to raw.total_cost_usd defensively. e.raw may be a string
3175
+ // (non-JSON line) — `.type` on it is just undefined, so this never throws.
3176
+ // `e.costUsd != null` keeps a genuine 0 (which `!= null` is true for).
3177
+ const isResult = !!(e.raw && typeof e.raw === 'object' && e.raw.type === 'result');
3178
+ const rawCost = e.costUsd != null
3179
+ ? Number(e.costUsd)
3180
+ : (isResult ? Number(e.raw.total_cost_usd ?? e.raw.cost_usd) : NaN);
3181
+ // A per-model cost override (config.mjs) wins over the CLI's own figure — so a
3182
+ // CLI that prices an on-prem/proxied model by name can't inflate the ledger.
3183
+ // With no override this is `rawCost` unchanged (default behavior preserved).
3184
+ //
3185
+ // Gated on `isResult` — NOT merely on attr.model. Every stream frame reaches
3186
+ // here, and only the terminal `result` carries cost; on the others rawCost is
3187
+ // NaN and falls through untouched today. A {free} override answers 0 for any
3188
+ // input, so resolving unconditionally would turn each of those into a real $0
3189
+ // and fire _recordCost — a full writeState + 'state' broadcast — per FRAME
3190
+ // instead of once per node. Looked up ONCE and shared with observeModelCost
3191
+ // below: modelCostConfig re-reads settings.json on every call.
3192
+ const costCfg = isResult && attr?.model ? modelCostConfig(attr.model) : null;
3193
+ const cost = costCfg
3194
+ ? resolveModelCost(attr.model, rawCost, e.raw.usage, costCfg)
3195
+ : rawCost;
3196
+ if (Number.isFinite(cost)) this._recordCost(cost, attr?.stepKey);
3197
+ else if (isResult && !this.claude.mock) {
3198
+ // A {perMtok} model prices from tokens alone, so a result with no usage is
3199
+ // unpriceable (NaN) — say so plainly rather than blaming a missing cost field.
3200
+ this._log('orchestrator', 'warn', costCfg?.perMtok
3201
+ ? `model "${attr.model}" is priced per-Mtok but the result carried no token usage — this step's spend is unaccounted`
3202
+ : 'result event carried no cost estimate (total_cost_usd absent)', attr);
3203
+ }
3204
+
3205
+ // §4.6 cost-reliability observation: only terminal result events of REAL
3206
+ // runs, only for the dispatched model (attr.model — the legacy role path
3207
+ // carries no attr and is skipped), and only env-routed models inside
3208
+ // observeModelCost. One warning per model per run; the observation itself
3209
+ // is derived state and must never fail the run.
3210
+ if (isResult && !this.claude.mock && attr?.model) {
3211
+ try {
3212
+ const verdict = observeModelCost(attr.model, Number.isFinite(cost) ? cost : null, e.raw.usage, costCfg);
3213
+ if (verdict === 'flagged' && !(this._costUnreliableWarned ||= new Set()).has(attr.model)) {
3214
+ this._costUnreliableWarned.add(attr.model);
3215
+ this._log('orchestrator', 'warn',
3216
+ `model "${attr.model}" reported no cost despite token usage (custom endpoint) — USD budget enforcement cannot see this spend`, attr);
3217
+ }
3218
+ } catch { /* derived state — never fail the run over it */ }
3219
+ }
3220
+
3221
+ // Sub-agent attribution. A child (Task/Agent) event carries parent_tool_use_id
3222
+ // = the id of the parent's Task tool_use block; main-agent events carry null/
3223
+ // absent. parent_tool_use_id is a TOP-LEVEL stream-json field; the message-
3224
+ // nested read is defensive. On a string `raw`, both reads yield undefined.
3225
+ const subId = e.raw?.parent_tool_use_id ?? e.raw?.message?.parent_tool_use_id ?? null;
3226
+
3227
+ // Learn Task/Agent descriptions from MAIN-agent events (subId == null) so the
3228
+ // child events below can be labeled by what their sub-agent was asked to do.
3229
+ if (subId == null) {
3230
+ registerSubAgents(e.raw, this._subAgentLabels);
3231
+ // Lifecycle: a NEW Task/Agent tool_use on the MAIN stream = a sub-agent spawn.
3232
+ // Needs `attr` to pin nodeId/stepIndex/cycle/stepKey; the clarify pre-step
3233
+ // (attr === null) carries no node, so it is logged but not lifecycle-tracked.
3234
+ if (attr) this._recordSubAgentSpawns(e.raw, attr);
3235
+ // Finish: a tool_result on the MAIN stream whose tool_use_id is a tracked
3236
+ // sub-agent → finished/error. These `user` envelopes were previously dropped.
3237
+ this._recordSubAgentFinishes(e.raw);
3238
+ // Background-agent completion: the system/task_notification frame arrives
3239
+ // on the main stream long after the launch-ack tool_result.
3240
+ this._recordAsyncTaskClose(e.raw);
3241
+ }
3242
+
3243
+ // Capture named-skill / MCP-tool usage for the Sub-agents dropdown pills
3244
+ // (main agent -> its step; sub-agent -> its record). Independent of the
3245
+ // text/tool log branches below (it runs BEFORE the `if (text) return`), so a
3246
+ // mixed text+tool_use turn is still caught.
3247
+ this._recordSkills(e.raw, subId, attr);
3248
+ // Count graphify CLI invocations (Bash only) per agent / sub-agent. Bash-only
3249
+ // by design: the graphify skill runs the CLI itself, so counting the Skill tool
3250
+ // too would double-count; the bash invocation is the ground truth and also
3251
+ // catches direct CLI use with no skill.
3252
+ this._recordGraphify(e.raw, subId, attr);
3253
+
3254
+ // Display source: parent role for main events; "role ▸ label" for sub-agent
3255
+ // events. `sub` drives the indented/dimmed web styling.
3256
+ let source = role;
3257
+ let sub = false;
3258
+ if (subId != null) {
3259
+ let label = this._subAgentLabels.get(subId);
3260
+ if (!label) {
3261
+ label = `sub-agent-${++this._subAgentFallbackSeq}`;
3262
+ this._subAgentLabels.set(subId, label); // stamp so the ordinal stays stable for this id
3263
+ }
3264
+ source = `${role} ▸ ${label}`;
3265
+ sub = true;
3266
+ }
3267
+ // Preserve the step attribution (nodeId/stepIndex/cycle) carried by attr so a
3268
+ // sub-agent line stays pinned to the right pipeline step/cycle in the UI; just
3269
+ // add `sub`. {...null} === {}, so attr === null (the clarify pre-step) is safe.
3270
+ const logAttr = sub ? { ...attr, sub: true } : attr;
3271
+
3272
+ // Human-readable assistant text (if any). NO early return: a single
3273
+ // assistant turn can carry BOTH a text block and tool_use blocks — fall
3274
+ // through so each tool call is logged too. A text-only turn has no
3275
+ // tool_use/tool_result blocks, so the loops below are empty and its output
3276
+ // is identical to the pre-change path.
3277
+ const text = (e.text || '').trim();
3278
+ if (text) this._log(source, 'info', text, logAttr);
3279
+
3280
+ // The `system`/init event has no text and no tool blocks — surface the
3281
+ // model (parity with worca's `[init] model=<model>`) instead of dropping it.
3282
+ if (e.raw && e.raw.type === 'system' && e.raw.subtype === 'init') {
3283
+ this._log(source, 'debug', `[init] model=${e.raw.model || '?'}`, logAttr);
3284
+ // §4.7: stamp the session's ACTUAL model on the step (mirrors the
3285
+ // sessionId stamp above) so the UI can resolve the "default" caption to
3286
+ // a concrete name. Display-only; sub-agent events never carry init.
3287
+ const step = !sub && e.raw.model && attr?.stepKey
3288
+ ? this.state.steps.find((s) => s.key === attr.stepKey) : null;
3289
+ if (step && step.modelUsed !== e.raw.model) {
3290
+ step.modelUsed = e.raw.model;
3291
+ this._persist().catch(() => {});
3292
+ }
3293
+ }
3294
+
3295
+ // Concrete tool calls the agent made this turn (assistant.tool_use blocks).
3296
+ for (const call of describeToolUses(e.raw, this.projectDir)) {
3297
+ this._log(source, 'debug', `→ ${call}`, logAttr);
3298
+ }
3299
+
3300
+ // Tool-result outcomes (`user`-envelope + child tool_result blocks).
3301
+ // ADDITIVE ONLY — _recordSubAgentFinishes (above) still owns sub-agent
3302
+ // lifecycle state; this loop never mutates state, it only logs.
3303
+ for (const line of describeToolResults(e.raw)) {
3304
+ this._log(source, 'debug', `← ${line}`, logAttr);
3305
+ }
3306
+ }
3307
+
3308
+ /**
3309
+ * Lifecycle spawn reducer: for every NEW Task/Agent tool_use block in a
3310
+ * MAIN-stream event, push a `running` sub-agent record (attributed to the
3311
+ * step via `attr`), mirror it to the sub_agents table, and emit a `spawn`
3312
+ * delta. Idempotent per tool_use id (re-seen ids are skipped). `attr` is
3313
+ * required (the caller only invokes this when a node is in scope).
3314
+ */
3315
+ _recordSubAgentSpawns(raw, attr) {
3316
+ const content = raw?.message?.content;
3317
+ if (!Array.isArray(content)) return;
3318
+ for (const c of content) {
3319
+ if (c?.type !== 'tool_use' || (c.name !== 'Task' && c.name !== 'Agent') || !c.id) continue;
3320
+ if (this.state.subAgents.some((s) => s.id === c.id)) continue; // idempotent
3321
+ const label = this._subAgentLabels.get(c.id) || clip(c.input?.description || c.input?.prompt, SUBAGENT_LABEL_MAX);
3322
+ const rec = {
3323
+ id: c.id,
3324
+ label: label || null,
3325
+ nodeId: attr.nodeId ?? null,
3326
+ uiPhase: attr.uiPhase ?? null,
3327
+ stepIndex: attr.stepIndex ?? null,
3328
+ cycle: attr.cycle ?? null,
3329
+ stepKey: attr.stepKey ?? null,
3330
+ status: 'running',
3331
+ startedAt: new Date().toISOString(),
3332
+ finishedAt: null,
3333
+ subagentType: c.input?.subagent_type ?? null,
3334
+ // In-memory only (no column): lets _recordSubAgentTelemetry price this
3335
+ // child. A sub-agent runs on the PARENT node's endpoint, so the parent's
3336
+ // model is the right price — UNLESS the Task input names a model that
3337
+ // itself carries an explicit override, which then governs the child.
3338
+ // A bare alias ('haiku') with no catalog entry is not one, so it keeps
3339
+ // the parent's rather than silently reverting to the CLI's figure.
3340
+ model: subAgentCostModel(c.input?.model, attr.model),
3341
+ // PERSISTED (sub_agents.run_model): the model this child actually ran on —
3342
+ // the alias its Task call named (the sub-agent model directive asks for an
3343
+ // explicit one on every call), else the parent node's model, which is what
3344
+ // a child with no `model` inherits. KNOWN GAP: an agent definition's own
3345
+ // `model:` frontmatter outranks an omitted param and is invisible in the
3346
+ // stream, so such a child records the parent's model. Deliberately NOT
3347
+ // `model` above: that one is the PRICING model, which can differ for an
3348
+ // explicit alias carrying its own catalog cost entry.
3349
+ runModel: (typeof c.input?.model === 'string' && c.input.model.trim())
3350
+ ? c.input.model.trim()
3351
+ : (attr.model ?? null),
3352
+ };
3353
+ this.state.subAgents.push(rec);
3354
+ this._upsertSubAgent(rec);
3355
+ this._subAgentTransition('spawn', rec);
3356
+ }
3357
+ }
3358
+
3359
+ /**
3360
+ * Lifecycle finish reducer: scan a MAIN-stream event's content for a
3361
+ * tool_result whose tool_use_id is a tracked sub-agent. Set status =
3362
+ * is_error ? 'error' : 'finished' and stamp finishedAt, but ONLY while the
3363
+ * record is still 'running' (a late/duplicate tool_result must not flip a
3364
+ * terminal record back or re-emit). Mirrors to the table + emits a `finish`
3365
+ * delta. The finish envelope is `{type:'user', message:{content:[{type:
3366
+ * 'tool_result', tool_use_id, is_error?:true}]}}` — previously dropped.
3367
+ * A background launch ack (frame-level `tool_use_result.isAsync`/
3368
+ * `status:'async_launched'`) is NOT a finish; `_recordAsyncTaskClose` owns
3369
+ * that close.
3370
+ */
3371
+ _recordSubAgentFinishes(raw) {
3372
+ const content = raw?.message?.content;
3373
+ if (!Array.isArray(content)) return;
3374
+ // Probed (claude 2.1.251, 2026-08-31; ask/events.mjs saw the same shape on
3375
+ // 2.1.239): the user tool_result frame carries a TOP-LEVEL `tool_use_result`
3376
+ // object. Background mode marks it {isAsync:true, status:'async_launched'} —
3377
+ // that tool_result is only a LAUNCH ACK; the real completion arrives later
3378
+ // as a system/task_notification frame (_recordAsyncTaskClose). One frame per
3379
+ // tool_result in practice, so applying the frame's object to each block is
3380
+ // safe (ask/events.mjs makes the same assumption).
3381
+ const tur = raw?.tool_use_result;
3382
+ const obj = tur && typeof tur === 'object' && !Array.isArray(tur) ? tur : null;
3383
+ const isAck = !!obj && (obj.isAsync === true || obj.status === 'async_launched');
3384
+ for (const b of content) {
3385
+ if (b?.type !== 'tool_result' || !b.tool_use_id) continue;
3386
+ const rec = this.state.subAgents.find((s) => s.id === b.tool_use_id);
3387
+ if (!rec || rec.status !== 'running') continue; // unknown id or already terminal
3388
+ if (isAck) {
3389
+ // Launch ack — still running in the background; task_notification (or
3390
+ // the execution backstop) closes it. resolvedModel closes a spawn-time
3391
+ // gap: an agent definition's `model:` frontmatter is invisible in the
3392
+ // Task input, so a record with no runModel learns it here. Never
3393
+ // overwrites a spawn-set alias (the UI pill renders the value verbatim).
3394
+ if (rec.runModel == null && typeof obj.resolvedModel === 'string' && obj.resolvedModel) {
3395
+ rec.runModel = obj.resolvedModel;
3396
+ this._upsertSubAgent(rec);
3397
+ this._subAgentTransition('update', rec);
3398
+ }
3399
+ continue;
3400
+ }
3401
+ if (obj) {
3402
+ // Foreground completion telemetry — the same durationMs/tokens fields the
3403
+ // gated PostToolUse hook fills; tool_use_result carries no cost. With
3404
+ // WORCA_SUBAGENT_HOOKS on, _recordSubAgentTelemetry may re-write these
3405
+ // after the finish (it does not gate on status): last writer wins, and
3406
+ // both sources quote the same CLI figures — deliberate, not a race to fix.
3407
+ if (Number.isFinite(Number(obj.totalDurationMs))) rec.durationMs = Number(obj.totalDurationMs);
3408
+ if (Number.isFinite(Number(obj.totalTokens))) rec.tokens = Number(obj.totalTokens);
3409
+ if (rec.runModel == null && typeof obj.resolvedModel === 'string' && obj.resolvedModel) rec.runModel = obj.resolvedModel;
3410
+ }
3411
+ rec.status = b.is_error ? 'error' : 'finished';
3412
+ rec.finishedAt = new Date().toISOString();
3413
+ this._upsertSubAgent(rec);
3414
+ this._subAgentTransition('finish', rec);
3415
+ }
3416
+ }
3417
+
3418
+ /**
3419
+ * Background sub-agent completion. Probed (claude 2.1.251, 2026-08-31): when a
3420
+ * backgrounded Task/Agent stops, the MAIN stream emits
3421
+ * {type:'system', subtype:'task_notification', task_id, tool_use_id,
3422
+ * status:'completed'|…, output_file, summary, usage?}
3423
+ * — the one stop marker keyed by tool_use_id (task_started / task_updated /
3424
+ * background_tasks_changed frames surround it and are ignored). A resumable
3425
+ * agent may notify more than once for the same task; the status!=='running'
3426
+ * guard makes repeats no-ops. Anything but status==='completed' closes as
3427
+ * 'error'. finishedAt = arrival time (observed ≤30ms after the agent stops).
3428
+ * usage.{duration_ms,total_tokens} rode along on the 2.1.239 capture
3429
+ * (test/fixtures/ask/task-subagent.jsonl:41) but is OPTIONAL — without it,
3430
+ * durationMs stays null and the UI's timestamp fallback is real wall time
3431
+ * for an async agent. No cost figure exists here; costUsd stays hook-gated.
3432
+ */
3433
+ _recordAsyncTaskClose(raw) {
3434
+ if (raw?.type !== 'system' || raw?.subtype !== 'task_notification' || !raw.tool_use_id) return;
3435
+ const rec = this.state.subAgents.find((s) => s.id === raw.tool_use_id);
3436
+ if (!rec || rec.status !== 'running') return;
3437
+ const u = raw.usage;
3438
+ if (u && typeof u === 'object' && !Array.isArray(u)) {
3439
+ if (Number.isFinite(Number(u.duration_ms))) rec.durationMs = Number(u.duration_ms);
3440
+ if (Number.isFinite(Number(u.total_tokens))) rec.tokens = Number(u.total_tokens);
3441
+ }
3442
+ rec.status = raw.status === 'completed' ? 'finished' : 'error';
3443
+ rec.finishedAt = new Date().toISOString();
3444
+ this._upsertSubAgent(rec);
3445
+ this._subAgentTransition('finish', rec);
3446
+ }
3447
+
3448
+ /**
3449
+ * Record skills / MCP-tools used in one agent event. Routes by parent_tool_use_id:
3450
+ * a MAIN-agent turn (subId == null) attributes to its pipeline step (by stepKey);
3451
+ * a sub-agent turn (subId != null) attributes to the spawned record (id === subId).
3452
+ * Grows a deduped, capped `skills` array and emits a delta + persists ONLY when the
3453
+ * set actually changed. No-op when there is nothing to attribute to (e.g. the
3454
+ * clarify pre-step has no step; a child event seen before its spawn).
3455
+ */
3456
+ _recordSkills(raw, subId, attr) {
3457
+ const labels = extractSkillLabels(raw);
3458
+ if (!labels.length) return;
3459
+ if (subId == null) {
3460
+ const key = attr?.stepKey;
3461
+ const step = key ? this.state.steps.find((s) => s.key === key) : null;
3462
+ if (!step) return;
3463
+ const merged = mergeSkills(step.skills, labels);
3464
+ if (!merged) return;
3465
+ step.skills = merged;
3466
+ this._emit('stepskills', {
3467
+ stepKey: step.key,
3468
+ nodeId: step.nodeId ?? null,
3469
+ cycle: step.cycle ?? null,
3470
+ skills: merged,
3471
+ ts: new Date().toISOString(),
3472
+ });
3473
+ this._persist().catch(() => {}); // mirrors _recordCost: per-step skills survive a reload
3474
+ } else {
3475
+ const rec = this.state.subAgents.find((s) => s.id === subId);
3476
+ if (!rec) return;
3477
+ const merged = mergeSkills(rec.skills, labels);
3478
+ if (!merged) return;
3479
+ rec.skills = merged;
3480
+ this._upsertSubAgent(rec);
3481
+ this._subAgentTransition('update', rec);
3482
+ }
3483
+ }
3484
+
3485
+ /**
3486
+ * Count graphify CLI invocations (Bash only) in one agent event and add them to
3487
+ * the running total. Routes exactly like _recordSkills: a MAIN-agent turn
3488
+ * (subId == null) accrues onto its pipeline step (by stepKey) and emits a
3489
+ * `stepgraphify` delta; a sub-agent turn accrues onto the spawned record and
3490
+ * emits a `subagent` update. No-op when the event invoked graphify zero times or
3491
+ * there is nothing to attribute to (clarify pre-step; child seen before spawn).
3492
+ */
3493
+ _recordGraphify(raw, subId, attr) {
3494
+ const n = countGraphifyBashCalls(raw);
3495
+ if (!n) return;
3496
+ if (subId == null) {
3497
+ const key = attr?.stepKey;
3498
+ const step = key ? this.state.steps.find((s) => s.key === key) : null;
3499
+ if (!step) return;
3500
+ step.graphifyCount = (step.graphifyCount ?? 0) + n;
3501
+ this._emit('stepgraphify', {
3502
+ stepKey: step.key,
3503
+ nodeId: step.nodeId ?? null,
3504
+ cycle: step.cycle ?? null,
3505
+ graphifyCount: step.graphifyCount,
3506
+ ts: new Date().toISOString(),
3507
+ });
3508
+ this._persist().catch(() => {}); // mirrors _recordSkills: survives a reload
3509
+ } else {
3510
+ const rec = this.state.subAgents.find((s) => s.id === subId);
3511
+ if (!rec) return;
3512
+ rec.graphifyCount = (rec.graphifyCount ?? 0) + n;
3513
+ this._upsertSubAgent(rec);
3514
+ this._subAgentTransition('update', rec);
3515
+ }
3516
+ }
3517
+
3518
+ /** Best-effort mirror of a sub-agent record to the sub_agents table. Guarded
3519
+ * exactly like _persist/_artifact: no pipeline → in-memory only (unit ctx). */
3520
+ _upsertSubAgent(rec) {
3521
+ if (!this.pipeline) return;
3522
+ try { upsertSubAgent(this.pipeline.id, rec); } catch { /* best-effort */ }
3523
+ }
3524
+
3525
+ /** Emit a hybrid `subagent` delta. The full `state` snapshot remains the
3526
+ * reconcile/late-join source of truth (it carries subAgents). */
3527
+ _subAgentTransition(transition, rec) {
3528
+ this._emit('subagent', {
3529
+ transition,
3530
+ id: rec.id,
3531
+ label: rec.label ?? null,
3532
+ nodeId: rec.nodeId ?? null,
3533
+ uiPhase: rec.uiPhase ?? null,
3534
+ stepKey: rec.stepKey ?? null,
3535
+ stepIndex: rec.stepIndex ?? null,
3536
+ cycle: rec.cycle ?? null,
3537
+ status: rec.status,
3538
+ ...(rec.durationMs != null ? { durationMs: rec.durationMs } : {}),
3539
+ ...(rec.tokens != null ? { tokens: rec.tokens } : {}),
3540
+ ...(rec.costUsd != null ? { costUsd: rec.costUsd } : {}),
3541
+ ...(Array.isArray(rec.skills) ? { skills: rec.skills } : {}),
3542
+ ...(rec.subagentType != null ? { subagentType: rec.subagentType } : {}),
3543
+ ...(rec.graphifyCount != null ? { graphifyCount: rec.graphifyCount } : {}),
3544
+ // The model pill's live feed: without this the Running view paints no pill
3545
+ // until the next full state snapshot replaces r.subAgents.
3546
+ ...(rec.runModel != null ? { runModel: rec.runModel } : {}),
3547
+ ts: new Date().toISOString(),
3548
+ });
3549
+ }
3550
+
3551
+ /**
3552
+ * Telemetry enrichment from a surfaced PostToolUse:Agent hook-event. Reads the
3553
+ * parent tool_use_id + tool_response.{totalDurationMs,totalTokens,usage} and
3554
+ * fills the matching sub-agent record's durationMs/tokens/costUsd (only those
3555
+ * present), mirrors to the table, and emits an `update` delta. No-op for an
3556
+ * unknown id or a non-Agent hook. Strictly additive — the baseline lifecycle
3557
+ * needs none of this.
3558
+ */
3559
+ _recordSubAgentTelemetry(raw) {
3560
+ const id = raw?.tool_use_id ?? raw?.tool_response?.tool_use_id ?? null;
3561
+ if (!id) return;
3562
+ const rec = this.state.subAgents.find((s) => s.id === id);
3563
+ if (!rec) return;
3564
+ const tr = raw?.tool_response || {};
3565
+ if (Number.isFinite(Number(tr.totalDurationMs))) rec.durationMs = Number(tr.totalDurationMs);
3566
+ if (Number.isFinite(Number(tr.totalTokens))) rec.tokens = Number(tr.totalTokens);
3567
+ const cost = tr.usage?.cost_usd ?? tr.usage?.total_cost_usd ?? tr.cost_usd;
3568
+ if (Number.isFinite(Number(cost))) {
3569
+ // Apply the same per-model cost override as the node result path, so a
3570
+ // sub-agent of a free/priced model doesn't display the CLI's fabricated
3571
+ // figure. rec.model is set at spawn (see subAgentCostModel); absent (e.g.
3572
+ // after a resume, which rebuilds records from the table) → the CLI value
3573
+ // stands. A {perMtok} model with unpriceable usage yields NaN — leave the
3574
+ // row's cost UNSET rather than write a made-up figure into the display.
3575
+ const resolved = rec.model ? resolveModelCost(rec.model, Number(cost), tr.usage) : Number(cost);
3576
+ if (Number.isFinite(resolved)) rec.costUsd = resolved;
3577
+ }
3578
+ this._upsertSubAgent(rec);
3579
+ this._subAgentTransition('update', rec);
3580
+ }
3581
+
3582
+
3583
+
3584
+
3585
+ /**
3586
+ * Attribute a dollar cost to the step currently executing and roll it into
3587
+ * the pipeline total. The active step is identified by the live (phase,cycle)
3588
+ * — the SAME key _recordStep uses — because a `result` event always arrives
3589
+ * between that phase's 'start' and 'done' markers. Records the figure even when
3590
+ * it is 0 (so mock runs DISPLAY a truthful $0.00 rather than a blank); only
3591
+ * NaN/negative are ignored. Multiple results on one step accumulate. Emits a
3592
+ * 'state' snapshot so a live UI updates, and persists so history (state.json)
3593
+ * carries the figure.
3594
+ * @param {number} costUsd
3595
+ */
3596
+ _recordCost(costUsd, stepKey = null) {
3597
+ if (!Number.isFinite(costUsd) || costUsd < 0) return;
3598
+ const key = stepKey
3599
+ || (this.state.cycle ? `${this.state.phase}#${this.state.cycle}` : this.state.phase);
3600
+ const step = this.state.steps.find((s) => s.key === key);
3601
+ if (step) step.costUsd = roundUsd((step.costUsd || 0) + costUsd);
3602
+ // Derive the pipeline total from the per-step figures so it ALWAYS equals
3603
+ // their sum. Keeping a separate running total and rounding it on every add
3604
+ // drifts from Σ steps (e.g. 0.00005 + 0.00015 gave total 0.0003 vs Σ 0.0002).
3605
+ this.state.totalCostUsd = sumStepCosts(this.state.steps);
3606
+ // Append-only spend ledger (windowed budget accounting). Best-effort:
3607
+ // accounting must never kill a run; ledger and state share the same DB,
3608
+ // so failures co-occur with the _persist catch below anyway.
3609
+ if (costUsd > 0 && this.pipeline?.id) {
3610
+ try { recordCostDelta({ pipelineId: this.pipeline.id, stepKey: key, amountUsd: costUsd }); }
3611
+ catch (err) { this._log('orchestrator', 'warn', `cost ledger write failed: ${err?.message || err}`); }
3612
+ }
3613
+ this.state.updatedAt = new Date().toISOString();
3614
+ this._emit('state', this.getState());
3615
+ this._persist().catch(() => {});
3616
+ }
3617
+
3618
+ _emit(event, payload) {
3619
+ try {
3620
+ this.emit(event, payload);
3621
+ } catch {
3622
+ /* never let a listener crash the state machine */
3623
+ }
3624
+ }
3625
+
3626
+ /**
3627
+ * Options for the title-generation spawn. The title call is the one claude
3628
+ * process a RUN starts outside runOpts, so it must mirror the run's claude
3629
+ * policy (bin, mock, env scrub) rather than inherit runClaude's PATH/env
3630
+ * defaults — a run built with claude:{mock:true} spawned the developer's REAL
3631
+ * binary 157x per `npm test` until 2026-08-30. Exposed as a method so the
3632
+ * plumbing is unit-testable (ESM imports cannot be spied).
3633
+ */
3634
+ _titleGenOpts() {
3635
+ return {
3636
+ // §2.1 row 3: fire-and-forget title generation was the one remaining worca-cc
3637
+ // process started inside the user's LIVE checkout. Once a run root exists
3638
+ // there is no reason for it. The kickoff site moved to just after
3639
+ // _setupRunRoot() so runCwd is populated here.
3640
+ cwd: this.runCwd ?? this.projectDir,
3641
+ signal: this.abort.signal,
3642
+ bin: this.claude.bin,
3643
+ mock: this.claude.mock,
3644
+ // Same env policy as the pipeline nodes. Both undefined on an unconfigured
3645
+ // project ⇒ byte-identical spawn env (legacy parity).
3646
+ envScrub: this.guardrails?.envScrub || undefined,
3647
+ envAllowlist: this.guardrails?.envScrub ? this.guardrails.envAllowlist : undefined,
3648
+ };
3649
+ }
3650
+
3651
+ /**
3652
+ * Fire-and-forget: generate a concise LLM title and, when ready, persist + broadcast it.
3653
+ * The promise is stored on this._titlePromise for test determinism but is NEVER awaited
3654
+ * by run() (must not delay the run). Aborts with the run via this.abort.signal.
3655
+ */
3656
+ _kickoffTitleGeneration() {
3657
+ const prompt = this.pipeline?.promptText || this.opts.prompt || '';
3658
+ const id = this.pipeline?.id;
3659
+ if (!prompt || !id) { this._titlePromise = Promise.resolve(); return; }
3660
+ this._titlePromise = Promise.resolve()
3661
+ .then(() => generateTitle(prompt, this._titleGenOpts()))
3662
+ .then((real) => {
3663
+ if (!real || real === this.state.title) return; // empty / unchanged → keep provisional
3664
+ if (this.abort.signal.aborted) return;
3665
+ this.state.title = real;
3666
+ this.state.titleProvisional = false;
3667
+ this.state.updatedAt = new Date().toISOString();
3668
+ updatePipelineTitle(id, real); // persist (dedicated UPDATE)
3669
+ // Carry pipelineId: the client run model has no pipeline id; History patch needs it.
3670
+ this._emit('title', { title: real, provisional: false, pipelineId: id }); // live broadcast
3671
+ })
3672
+ .catch(() => { /* generateTitle already swallows; this is a final backstop */ });
3673
+ }
3674
+
3675
+ async _persist() {
3676
+ if (!this.pipeline) return;
3677
+ try {
3678
+ await writeState(this.pipeline.dir, this.state);
3679
+ } catch {
3680
+ /* persistence is best-effort */
3681
+ }
3682
+ }
3683
+
3684
+ /** Begin owning this run's row: stamp pid/host + start the heartbeat timer. Idempotent. */
3685
+ _startHeartbeat() {
3686
+ if (!this.pipeline?.id) return;
3687
+ claimPipelineOwnership(this.pipeline.id);
3688
+ if (this._heartbeatTimer) return;
3689
+ this._heartbeatTimer = setInterval(() => {
3690
+ try { touchHeartbeat(this.pipeline.id); } catch { /* best-effort */ }
3691
+ }, HEARTBEAT_INTERVAL_MS);
3692
+ this._heartbeatTimer.unref?.(); // never hold the process open
3693
+ }
3694
+
3695
+ /** Stop heartbeating and drop ownership (terminal/paused). Safe to call repeatedly. */
3696
+ _stopHeartbeat() {
3697
+ if (this._heartbeatTimer) { clearInterval(this._heartbeatTimer); this._heartbeatTimer = null; }
3698
+ if (this.pipeline?.id) clearPipelineOwnership(this.pipeline.id);
3699
+ }
3700
+
3701
+ /** Terminal bookkeeping for a pause: persist the resume point + paused status.
3702
+ * An ERROR-pause additionally keeps what the retired error path produced — the
3703
+ * diff artifact and the task-source write-back (statusToResult('paused') ->
3704
+ * 'needs-human'; retryWriteback re-reads the ROW, so this runs AFTER the persist).
3705
+ * Safe here and only here: the checkout and the checkpoint refs are live, and the
3706
+ * finally never tears a paused run down. Both helpers are no-ops with an empty
3707
+ * workDirs (a setup-phase pause). */
3708
+ async _completePaused() {
3709
+ // D7: a pause that landed BEFORE run()'s setup finished (a converted setup failure,
3710
+ // or a user pause racing one — run()'s pause branch catches a plain error while
3711
+ // 'pausing') must replay that setup on resume; a completed setup never leaves a
3712
+ // stale stamp behind (resume() re-arms the consumed point, which may carry one).
3713
+ const rp = this.state.resumePoint;
3714
+ if (rp && typeof rp === 'object') {
3715
+ if (this._setupDone) { delete rp.setupIncomplete; delete rp.titleProvisional; }
3716
+ else {
3717
+ rp.setupIncomplete = true;
3718
+ // Whether run() ever kicked the LLM title off (it does so right after the run
3719
+ // root exists): the replay reads this to finish the job, since the flag is
3720
+ // not a row column.
3721
+ rp.titleProvisional = this.state.titleProvisional === true;
3722
+ }
3723
+ }
3724
+ this._setStatus('paused');
3725
+ await this._persist();
3726
+ // A plain manual pause has no reason; every reasoned pause audited at its site.
3727
+ if (!this.pauseReason) await appendAudit(this.pipeline.dir, `Pipeline **paused**.`).catch(() => {});
3728
+ // A FORCED pause (pauseReason set: usage limit, cost cap, auto-mode
3729
+ // auth/quota, exhausted recoverable retries, an error) parks the run with
3730
+ // nobody attached, so the task source must hear it NOW — statusToResult
3731
+ // ('paused') -> 'needs-human' — or the external task stays claimed "in
3732
+ // progress" until a human stumbles on it. A manual pause skips this: the
3733
+ // user is present and resuming shortly, and the resumed run's terminal path
3734
+ // reports the real outcome. An ERROR-pause additionally keeps the diff
3735
+ // artifact the retired error path produced. Never throws (spec §7.5).
3736
+ const consequences = pauseConsequences(this.pauseReason);
3737
+ if (consequences.stagesResults) await this._buildResults({ stage: true });
3738
+ if (consequences.reportsToSource) await this._reportToSource();
3739
+ const payload = {
3740
+ status: 'paused',
3741
+ pipelineDir: this.pipeline.dir,
3742
+ reason: this.pauseReason || null,
3743
+ detail: this.pauseDetail || null,
3744
+ };
3745
+ this._emit('done', payload);
3746
+ return { ...payload };
3747
+ }
3748
+
3749
+ /** Engine hook (optional): the LAST clean graph point the engine holds — the final
3750
+ * all-terminal snapshot after a completed run, the last clean one mid-run. The
3751
+ * failure fallback prefers it over the pre-dispatch point so a failure AFTER the
3752
+ * engine finished never re-runs the graph (D14). Base engines: none. */
3753
+ _engineLastPoint() { return null; }
3754
+
3755
+ /** Engine hook (optional): the run's distinct agent keys from the frozen manifest —
3756
+ * what the skills gate needs on a setup replay (D7). Base engines: none. */
3757
+ _engineAgentKeys() { return new Set(); }
3758
+
3759
+ /**
3760
+ * The SETUP / SHELL / RESUME site (failure-policy.mjs): a failure the shell sees
3761
+ * once the pipeline row exists — before run()'s setup finished ('setup'), after it
3762
+ * ('shell'), or before resume() rehydrated the paused run ('resume', where the
3763
+ * only verdict that can be enacted is to end the run: the point on disk is
3764
+ * already the best the run can offer). A verdict already issued downstream (a terminal error from the node
3765
+ * or flow site) is enacted, never re-decided. Returns null when the verdict is a
3766
+ * terminal error — the caller then falls through to the error path — else records
3767
+ * the cause; kills anything still in flight (pause() is a no-op unless the run is
3768
+ * 'running', and the status write below is unconditional); picks the resume
3769
+ * point MOST-SPECIFIC FIRST — state.resumePoint (the engine's live point), the
3770
+ * engine's LAST clean point (the final all-terminal snapshot when the failure
3771
+ * came after the engine finished, D14), the consumed `fallbackPoint` (resume()'s
3772
+ * rp), else the engine's pre-dispatch point; scrubs any terminal error row (and
3773
+ * the fail-fast's skipped collateral) out of its snapshot; then completes as a
3774
+ * pause — _completePaused stamps/strips `setupIncomplete` from _setupDone (D7)
3775
+ * for EVERY pause path. Emits NO 'error' event: the `done` payload carries the
3776
+ * reason + detail, the run log the line.
3777
+ */
3778
+ async _pauseForFailure(err, fallbackPoint = null) {
3779
+ const site = !this._rehydrated ? 'resume' : this._setupDone ? 'shell' : 'setup';
3780
+ const verdict = isTerminal(err) ? { outcome: 'error' }
3781
+ : resolveFailure({ site, cls: classifyError(err), auto: this.auto });
3782
+ if (verdict.outcome !== 'pause') { markTerminal(err); return null; }
3783
+ this._pauseFor(verdict.reason, err);
3784
+ const source = this.state.resumePoint || this._engineLastPoint() || fallbackPoint || this._enginePrePausePoint();
3785
+ this.state.resumePoint = {
3786
+ ...source,
3787
+ snapshot: scrubErrorRows(source.snapshot ?? null),
3788
+ pauseReason: this.pauseReason,
3789
+ pauseDetail: this.pauseDetail,
3790
+ pausedAt: new Date().toISOString(),
3791
+ };
3792
+ return await this._completePaused();
3793
+ }
3794
+
3795
+ /** The LAUNCH site: no pipeline row yet, so a terminal error is the only verdict
3796
+ * the shell can enact. Consulted for completeness — a row flipped to 'pause'
3797
+ * cannot be honored here and says so in the run log. */
3798
+ _launchVerdict(err) {
3799
+ const verdict = resolveFailure({ site: 'launch', cls: classifyError(err), auto: this.auto });
3800
+ if (verdict.outcome !== 'error') {
3801
+ this._log('orchestrator', 'warn', `failure policy asks to ${verdict.outcome} a launch failure, but no pipeline row exists to resume into — ending the run as an error`);
3802
+ }
3803
+ markTerminal(err);
3804
+ }
3805
+
3806
+ /**
3807
+ * resume() of a point whose run() never finished its setup (D7) — run()'s steps
3808
+ * 3..3e in order, abort-checked, each guarded so a step that DID complete is not
3809
+ * redone. Returns the resolved skill map for the detached assembly.
3810
+ * @returns {Promise<Map>} resolvedSkills
3811
+ */
3812
+ async _replaySetup() {
3813
+ // 3) checkpoint
3814
+ if (!this.checkpointRef) {
3815
+ if (this.isWorkspace) await this._ensureGitCheckpointAll(); else await this._ensureGitCheckpoint();
3816
+ } else if (!this.isWorkspace) {
3817
+ const onlyKey = this.members[0]?.projectKey;
3818
+ if (onlyKey && !this.checkpointRefs[onlyKey]) {
3819
+ this.checkpointRefs[onlyKey] = this.checkpointRef;
3820
+ this.state.checkpointRefs = { ...this.checkpointRefs };
3821
+ }
3822
+ }
3823
+ // run() closes the preflight bookend right after the checkpoint; the paused
3824
+ // run's ledger still holds it at 'start' (the rehydrated steps), so close it
3825
+ // here or a finished run keeps an open preflight row forever.
3826
+ this._bookend('preflight', 'done');
3827
+ this._checkAbort();
3828
+ // 3b) run root + worktrees — keyed on the per-member map, NEVER on this.workDir
3829
+ // (it defaults to projectDir and is never falsy).
3830
+ const missing = this.members.some((m) => !this.workDirs.get(m.projectKey));
3831
+ if (missing) await this._setupRunRoot({ replay: true });
3832
+ // run() kicks the LLM title off once runCwd exists; a run that paused before
3833
+ // that point still carries its provisional title, so kick it off now. A run
3834
+ // that got past it already holds the generated row.title (loaded by resume()).
3835
+ if (this.state.titleProvisional) this._kickoffTitleGeneration();
3836
+ this._checkAbort();
3837
+ // 3c) graph build (fail-safe, idempotent)
3838
+ if (this.isWorkspace) await this._buildWorktreeGraphAll(); else await this._buildWorktreeGraph();
3839
+ this._checkAbort();
3840
+ // 3d) the skills gate + legacy injection — run()'s block, agent keys from the frozen manifest
3841
+ const requiredSkills = collectRequiredSkills(this.registry, this._engineAgentKeys());
3842
+ let resolvedSkills = new Map();
3843
+ if (requiredSkills.length) {
3844
+ const skillCtx = { repoRoot: REPO_ROOT, projectDir: this.projectDir, pluginDirs: pluginSkillDirs() };
3845
+ resolvedSkills = validateSkills(requiredSkills, skillCtx); // throws => caught => the run PAUSES again
3846
+ if (this.runRootMode !== 'detached') {
3847
+ const candidates = this.isWorkspace ? [...this.workDirs.values()] : [this.workDir];
3848
+ const worktrees = candidates.filter((d) => d && d !== this.projectDir);
3849
+ const injected = await injectSkills(resolvedSkills, { targets: worktrees });
3850
+ if (injected.length) {
3851
+ await appendAudit(this.pipeline.dir, `Skills: injected ${injected.join(', ')} into ${worktrees.length} worktree(s).`);
3852
+ }
3853
+ }
3854
+ }
3855
+ this._checkAbort();
3856
+ await appendAudit(this.pipeline.dir, 'Setup replayed on resume (the paused run never finished it).').catch(() => {});
3857
+ return resolvedSkills;
3858
+ }
3859
+
3860
+ // ── engine hooks ─────────────────────────────────────────────────────────────
3861
+ // The harness is engine-agnostic; everything an engine decides sits behind
3862
+ // these six seams. The base throws so a half-built engine fails loudly at the
3863
+ // seam instead of running a half-configured pipeline.
3864
+
3865
+ /** Resolve the run's topology from the merged registry.
3866
+ * @param {Record<string,object>} _registry loadAgentRegistry() output
3867
+ * @returns {Promise<{manifest:object, agentKeys:Set<string>, workflow:{id:string,name:string}}>}
3868
+ * All three fields are REQUIRED; the shell throws a named 'engine hook
3869
+ * contract' error when one is missing. manifest -> state.stepper (the UI
3870
+ * snapshot); agentKeys -> the §9.4 preflight gate + the skills gate;
3871
+ * workflow -> the run's audit line. */
3872
+ async _resolveTopology(_registry) { throw new Error('engine hook not implemented: _resolveTopology'); }
3873
+
3874
+ /** Run the pipeline to completion or to a pause.
3875
+ * @param {{resume?:object|null, rehydrated?:object|null}} _args resume point + _engineRehydrate's bag
3876
+ * @returns {Promise<'done'|'paused'>} */
3877
+ async _engineRun(_args) { throw new Error('engine hook not implemented: _engineRun'); }
3878
+
3879
+ /** The resume point recorded when a pause unwinds BEFORE the engine started
3880
+ * (preflight/worktree). @returns {object} */
3881
+ _enginePrePausePoint() { throw new Error('engine hook not implemented: _enginePrePausePoint'); }
3882
+
3883
+ /** Read the engine-specific parts of a resume point; throws when the point is
3884
+ * not this engine's. Called at the position of dev's version gate: BEFORE the
3885
+ * shell has rehydrated any state (state.*, pipeline, logWriter, stepModels,
3886
+ * workflowId, guardrails are NOT restored yet) and OUTSIDE the shell's try —
3887
+ * a throw here rejects resume() without touching the row. Keep it pure: read
3888
+ * rp, decide whether the point is yours, return the bag. Engine restoration
3889
+ * that needs state/registry/pipeline (manifest adoption, prompt hydration,
3890
+ * the §9.4 re-preflight) belongs in _engineRun({resume, rehydrated}), which
3891
+ * runs inside the try after everything is restored — exactly where v1 does
3892
+ * its re-preflight. May be async: the shell awaits this call.
3893
+ * @param {object} _rp
3894
+ * @returns {{checkpointRef:string|null,
3895
+ * memberWorktrees:Array<{projectKey:string, worktreeDir:string, graphInstruction:string}>,
3896
+ * plan?:object|null, audit:string}} audit is REQUIRED — the shell
3897
+ * writes it verbatim as the resume audit line. */
3898
+ _engineRehydrate(_rp) { throw new Error('engine hook not implemented: _engineRehydrate'); }
3899
+
3900
+ /** Preflight/Done are ledger rows like any other execution: keyed
3901
+ * `x:<name>:1`, agentKey null, excluded from progress and execution counts by
3902
+ * the readers (run-decor's ledgerRows, cli/render's summary). */
3903
+ _bookend(name, status) {
3904
+ const executionId = `x:${name}:1`;
3905
+ // _recordStep keys on `cycle ? phase#cycle : phase`, so pass cycle 0 to get
3906
+ // the executionId VERBATIM as the ledger key, then stamp the exec columns.
3907
+ // `executionId` is NOT optional: artifacts.mjs persists execution_id from it,
3908
+ // and without it a REHYDRATED run stops filtering the bookends.
3909
+ this._recordStep(executionId, 0, status, name);
3910
+ const row = this.state.steps.find((s) => s.key === executionId);
3911
+ if (row) {
3912
+ Object.assign(row, {
3913
+ executionId, nodeId: name, phase: null, cycle: 1, kind: 'cycle', ordinal: 1,
3914
+ agentKey: null, stepIndex: null, trigger: { wireIds: [], freshPorts: [] },
3915
+ });
3916
+ }
3917
+ this.state.updatedAt = new Date().toISOString();
3918
+ this._emit('exec', {
3919
+ nodeId: name, executionId, kind: 'cycle', ordinal: 1, status,
3920
+ agentKey: null, trigger: { wireIds: [], freshPorts: [] },
3921
+ });
3922
+ this._emit('state', this.getState());
3923
+ this._persist().catch(() => {});
3924
+ }
3925
+
3926
+ /** Constructor seam for the v1 runner registry (v1 only; the graph engine
3927
+ * injects its runners through the executor). Called from the constructor at
3928
+ * the exact position the assignment had. */
3929
+ _initRunners(_opts) { /* base: no runner registry */ }
3930
+ }
3931
+
3932
+ /** TEST-ONLY: the skill-label helpers `test/skill-capture.test.mjs` pins. They
3933
+ * are harness code, so they outlive the v1 engine that used to re-export them. */
3934
+ export const _testing = { SKILLS_MAX, skillLabel, mergeSkills };