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