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