@smartmemory/compose 0.1.0

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 (181) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1014 -0
  3. package/bin/compose.js +1515 -0
  4. package/dist/assets/_baseUniq-CQwX6VLz.js +1 -0
  5. package/dist/assets/arc-SxJ2J1sh.js +1 -0
  6. package/dist/assets/architectureDiagram-Q4EWVU46-BykunY1F.js +36 -0
  7. package/dist/assets/blockDiagram-DXYQGD6D-ohAKBOUw.js +132 -0
  8. package/dist/assets/c4Diagram-AHTNJAMY-DBDC3ENB.js +10 -0
  9. package/dist/assets/channel-DGElom1e.js +1 -0
  10. package/dist/assets/chunk-4BX2VUAB-Cv93Z7uM.js +1 -0
  11. package/dist/assets/chunk-4TB4RGXK-DE0WBDkj.js +206 -0
  12. package/dist/assets/chunk-55IACEB6-CE1EXenG.js +1 -0
  13. package/dist/assets/chunk-EDXVE4YY-DA7Ana6H.js +1 -0
  14. package/dist/assets/chunk-FMBD7UC4-CTDIPA3p.js +15 -0
  15. package/dist/assets/chunk-OYMX7WX6-uGBaPaTX.js +231 -0
  16. package/dist/assets/chunk-QZHKN3VN-CYlnXuUO.js +1 -0
  17. package/dist/assets/chunk-YZCP3GAM-ojGkzcZK.js +1 -0
  18. package/dist/assets/classDiagram-6PBFFD2Q-KqWP9wWZ.js +1 -0
  19. package/dist/assets/classDiagram-v2-HSJHXN6E-KqWP9wWZ.js +1 -0
  20. package/dist/assets/clone-DUJKJXd7.js +1 -0
  21. package/dist/assets/cose-bilkent-S5V4N54A-Bktn9hL-.js +1 -0
  22. package/dist/assets/dagre-KV5264BT-DFaSzuRF.js +4 -0
  23. package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  24. package/dist/assets/diagram-5BDNPKRD-DnfmDzEm.js +10 -0
  25. package/dist/assets/diagram-G4DWMVQ6-Bm8W9YnG.js +24 -0
  26. package/dist/assets/diagram-MMDJMWI5-B5-TSKvp.js +43 -0
  27. package/dist/assets/diagram-TYMM5635-ls4rqlky.js +24 -0
  28. package/dist/assets/erDiagram-SMLLAGMA-giG6WO-r.js +85 -0
  29. package/dist/assets/flowDiagram-DWJPFMVM-XvlUuz-7.js +162 -0
  30. package/dist/assets/ganttDiagram-T4ZO3ILL-hLBV57oV.js +292 -0
  31. package/dist/assets/gitGraphDiagram-UUTBAWPF-BHu3s_Gn.js +106 -0
  32. package/dist/assets/graph-D0Cfv00Y.js +1 -0
  33. package/dist/assets/index-CUd6pFGF.css +1 -0
  34. package/dist/assets/index-DReRlzZI.js +1144 -0
  35. package/dist/assets/infoDiagram-42DDH7IO-DbqRsOo3.js +2 -0
  36. package/dist/assets/init-Gi6I4Gst.js +1 -0
  37. package/dist/assets/ishikawaDiagram-UXIWVN3A-DnCdx7zb.js +70 -0
  38. package/dist/assets/journeyDiagram-VCZTEJTY-CfD7eNcP.js +139 -0
  39. package/dist/assets/kanban-definition-6JOO6SKY-BYaO9-mK.js +89 -0
  40. package/dist/assets/katex-DkKDou_j.js +257 -0
  41. package/dist/assets/layout-Bj72wOEB.js +1 -0
  42. package/dist/assets/linear-BRFo114D.js +1 -0
  43. package/dist/assets/min-GCHnKlJS.js +1 -0
  44. package/dist/assets/mindmap-definition-QFDTVHPH-n0PMebY4.js +96 -0
  45. package/dist/assets/ordinal-Cboi1Yqb.js +1 -0
  46. package/dist/assets/pieDiagram-DEJITSTG-pN4CljHF.js +30 -0
  47. package/dist/assets/quadrantDiagram-34T5L4WZ-DNoAy8-D.js +7 -0
  48. package/dist/assets/requirementDiagram-MS252O5E-BhtY05PT.js +84 -0
  49. package/dist/assets/sankeyDiagram-XADWPNL6-B6AD-16A.js +10 -0
  50. package/dist/assets/sequenceDiagram-FGHM5R23-DShHM-uk.js +157 -0
  51. package/dist/assets/stateDiagram-FHFEXIEX-DMxn7HTo.js +1 -0
  52. package/dist/assets/stateDiagram-v2-QKLJ7IA2-o6PnCs4e.js +1 -0
  53. package/dist/assets/timeline-definition-GMOUNBTQ-Cdu6uq52.js +120 -0
  54. package/dist/assets/vennDiagram-DHZGUBPP-CpK29iRe.js +34 -0
  55. package/dist/assets/wardley-RL74JXVD-BQgSkdcO.js +162 -0
  56. package/dist/assets/wardleyDiagram-NUSXRM2D-DJHYev6O.js +20 -0
  57. package/dist/assets/xychartDiagram-5P7HB3ND-1d75pbaO.js +7 -0
  58. package/dist/index.html +30 -0
  59. package/lib/agent-chains.js +65 -0
  60. package/lib/agent-string.js +86 -0
  61. package/lib/budget-ledger.js +86 -0
  62. package/lib/build-all.js +162 -0
  63. package/lib/build-dag.js +120 -0
  64. package/lib/build-stream-writer.js +190 -0
  65. package/lib/build.js +2997 -0
  66. package/lib/capability-checker.js +53 -0
  67. package/lib/cert-inject.js +38 -0
  68. package/lib/cli-progress.js +483 -0
  69. package/lib/constants.js +69 -0
  70. package/lib/cross-layer-audit.js +84 -0
  71. package/lib/debug-discipline.js +173 -0
  72. package/lib/feature-json.js +106 -0
  73. package/lib/gate-prompt.js +291 -0
  74. package/lib/gate-tiers.js +194 -0
  75. package/lib/health-history.js +119 -0
  76. package/lib/health-score.js +227 -0
  77. package/lib/ideabox.js +570 -0
  78. package/lib/import.js +244 -0
  79. package/lib/migrate-roadmap.js +94 -0
  80. package/lib/model-pricing.js +67 -0
  81. package/lib/new.js +413 -0
  82. package/lib/pipeline-cli.js +489 -0
  83. package/lib/plan-parser.js +103 -0
  84. package/lib/qa-scoping.js +474 -0
  85. package/lib/questionnaire.js +200 -0
  86. package/lib/resolve-port.js +7 -0
  87. package/lib/result-normalizer.js +349 -0
  88. package/lib/review-lenses.js +166 -0
  89. package/lib/roadmap-gen.js +210 -0
  90. package/lib/roadmap-parser.js +176 -0
  91. package/lib/server-probe.js +23 -0
  92. package/lib/staleness.js +87 -0
  93. package/lib/step-prompt.js +260 -0
  94. package/lib/step-validator.js +49 -0
  95. package/lib/stratum-mcp-client.js +365 -0
  96. package/lib/team-flag.js +46 -0
  97. package/lib/test-bootstrap.js +401 -0
  98. package/lib/triage.js +274 -0
  99. package/lib/vision-writer.js +391 -0
  100. package/package.json +111 -0
  101. package/pipelines/bug-fix.stratum.yaml +230 -0
  102. package/pipelines/build.stratum.yaml +498 -0
  103. package/pipelines/content.stratum.yaml +112 -0
  104. package/pipelines/coverage-sweep.stratum.yaml +52 -0
  105. package/pipelines/refactor.stratum.yaml +169 -0
  106. package/pipelines/research.stratum.yaml +88 -0
  107. package/pipelines/review-fix.stratum.yaml +109 -0
  108. package/presets/team-feature.stratum.yaml +105 -0
  109. package/presets/team-research.stratum.yaml +108 -0
  110. package/presets/team-review.stratum.yaml +106 -0
  111. package/scripts/agent-activity-hook.sh +31 -0
  112. package/scripts/agent-error-hook.sh +28 -0
  113. package/scripts/analyze-orphans.mjs +50 -0
  114. package/scripts/find-orphans.mjs +26 -0
  115. package/scripts/fix-phases.mjs +49 -0
  116. package/scripts/generate-stratum-spec.mjs +137 -0
  117. package/scripts/import-roadmap.mjs +116 -0
  118. package/scripts/phase-audit.mjs +33 -0
  119. package/scripts/run-pipeline.mjs +314 -0
  120. package/scripts/session-end-hook.sh +18 -0
  121. package/scripts/session-start-hook.sh +38 -0
  122. package/scripts/vision-hook.sh +104 -0
  123. package/scripts/vision-track.mjs +554 -0
  124. package/scripts/wire-all-orphans.mjs +108 -0
  125. package/scripts/wire-orphans.mjs +164 -0
  126. package/server/activity-routes.js +123 -0
  127. package/server/agent-health.js +197 -0
  128. package/server/agent-hooks.js +102 -0
  129. package/server/agent-mcp.js +10 -0
  130. package/server/agent-registry.js +95 -0
  131. package/server/agent-server.js +290 -0
  132. package/server/agent-spawn.js +251 -0
  133. package/server/agent-templates.js +77 -0
  134. package/server/artifact-manager.js +247 -0
  135. package/server/artifact-templates/architecture.md +28 -0
  136. package/server/artifact-templates/blueprint.md +21 -0
  137. package/server/artifact-templates/design.md +36 -0
  138. package/server/artifact-templates/plan.md +25 -0
  139. package/server/artifact-templates/prd.md +43 -0
  140. package/server/artifact-templates/report.md +40 -0
  141. package/server/block-tracker.js +90 -0
  142. package/server/build-stream-bridge.js +502 -0
  143. package/server/coalescing-buffer.js +46 -0
  144. package/server/compose-mcp-tools.js +479 -0
  145. package/server/compose-mcp.js +324 -0
  146. package/server/connectors/agent-connector.js +78 -0
  147. package/server/connectors/claude-sdk-connector.js +198 -0
  148. package/server/connectors/codex-connector.js +240 -0
  149. package/server/connectors/connector-discovery.js +18 -0
  150. package/server/connectors/connector-runtime.js +13 -0
  151. package/server/connectors/opencode-connector.js +200 -0
  152. package/server/design-routes.js +540 -0
  153. package/server/design-session.js +161 -0
  154. package/server/feature-scan.js +593 -0
  155. package/server/file-watcher.js +284 -0
  156. package/server/find-root.js +29 -0
  157. package/server/graph-export.js +343 -0
  158. package/server/ideabox-cache.js +77 -0
  159. package/server/ideabox-routes.js +294 -0
  160. package/server/index.js +156 -0
  161. package/server/model-tiers.js +49 -0
  162. package/server/pipeline-routes.js +288 -0
  163. package/server/policy-evaluator.js +36 -0
  164. package/server/project-root.js +122 -0
  165. package/server/security.js +23 -0
  166. package/server/session-manager.js +403 -0
  167. package/server/session-routes.js +190 -0
  168. package/server/session-store.js +107 -0
  169. package/server/settings-routes.js +35 -0
  170. package/server/settings-store.js +234 -0
  171. package/server/stratum-api.js +102 -0
  172. package/server/stratum-client.js +192 -0
  173. package/server/stratum-sync.js +193 -0
  174. package/server/summarizer.js +139 -0
  175. package/server/supervisor.js +196 -0
  176. package/server/vision-routes.js +668 -0
  177. package/server/vision-server.js +393 -0
  178. package/server/vision-store.js +360 -0
  179. package/server/vision-utils.js +179 -0
  180. package/server/worktree-gc.js +137 -0
  181. package/templates/ROADMAP.md +46 -0
package/lib/build.js ADDED
@@ -0,0 +1,2997 @@
1
+ /**
2
+ * build.js — Headless lifecycle runner for `compose build`.
3
+ *
4
+ * Orchestrates feature execution through a Stratum workflow:
5
+ * load spec → stratum_plan → dispatch steps to agents → enforce gates → audit.
6
+ *
7
+ * No server required. Vision state written directly to disk.
8
+ * Gates resolved via CLI readline prompt.
9
+ */
10
+
11
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, renameSync, mkdtempSync, rmSync } from 'node:fs';
12
+ import { join, resolve, dirname } from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+ import { homedir, tmpdir } from 'node:os';
15
+ import { execSync } from 'node:child_process';
16
+ import { createHash } from 'node:crypto';
17
+
18
+ import { StratumMcpClient, StratumError } from './stratum-mcp-client.js';
19
+ import { runAndNormalize, AgentTimeoutError, UserInterruptError } from './result-normalizer.js';
20
+ import { checkCapabilityViolation } from './capability-checker.js';
21
+ import { buildStepPrompt, buildRetryPrompt, buildGateContext, clearAmbientContextCache } from './step-prompt.js';
22
+ import { promptGate } from './gate-prompt.js';
23
+ import { VisionWriter, ServerUnreachableError } from './vision-writer.js';
24
+ import { resolvePort } from './resolve-port.js';
25
+ import { probeServer } from './server-probe.js';
26
+ import { CliProgress } from './cli-progress.js';
27
+ import { BuildStreamWriter } from './build-stream-writer.js';
28
+ import { resolveAgentConfig } from './agent-string.js';
29
+
30
+ import YAML from 'yaml';
31
+ import { ClaudeSDKConnector } from '../server/connectors/claude-sdk-connector.js';
32
+ import { CodexConnector } from '../server/connectors/codex-connector.js';
33
+ import { updateFeature, readFeature, writeFeature } from './feature-json.js';
34
+ import { evaluatePolicy } from '../server/policy-evaluator.js';
35
+ import { runTriage, isTriageStale } from './triage.js';
36
+ import { shouldRunCrossModel, LENS_DEFINITIONS } from './review-lenses.js';
37
+ import { injectCertInstructions } from './cert-inject.js';
38
+ import { detectTestFramework, scaffoldTestFramework } from './test-bootstrap.js';
39
+ import { classifyStepAsTier, evaluateTiers } from './gate-tiers.js';
40
+ import { mapFilesToRoutes, classifyRoutes, isDocsOnlyDiff } from './qa-scoping.js';
41
+ import { computeCompositeScore } from './health-score.js';
42
+ import { recordScore } from './health-history.js';
43
+ import { FixChainDetector, AttemptCounter, DebugLedger, TraceValidator } from './debug-discipline.js';
44
+ import { CrossLayerAudit, loadDebugConfig } from './cross-layer-audit.js';
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // STRAT-IMMUTABLE: pipeline and policy integrity helpers
48
+ // ---------------------------------------------------------------------------
49
+
50
+ /**
51
+ * Compute SHA-256 hex hash of a string.
52
+ */
53
+ function _sha256(content) {
54
+ return createHash('sha256').update(content).digest('hex');
55
+ }
56
+
57
+ /**
58
+ * Verify the pipeline YAML file on disk matches the hash captured at build start.
59
+ * Throws StratumError('PIPELINE_MODIFIED') if the file has changed or cannot be read.
60
+ */
61
+ export function verifyPipelineIntegrity(specPath, expectedHash) {
62
+ let current;
63
+ try {
64
+ current = readFileSync(specPath, 'utf-8');
65
+ } catch (err) {
66
+ throw new StratumError('PIPELINE_MODIFIED',
67
+ `Pipeline spec could not be re-read: ${err.message}`, specPath);
68
+ }
69
+ const actualHash = _sha256(current);
70
+ if (actualHash !== expectedHash) {
71
+ throw new StratumError('PIPELINE_MODIFIED',
72
+ `Pipeline spec was modified during execution. Revert changes and retry.`,
73
+ `expected=${expectedHash} actual=${actualHash}`);
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Verify the gate policy fields in settings.json match the hash captured at build start.
79
+ * Gracefully degrades (no-op) if settings.json is missing — it may not exist in all envs.
80
+ * Throws StratumError('POLICY_MODIFIED') if the file exists and the policies hash differs.
81
+ */
82
+ export function verifyPolicyIntegrity(settingsPath, expectedHash) {
83
+ if (!existsSync(settingsPath)) {
84
+ // Settings file absent — graceful degradation, no verification possible.
85
+ return;
86
+ }
87
+ let policies;
88
+ try {
89
+ const raw = readFileSync(settingsPath, 'utf-8');
90
+ const parsed = JSON.parse(raw);
91
+ policies = parsed.policies ?? {};
92
+ } catch (err) {
93
+ throw new StratumError('POLICY_MODIFIED',
94
+ `settings.json could not be re-read: ${err.message}`, settingsPath);
95
+ }
96
+ const actualHash = _sha256(JSON.stringify(policies));
97
+ if (actualHash !== expectedHash) {
98
+ throw new StratumError('POLICY_MODIFIED',
99
+ `Gate policy was modified during execution. Revert changes and retry.`,
100
+ `expected=${expectedHash} actual=${actualHash}`);
101
+ }
102
+ }
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Spec helpers
106
+ // ---------------------------------------------------------------------------
107
+
108
+ /**
109
+ * Extract the flow name from a parsed Stratum spec.
110
+ * Priority:
111
+ * 1. v0.3 workflow.name (explicit declaration)
112
+ * 2. Flow matching templateName (convention: template "build" → flow "build")
113
+ * 3. First key under flows: (single-flow specs)
114
+ * Falls back to 'build' if parsing fails or no flow is found.
115
+ */
116
+ function extractFlowName(specYaml, templateName = 'build') {
117
+ try {
118
+ const parsed = YAML.parse(specYaml);
119
+ // v0.3 workflow.name — explicit declaration wins
120
+ if (parsed?.workflow?.name) return parsed.workflow.name;
121
+ // flows-based specs
122
+ if (parsed?.flows) {
123
+ const keys = Object.keys(parsed.flows);
124
+ // Prefer flow matching the template name
125
+ if (keys.includes(templateName)) return templateName;
126
+ // Single-flow or non-default template: use first key
127
+ if (keys.length > 0) return keys[0];
128
+ }
129
+ } catch { /* fall through */ }
130
+ return 'build';
131
+ }
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // Debug discipline helpers (COMP-DEBUG-1)
135
+ // ---------------------------------------------------------------------------
136
+
137
+ /**
138
+ * Extract a list of changed files from a step result/response object.
139
+ * Handles multiple result shapes agents may return.
140
+ */
141
+ function extractFilesChanged(response) {
142
+ const result = response.result ?? {};
143
+ if (Array.isArray(result.files_changed)) return result.files_changed;
144
+ if (typeof result.files_changed === 'string') return result.files_changed.split(',').map(f => f.trim()).filter(Boolean);
145
+ return [];
146
+ }
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // Agent registry
150
+ // ---------------------------------------------------------------------------
151
+
152
+ const DEFAULT_AGENTS = new Map([
153
+ ['claude', (opts) => new ClaudeSDKConnector(opts)],
154
+ ['codex', (opts) => new CodexConnector(opts)],
155
+ ]);
156
+
157
+ // Per-step timeout in ms. Steps not listed get the default.
158
+ // These are circuit breakers — generous enough for real work, tight enough to stop spiraling.
159
+ const STEP_TIMEOUT_MS = {
160
+ explore_design: 20 * 60_000, // 20 min
161
+ scope: 5 * 60_000, // 5 min
162
+ prd: 15 * 60_000, // 15 min
163
+ architecture: 15 * 60_000, // 15 min
164
+ blueprint: 20 * 60_000, // 20 min
165
+ verification: 10 * 60_000, // 10 min
166
+ plan: 15 * 60_000, // 15 min
167
+ execute: 45 * 60_000, // 45 min
168
+ review: 15 * 60_000, // 15 min (multi-lens parallel review)
169
+ triage: 2 * 60_000, // 2 min (parallel_review triage step)
170
+ merge: 3 * 60_000, // 3 min (parallel_review merge step)
171
+ codex_review: 10 * 60_000, // 10 min (codex cross-model review)
172
+ run_tests: 10 * 60_000, // 10 min (coverage sub-flow step)
173
+ report: 10 * 60_000, // 10 min
174
+ docs: 10 * 60_000, // 10 min
175
+ ship: 5 * 60_000, // 5 min (should be fast — just git ops)
176
+ };
177
+ const DEFAULT_TIMEOUT_MS = 30 * 60_000; // 30 min fallback
178
+
179
+ /**
180
+ * Default connector factory.
181
+ * Accepts either a bare provider name ("claude") or a full agent string
182
+ * ("claude:read-only-reviewer"). Resolves capability restrictions from the
183
+ * template and passes them to the connector constructor.
184
+ *
185
+ * @param {string} agentString Full agent string, e.g. "claude:read-only-reviewer" or "claude"
186
+ * @param {object} opts Additional connector options (cwd, model, etc.)
187
+ */
188
+ function defaultConnectorFactory(agentString, opts) {
189
+ const { provider, allowedTools, disallowedTools, modelID, thinking, effort } = resolveAgentConfig(agentString);
190
+ const factory = DEFAULT_AGENTS.get(provider);
191
+ if (!factory) {
192
+ throw new Error(
193
+ `compose build: step requires agent "${provider}" but no connector is registered.\n` +
194
+ `Known agents: ${[...DEFAULT_AGENTS.keys()].join(', ')}\n` +
195
+ `Check your .stratum.yaml spec or install the agent.`
196
+ );
197
+ }
198
+ // Pass tool restrictions only when they are defined (avoids overriding connector defaults)
199
+ const connectorOpts = { ...opts };
200
+ if (allowedTools !== null) connectorOpts.allowedTools = allowedTools;
201
+ if (disallowedTools !== null) connectorOpts.disallowedTools = disallowedTools;
202
+ // Pass resolved model ID when a tier was specified — connector uses its own default otherwise
203
+ // Both keys for cross-connector compatibility: ClaudeSDKConnector uses `model`,
204
+ // CodexConnector/AgentConnector base class uses `modelID`
205
+ if (modelID !== null) {
206
+ connectorOpts.model = modelID;
207
+ connectorOpts.modelID = modelID;
208
+ }
209
+ // Tier-default thinking/effort; caller (opts) can override.
210
+ if (thinking !== null && connectorOpts.thinking === undefined) connectorOpts.thinking = thinking;
211
+ if (effort !== null && connectorOpts.effort === undefined) connectorOpts.effort = effort;
212
+ return factory(connectorOpts);
213
+ }
214
+
215
+ // ---------------------------------------------------------------------------
216
+ // Active build state (resume/abort)
217
+ // ---------------------------------------------------------------------------
218
+
219
+ function activeBuildPath(dataDir) {
220
+ return join(dataDir, 'active-build.json');
221
+ }
222
+
223
+ function readActiveBuild(dataDir) {
224
+ const p = activeBuildPath(dataDir);
225
+ if (!existsSync(p)) return null;
226
+ try {
227
+ return JSON.parse(readFileSync(p, 'utf-8'));
228
+ } catch {
229
+ return null;
230
+ }
231
+ }
232
+
233
+ function writeActiveBuild(dataDir, state) {
234
+ mkdirSync(dataDir, { recursive: true });
235
+ // Always stamp PID so concurrent processes can detect each other
236
+ state.pid = process.pid;
237
+ const target = activeBuildPath(dataDir);
238
+ const tmp = target + '.tmp';
239
+ writeFileSync(tmp, JSON.stringify(state, null, 2));
240
+ renameSync(tmp, target);
241
+ }
242
+
243
+ // ---------------------------------------------------------------------------
244
+ // Prior dirty lenses sidecar (STRAT-REV-5: selective re-review)
245
+ // ---------------------------------------------------------------------------
246
+
247
+ function priorDirtyLensesPath(composeDir) {
248
+ return join(composeDir, 'prior_dirty_lenses.json');
249
+ }
250
+
251
+ function persistPriorDirtyLenses(composeDir, lensesRun) {
252
+ mkdirSync(composeDir, { recursive: true });
253
+ writeFileSync(
254
+ priorDirtyLensesPath(composeDir),
255
+ JSON.stringify(lensesRun ?? [], null, 2)
256
+ );
257
+ }
258
+
259
+ function clearPriorDirtyLenses(composeDir) {
260
+ const p = priorDirtyLensesPath(composeDir);
261
+ if (existsSync(p)) unlinkSync(p);
262
+ }
263
+
264
+ /**
265
+ * Check whether a process with the given PID is still alive.
266
+ */
267
+ function isProcessAlive(pid) {
268
+ if (!pid || typeof pid !== 'number') return false;
269
+ try {
270
+ process.kill(pid, 0); // signal 0 = existence check, no actual signal
271
+ return true;
272
+ } catch {
273
+ return false;
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Build an askAgent helper that answers a single question using the claude connector.
279
+ * Shared by the await_gate handlers in runBuild and executeChildFlow.
280
+ */
281
+ /**
282
+ * Build an askAgent helper that answers gate questions with full workflow context.
283
+ *
284
+ * @param {Function} getConnector - Connector factory
285
+ * @param {object} context - Execution context (cwd, featureCode, featureDir, stepHistory, filesChanged)
286
+ * @param {object} gateDispatch - Stratum gate dispatch (step_id, on_approve, on_revise, on_kill)
287
+ * @param {object} [gateExtras] - Optional enrichment (fromPhase, toPhase, summary)
288
+ */
289
+ function makeAskAgent(getConnector, context, gateDispatch, gateExtras) {
290
+ const preamble = buildGateContext(gateDispatch, context, gateExtras);
291
+
292
+ return async function askAgent(question, artifactPath) {
293
+ const connector = getConnector('claude', { cwd: context.cwd });
294
+ const fileRef = artifactPath && !artifactPath.endsWith('/')
295
+ ? `Read the file "${artifactPath}" and answer`
296
+ : `Look at the project files in the working directory and answer`;
297
+ const qaPrompt =
298
+ `${preamble}\n\n---\n\n` +
299
+ `${fileRef} this question concisely:\n\n` +
300
+ `${question}\n\n` +
301
+ `Keep your answer brief — 2-3 sentences max.`;
302
+ const parts = [];
303
+ for await (const event of connector.run(qaPrompt, {})) {
304
+ if (event.type === 'assistant' && event.content) parts.push(event.content);
305
+ if (event.type === 'result' && event.content && parts.length === 0) parts.push(event.content);
306
+ }
307
+ return parts.join('') || '(no answer)';
308
+ };
309
+ }
310
+
311
+ export function deleteActiveBuild(dataDir) {
312
+ const p = activeBuildPath(dataDir);
313
+ if (existsSync(p)) unlinkSync(p);
314
+ }
315
+
316
+ // ---------------------------------------------------------------------------
317
+ // Flow-status helpers
318
+ // ---------------------------------------------------------------------------
319
+
320
+ /**
321
+ * Returns true when a Stratum flow has reached a terminal state and will
322
+ * never produce more steps. Used to detect stale lock files and decide
323
+ * whether a resumed flow needs a fresh start.
324
+ */
325
+ function isTerminalFlow(status) {
326
+ return status === 'complete' || status === 'killed';
327
+ }
328
+
329
+ // ---------------------------------------------------------------------------
330
+ // Template resolution
331
+ // ---------------------------------------------------------------------------
332
+
333
+ /**
334
+ * Resolve a template name to a file path. Checks two locations:
335
+ * 1. Project-local: <cwd>/pipelines/<name>.stratum.yaml
336
+ * 2. Bundled presets: <compose-package>/presets/<name>.stratum.yaml
337
+ *
338
+ * @param {string} [name='build'] - Template name
339
+ * @param {string} cwd - Project root directory
340
+ * @returns {string} Resolved file path
341
+ */
342
+ export function resolveTemplatePath(name, cwd) {
343
+ const templateName = name ?? 'build';
344
+ const projectPath = join(cwd, 'pipelines', `${templateName}.stratum.yaml`);
345
+ if (existsSync(projectPath)) return projectPath;
346
+
347
+ const packageDir = dirname(fileURLToPath(import.meta.url));
348
+ const presetsPath = join(packageDir, '..', 'presets', `${templateName}.stratum.yaml`);
349
+ if (existsSync(presetsPath)) return presetsPath;
350
+
351
+ return projectPath;
352
+ }
353
+
354
+ // ---------------------------------------------------------------------------
355
+ // Main entry point
356
+ // ---------------------------------------------------------------------------
357
+
358
+ /**
359
+ * Run a feature through the Stratum lifecycle.
360
+ *
361
+ * @param {string} featureCode - Feature code (e.g. 'FEAT-1')
362
+ * @param {object} opts
363
+ * @param {string} [opts.cwd] - Project root with .compose/ (default: process.cwd())
364
+ * @param {string} [opts.workingDirectory] - Agent working directory (default: opts.cwd). Use when
365
+ * agents need to operate in a different directory than
366
+ * the project root (e.g. parent dir for cross-repo features).
367
+ * @param {boolean} [opts.abort] - Abort active build instead of running
368
+ * @param {string} [opts.description] - Feature description override
369
+ * @param {Function} [opts.connectorFactory] - Override agent connector creation (for testing)
370
+ * @param {object} [opts.gateOpts] - Options for gate prompt (input/output streams)
371
+ * @param {string} [opts.template] - Pipeline template name (default: 'build').
372
+ * Resolves to pipelines/${template}.stratum.yaml.
373
+ * When provided, skips triage entirely.
374
+ * @param {boolean} [opts.skipTriage] - Skip pre-build triage (use spec as-is).
375
+ */
376
+ export async function runBuild(featureCode, opts = {}) {
377
+ const cwd = opts.cwd ?? process.cwd();
378
+ const agentCwd = opts.workingDirectory ?? cwd;
379
+ const getConnector = opts.connectorFactory ?? defaultConnectorFactory;
380
+
381
+ // Resolve project paths
382
+ const composeDir = join(cwd, '.compose');
383
+ const dataDir = join(composeDir, 'data');
384
+
385
+ // Handle --abort early (featureCode may be null)
386
+ if (opts.abort) {
387
+ await abortBuild(dataDir, featureCode);
388
+ return;
389
+ }
390
+
391
+ const featureDir = join(cwd, 'docs', 'features', featureCode);
392
+
393
+ // Debug discipline (COMP-DEBUG-1)
394
+ const debugStatePath = join(composeDir, 'debug-state.json');
395
+ let fixChainDetector, attemptCounter, debugLedger, crossLayerAudit;
396
+ try {
397
+ if (existsSync(debugStatePath)) {
398
+ const saved = JSON.parse(readFileSync(debugStatePath, 'utf-8'));
399
+ fixChainDetector = FixChainDetector.fromJSON(saved.fixChain ?? {});
400
+ attemptCounter = AttemptCounter.fromJSON(saved.attempt ?? {});
401
+ } else {
402
+ fixChainDetector = new FixChainDetector();
403
+ attemptCounter = new AttemptCounter();
404
+ }
405
+ debugLedger = new DebugLedger(composeDir);
406
+ crossLayerAudit = new CrossLayerAudit(loadDebugConfig(cwd));
407
+ } catch {
408
+ fixChainDetector = new FixChainDetector();
409
+ attemptCounter = new AttemptCounter();
410
+ debugLedger = new DebugLedger(composeDir);
411
+ crossLayerAudit = new CrossLayerAudit({ cross_layer_repos: [], cross_layer_extensions: [] });
412
+ }
413
+
414
+ // Read compose.json
415
+ const configPath = join(composeDir, 'compose.json');
416
+ if (!existsSync(configPath)) {
417
+ throw new Error(`No .compose/compose.json found at ${cwd}. Run 'compose init' first.`);
418
+ }
419
+ let composeConfig = {};
420
+ try { composeConfig = JSON.parse(readFileSync(configPath, 'utf-8')); } catch { /* use defaults */ }
421
+ const contextDirPath = join(cwd, composeConfig.paths?.context ?? 'docs/context');
422
+
423
+ // ---------------------------------------------------------------------------
424
+ // Pre-build triage — runs before spec loading so profile can toggle skip_if.
425
+ // Skipped when:
426
+ // - opts.skipTriage is true (user flag --skip-triage)
427
+ // - opts.template is explicitly set (user chose a specific template)
428
+ // ---------------------------------------------------------------------------
429
+ let buildProfile = null;
430
+ if (!opts.skipTriage && !opts.template) {
431
+ let cachedFeature = readFeature(cwd, featureCode);
432
+ if (cachedFeature?.profile && !isTriageStale(cwd, featureCode)) {
433
+ // Reuse cached profile
434
+ buildProfile = cachedFeature.profile;
435
+ console.log(`[triage] Using cached profile (tier ${cachedFeature.complexity ?? '?'}): ${JSON.stringify(buildProfile)}`);
436
+ } else {
437
+ // Run fresh triage
438
+ const triageResult = await runTriage(featureCode, { cwd });
439
+ buildProfile = triageResult.profile;
440
+ console.log(`[triage] Tier ${triageResult.tier}: ${triageResult.rationale}`);
441
+ console.log(`[triage] Profile: ${JSON.stringify(buildProfile)}`);
442
+
443
+ const triageTimestamp = new Date().toISOString();
444
+ if (!cachedFeature) {
445
+ // Create feature.json — feature folder exists but json was missing
446
+ const featureDesc = opts.description ?? featureCode;
447
+ writeFeature(cwd, {
448
+ code: featureCode,
449
+ description: featureDesc,
450
+ status: 'PLANNED',
451
+ complexity: String(triageResult.tier),
452
+ profile: buildProfile,
453
+ triageTimestamp,
454
+ });
455
+ } else {
456
+ updateFeature(cwd, featureCode, {
457
+ complexity: String(triageResult.tier),
458
+ profile: buildProfile,
459
+ triageTimestamp,
460
+ });
461
+ }
462
+ }
463
+ }
464
+
465
+ // Load lifecycle spec (template selection)
466
+ const templateName = opts.template ?? 'build';
467
+ const specPath = resolveTemplatePath(opts.template, cwd);
468
+ if (!existsSync(specPath)) {
469
+ throw new Error(`Lifecycle spec not found: ${specPath}`);
470
+ }
471
+ let specYaml = readFileSync(specPath, 'utf-8');
472
+
473
+ // STRAT-IMMUTABLE: hash the on-disk spec BEFORE triage mutation for tamper detection.
474
+ // verifyPipelineIntegrity() re-reads from disk, so we must compare against the original file content.
475
+ const specFileHash = _sha256(specYaml);
476
+
477
+ // Apply triage profile to spec — toggle skip_if on skippable steps
478
+ if (buildProfile) {
479
+ try {
480
+ const specObj = YAML.parse(specYaml);
481
+ const flows = specObj?.flows ?? {};
482
+ // Find the build flow (or first flow)
483
+ const flowKey = Object.keys(flows).includes('build') ? 'build' : Object.keys(flows)[0];
484
+ const steps = flows[flowKey]?.steps ?? [];
485
+ const skippableSteps = ['prd', 'architecture', 'verification', 'report'];
486
+ for (const step of steps) {
487
+ if (!skippableSteps.includes(step.id)) continue;
488
+ const needsKey = `needs_${step.id}`;
489
+ if (buildProfile[needsKey] === true) {
490
+ // Enable step — remove skip_if/skip_reason
491
+ delete step.skip_if;
492
+ delete step.skip_reason;
493
+ } else if (buildProfile[needsKey] === false) {
494
+ // Disable step — mark as unconditionally skipped
495
+ const tier = readFeature(cwd, featureCode)?.complexity ?? '?';
496
+ step.skip_if = 'true';
497
+ step.skip_reason = `Skipped by triage (tier ${tier})`;
498
+ }
499
+ }
500
+ specYaml = YAML.stringify(specObj);
501
+ } catch (err) {
502
+ // Non-fatal — fall back to unmodified spec
503
+ console.warn(`[triage] Failed to apply profile to spec: ${err.message} — using spec as-is`);
504
+ }
505
+ }
506
+
507
+ // Build description from feature folder
508
+ const description = opts.description ?? loadFeatureDescription(featureDir, featureCode);
509
+
510
+ // Vision writer
511
+ const visionWriter = new VisionWriter(dataDir);
512
+ const itemId = await visionWriter.ensureFeatureItem(featureCode, featureCode);
513
+
514
+ // Load policy settings (lazy from disk — works for all callers)
515
+ const settingsPath = join(dataDir, 'settings.json');
516
+ let policySettings = { policies: {} };
517
+ try {
518
+ if (existsSync(settingsPath)) {
519
+ policySettings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
520
+ }
521
+ } catch (err) {
522
+ if (err.code !== 'ENOENT') {
523
+ console.warn(`[build] Failed to load settings: ${err.message} — defaulting all gates to 'gate' mode`);
524
+ }
525
+ }
526
+
527
+ // STRAT-IMMUTABLE: hash policy fields for tamper detection.
528
+ const policyHash = _sha256(JSON.stringify(policySettings.policies ?? {}));
529
+
530
+ if (agentCwd !== cwd) {
531
+ console.log(`Agent working directory: ${agentCwd}`);
532
+ }
533
+
534
+ // CLI progress renderer
535
+ const progress = new CliProgress();
536
+
537
+ // Stratum MCP client
538
+ const stratum = new StratumMcpClient();
539
+ await stratum.connect({ cwd });
540
+
541
+ // Update feature.json status to IN_PROGRESS
542
+ updateFeature(cwd, featureCode, { status: 'IN_PROGRESS' });
543
+
544
+ // Hoisted for finally-block visibility
545
+ let streamWriter = null;
546
+ let buildStatus = 'complete';
547
+ let signalHandler = null;
548
+ // COMP-OBS-COST: Accumulate token/cost totals across all steps (hoisted for finally-block)
549
+ // On resume, seed from active-build.json to preserve pre-resume cost totals
550
+ const buildCostTotals = { input_tokens: 0, output_tokens: 0, cost_usd: 0 };
551
+
552
+ // COMP-OBS-GATES: accumulate tier pass/fail results for this build.
553
+ // Keys are tier IDs (T0–T4), values are true (passed), false (failed), or null (not yet run).
554
+ const tierResults = {};
555
+
556
+ // COMP-HEALTH: accumulate build signals for composite health scoring.
557
+ // Each key corresponds to a scoring dimension in lib/health-score.js.
558
+ // Signals are populated as child flows and steps complete.
559
+ const buildSignals = {};
560
+ // Accumulate runtime violations across all steps (runtime_errors dimension)
561
+ const allViolations = [];
562
+ // Accumulate contract compliance signal: array of { passed: bool } per ensure check
563
+ const contractCompliance = [];
564
+
565
+ const priorActive = readActiveBuild(dataDir);
566
+ if (priorActive && priorActive.featureCode === featureCode && priorActive.status === 'running') {
567
+ if (typeof priorActive.total_input_tokens === 'number') buildCostTotals.input_tokens = priorActive.total_input_tokens;
568
+ if (typeof priorActive.total_output_tokens === 'number') buildCostTotals.output_tokens = priorActive.total_output_tokens;
569
+ if (typeof priorActive.cumulative_cost_usd === 'number') buildCostTotals.cost_usd = priorActive.cumulative_cost_usd;
570
+ }
571
+
572
+ try {
573
+ // Check for active build (resume)
574
+ const active = readActiveBuild(dataDir);
575
+ let response;
576
+ let isFreshStart = true;
577
+
578
+ if (active && active.featureCode === featureCode && active.flowId) {
579
+ // Same feature — try to resume or start fresh
580
+ if (active.status && active.status !== 'running') {
581
+ console.log(`Previous build ${active.status}. Starting fresh.`);
582
+ response = await startFresh(stratum, specYaml, featureCode, description, dataDir, templateName);
583
+ } else if (active.pid && active.pid !== process.pid && isProcessAlive(active.pid)) {
584
+ // Same feature, different live process — block
585
+ throw new Error(
586
+ `Build already running for ${featureCode} (pid ${active.pid}). ` +
587
+ `Use 'compose build --abort' to cancel it.`
588
+ );
589
+ } else {
590
+ console.log(`Found previous build for ${featureCode} (flow: ${active.flowId})`);
591
+ try {
592
+ response = await stratum.resume(active.flowId);
593
+ if (isTerminalFlow(response.status)) {
594
+ console.log(`Previous build already ${response.status}. Starting fresh.`);
595
+ response = await startFresh(stratum, specYaml, featureCode, description, dataDir, templateName);
596
+ } else {
597
+ console.log(`Resuming from step: ${response.step_id}`);
598
+ isFreshStart = false;
599
+ }
600
+ } catch (err) {
601
+ const recoverable = err?.code === 'flow_not_found'
602
+ || err?.code === 'STRATUM_ERROR'
603
+ || err?.message?.includes('No active flow');
604
+ if (recoverable) {
605
+ console.log('Previous flow not found. Starting fresh.');
606
+ response = await startFresh(stratum, specYaml, featureCode, description, dataDir, templateName);
607
+ } else {
608
+ throw err;
609
+ }
610
+ }
611
+ }
612
+ } else {
613
+ // Different feature or no active build — start fresh.
614
+ // active-build.json is last-writer-wins: concurrent builds for
615
+ // different features are allowed; the UI shows the most recent.
616
+ response = await startFresh(stratum, specYaml, featureCode, description, dataDir, templateName);
617
+ }
618
+
619
+ // Update vision state
620
+ await visionWriter.updateItemStatus(itemId, 'in_progress');
621
+
622
+ // Stream writer — instantiated after plan/resume succeeds to prevent
623
+ // a rejected/duplicate invocation from truncating an active build's stream.
624
+ // Only truncate on fresh starts; resumed builds append to existing stream.
625
+ streamWriter = new BuildStreamWriter(composeDir, featureCode, { truncate: isFreshStart });
626
+ streamWriter.write({
627
+ type: isFreshStart ? 'build_start' : 'build_resume',
628
+ featureCode,
629
+ flowId: response.flow_id,
630
+ specPath: `pipelines/${templateName}.stratum.yaml`,
631
+ });
632
+
633
+ // SIGINT/SIGTERM: mark build as killed
634
+ signalHandler = () => {
635
+ buildStatus = 'killed';
636
+ streamWriter.close('killed');
637
+ };
638
+ process.on('SIGINT', signalHandler);
639
+ process.on('SIGTERM', signalHandler);
640
+
641
+ // Dispatch loop — agents operate in agentCwd (which may differ from cwd for cross-repo builds)
642
+ // stepHistory accumulates context across steps so downstream steps don't re-explore
643
+ const stepHistory = [];
644
+ const context = {
645
+ cwd: agentCwd,
646
+ featureCode,
647
+ featureDir: join(cwd, 'docs', 'features', featureCode),
648
+ contextDir: contextDirPath,
649
+ stepHistory,
650
+ };
651
+
652
+
653
+ while (response.status !== 'complete' && response.status !== 'killed') {
654
+ const stepId = response.step_id;
655
+ const flowId = response.flow_id;
656
+ const stepNum = response.step_number ?? '?';
657
+ const totalSteps = response.total_steps ?? '?';
658
+
659
+ if (response.status === 'execute_step') {
660
+ progress.stepStart(stepNum, totalSteps, stepId);
661
+
662
+ // Stream: step start
663
+ streamWriter.write({
664
+ type: 'build_step_start',
665
+ stepId, stepNum, totalSteps,
666
+ agent: response.agent ?? 'claude',
667
+ intent: response.intent ?? null,
668
+ flowId,
669
+ });
670
+
671
+ // Update tracking
672
+ await visionWriter.updateItemPhase(itemId, stepId);
673
+ updateActiveBuildStep(dataDir, stepId, { stepNum: response.step_number, totalSteps: response.total_steps });
674
+
675
+ // Ship step: run git commit in-process instead of delegating to a sandboxed agent.
676
+ // The agent can't git commit (sandbox blocks it), so we do it here where we have
677
+ // full shell access. This turns a 10+ minute spiral into a <5 second operation.
678
+ if (stepId === 'ship') {
679
+ const shipResult = await executeShipStep(featureCode, agentCwd, cwd, context, description, progress);
680
+ stepHistory.push({
681
+ stepId: 'ship',
682
+ artifact: shipResult.artifact,
683
+ summary: shipResult.summary,
684
+ outcome: shipResult.outcome,
685
+ });
686
+ if (shipResult.outcome === 'failed') {
687
+ console.error(`\nShip failed: ${shipResult.summary}`);
688
+ buildStatus = 'failed';
689
+ streamWriter.write({
690
+ type: 'build_step_done',
691
+ stepId: 'ship', summary: shipResult.summary, retries: 0,
692
+ violations: [shipResult.summary], flowId,
693
+ });
694
+ break;
695
+ }
696
+ progress.stepDone(stepId);
697
+ // COMP-HEALTH: collect plan_completion signal from ship result (if present)
698
+ if (shipResult.planCompletionPct != null || shipResult.plan_completion_pct != null) {
699
+ buildSignals.plan_completion = {
700
+ planCompletionPct: shipResult.planCompletionPct ?? shipResult.plan_completion_pct,
701
+ };
702
+ }
703
+ verifyPipelineIntegrity(specPath, specFileHash);
704
+ response = await stratum.stepDone(flowId, stepId, shipResult);
705
+ streamWriter.write({
706
+ type: 'build_step_done',
707
+ stepId, summary: shipResult.summary, retries: 0, violations: [], flowId,
708
+ });
709
+ continue;
710
+ }
711
+
712
+ // Build prompt and dispatch to agent
713
+ const stepStartMs = Date.now();
714
+ const agentType = response.agent ?? 'claude';
715
+ const prompt = buildStepPrompt(response, context);
716
+ const connector = getConnector(agentType, { cwd: agentCwd });
717
+ const maxDurationMs = STEP_TIMEOUT_MS[stepId] ?? DEFAULT_TIMEOUT_MS;
718
+
719
+ // Collect tool_use events for post-step capability audit (Item 193/195)
720
+ const observedTools = [];
721
+ const onToolUse = ({ tool, input, timestamp }) => {
722
+ observedTools.push({ tool, input, timestamp });
723
+ };
724
+
725
+ let mainResult;
726
+ try {
727
+ mainResult = await runAndNormalize(connector, prompt, response, { progress, streamWriter, maxDurationMs, onToolUse });
728
+ } catch (err) {
729
+ if (err instanceof UserInterruptError) {
730
+ if (err.action === 'skip') {
731
+ if (progress) progress.info(` ⏭ Skipped step "${stepId}"`);
732
+ mainResult = { text: '', result: { outcome: 'skipped', summary: `Skipped by user` } };
733
+ } else {
734
+ if (progress) progress.info(` ↻ Retrying step "${stepId}"`);
735
+ mainResult = { text: '', result: { outcome: 'failed', summary: `Retry requested by user` } };
736
+ }
737
+ } else if (err instanceof AgentTimeoutError) {
738
+ console.warn(`\n⚠ Agent timed out on step "${stepId}" after ${Math.round(err.durationMs / 1000)}s`);
739
+ streamWriter.write({ type: 'build_error', message: err.message, stepId });
740
+ mainResult = { text: '', result: { outcome: 'failed', summary: `Timed out after ${Math.round(err.durationMs / 1000)}s` } };
741
+ } else {
742
+ streamWriter.write({ type: 'build_error', message: err.message, stepId });
743
+ throw err;
744
+ }
745
+ }
746
+ const { result, text: stepText, usage: stepUsage } = mainResult;
747
+
748
+ // Scan agent output for "we should X" / "we could X" patterns that don't map
749
+ // to existing roadmap features — emit idea_suggestion hint events (Item 184).
750
+ // This is a passive hint; nothing is auto-filed.
751
+ if (stepText) {
752
+ const ideaSuggestionRe = /\b(?:we should|we could|we might want to|consider adding|it would be worth)\s+([^.!?\n]{10,120})/gi;
753
+ let m;
754
+ while ((m = ideaSuggestionRe.exec(stepText)) !== null) {
755
+ const suggestion = m[1].trim();
756
+ streamWriter.write({ type: 'idea_suggestion', stepId, text: suggestion });
757
+ }
758
+ }
759
+
760
+ // Emit capability_profile event for audit (informational, never blocking)
761
+ {
762
+ const { template: stepTemplate, allowedTools: stepAllowed, disallowedTools: stepDisallowed, tier: stepTier, modelID: stepModelID } = resolveAgentConfig(agentType);
763
+ if (stepTemplate) {
764
+ streamWriter.writeCapabilityProfile(stepId, agentType, stepTemplate, stepAllowed, stepDisallowed);
765
+ }
766
+ // Emit step_model event so the audit trail records which model actually ran each step
767
+ streamWriter.write({ type: 'step_model', stepId, agent: agentType, modelID: stepModelID, tier: stepTier });
768
+ }
769
+
770
+ // Post-step capability violation audit (Items 195/196)
771
+ // Read enforcement mode from settings.json (capabilities.enforcement: 'log'|'block')
772
+ {
773
+ const enforcement = (() => {
774
+ try {
775
+ if (existsSync(settingsPath)) {
776
+ const s = JSON.parse(readFileSync(settingsPath, 'utf-8'));
777
+ return s?.capabilities?.enforcement ?? 'log';
778
+ }
779
+ } catch { /* degraded — default to log */ }
780
+ return 'log';
781
+ })();
782
+
783
+ const capViolations = [];
784
+ for (const { tool } of observedTools) {
785
+ const check = checkCapabilityViolation(tool, agentType);
786
+ if (check.violation) {
787
+ capViolations.push({ tool, severity: check.severity, reason: check.reason });
788
+ // Emit capability_violation event to build stream
789
+ const { template: tpl } = resolveAgentConfig(agentType);
790
+ streamWriter.writeViolation(stepId, agentType, tpl ?? 'unknown', check.reason);
791
+ // Console log (always, even in block mode — for visibility)
792
+ console.log(` [caps] ${tool} used by ${agentType} — violates ${tpl ?? 'unknown'} profile`);
793
+ }
794
+ }
795
+
796
+ if (enforcement === 'block' && capViolations.length > 0) {
797
+ const tools = capViolations.map(v => v.tool).join(', ');
798
+ throw new StratumError('CAPABILITY_VIOLATION',
799
+ `Step "${stepId}" used disallowed tools: ${tools}`, stepId);
800
+ }
801
+ }
802
+
803
+ // Accumulate step context for downstream steps
804
+ const entry = {
805
+ stepId,
806
+ artifact: result?.artifact ?? null,
807
+ summary: result?.summary ?? 'Step complete',
808
+ outcome: result?.outcome ?? 'complete',
809
+ agent: response.agent ?? 'claude',
810
+ durationMs: Date.now() - stepStartMs,
811
+ // COMP-OBS-COST: per-step token/cost data
812
+ input_tokens: stepUsage?.input_tokens ?? 0,
813
+ output_tokens: stepUsage?.output_tokens ?? 0,
814
+ cost_usd: stepUsage?.cost_usd ?? 0,
815
+ };
816
+
817
+ // COMP-HEALTH: record contract compliance — ensure passed on first try
818
+ contractCompliance.push({ passed: true, stepId });
819
+ buildSignals.contract_compliance = contractCompliance;
820
+
821
+ // After code-producing steps, snapshot changed files so downstream
822
+ // steps (review, coverage, docs, ship) know exactly what was touched.
823
+ // Maintained as context.filesChanged (pre-deduplicated) for step-prompt.js.
824
+ if (stepId === 'execute' || stepId === 'docs') {
825
+ try {
826
+ const diff = execSync('git diff --name-only HEAD 2>/dev/null; git ls-files --others --exclude-standard 2>/dev/null', {
827
+ cwd: agentCwd, encoding: 'utf-8', timeout: 5000,
828
+ }).trim();
829
+ if (diff) {
830
+ const files = diff.split('\n').filter(Boolean);
831
+ entry.filesChanged = files;
832
+ // Merge into context-level deduplicated list
833
+ const existing = new Set(context.filesChanged ?? []);
834
+ for (const f of files) existing.add(f);
835
+ context.filesChanged = [...existing];
836
+ }
837
+ } catch { /* git not available or no repo — skip */ }
838
+ }
839
+
840
+ stepHistory.push(entry);
841
+ progress.stepDone(stepId);
842
+
843
+ // Note: scope-step BuildProfile persistence has been replaced by pre-build triage.
844
+ // runTriage() runs before stratum_plan() and populates feature.json directly.
845
+
846
+ // Keep a flat deduplicated file manifest on context so buildStepPrompt
847
+ // doesn't need to recompute it from history on every prompt build.
848
+ if (entry.filesChanged?.length > 0) {
849
+ const set = new Set(context.filesChanged ?? []);
850
+ for (const f of entry.filesChanged) set.add(f);
851
+ context.filesChanged = [...set];
852
+ }
853
+
854
+ verifyPipelineIntegrity(specPath, specFileHash);
855
+ response = await stratum.stepDone(flowId, stepId, result ?? { summary: 'Step complete' });
856
+ syncStepHistory(dataDir, stepHistory);
857
+
858
+ // Debug discipline enforcement (COMP-DEBUG-1)
859
+ if (stepId === 'fix' || stepId === 'diagnose') {
860
+ const filesChanged = extractFilesChanged({ result });
861
+ fixChainDetector.recordIteration(filesChanged);
862
+ attemptCounter.record({ filesChanged });
863
+
864
+ // Validate trace evidence on diagnose results
865
+ if (stepId === 'diagnose' && result) {
866
+ const traceResult = TraceValidator.validate(result);
867
+ if (!traceResult.valid) {
868
+ debugLedger.record({ type: 'trace_validation_failed', reason: traceResult.reason });
869
+ if (progress) progress.warn(`Debug discipline: trace evidence insufficient — ${traceResult.reason}`);
870
+ }
871
+
872
+ // Cross-layer scope detection after diagnose
873
+ const scopeCheck = crossLayerAudit.shouldExpand(result);
874
+ if (scopeCheck.expand) {
875
+ debugLedger.record({ type: 'scope_expansion_triggered', trigger: scopeCheck.trigger });
876
+ if (progress) progress.warn(`Debug discipline: cross-layer change detected (${scopeCheck.trigger}) — scope_check step should audit all configured repos`);
877
+ }
878
+ }
879
+
880
+ const chains = fixChainDetector.detect();
881
+ const intervention = attemptCounter.getIntervention();
882
+
883
+ if (chains.length > 0) {
884
+ debugLedger.record({ type: 'fix_chain_detected', chains });
885
+ }
886
+
887
+ if (intervention === 'escalate') {
888
+ debugLedger.record({ type: 'escalation', attempt: attemptCounter.count, isVisual: attemptCounter.isVisual });
889
+ if (streamWriter) streamWriter.write({ type: 'build_error', message: `Debug discipline: escalating after ${attemptCounter.count} attempts. Dispatching to cross-agent review.` });
890
+ } else if (intervention === 'trace_refresh') {
891
+ debugLedger.record({ type: 'trace_refresh_required', attempt: attemptCounter.count });
892
+ if (progress) progress.warn(`Debug discipline: ${attemptCounter.count} attempts — fresh trace evidence required before next fix`);
893
+ } else if (intervention === 'trace_reminder') {
894
+ if (progress) progress.warn(`Debug discipline: ${attemptCounter.count} attempts on same target — verify trace evidence is current`);
895
+ }
896
+
897
+ // Persist debug state
898
+ try {
899
+ writeFileSync(debugStatePath, JSON.stringify({
900
+ fixChain: fixChainDetector.toJSON(),
901
+ attempt: attemptCounter.toJSON(),
902
+ }), 'utf-8');
903
+ } catch { /* best-effort */ }
904
+ }
905
+
906
+ // Stream: step done — read retries/violations from active-build state
907
+ // (syncStepHistory has already written them above)
908
+ {
909
+ const buildState = readActiveBuild(dataDir);
910
+ const stepState = buildState?.steps?.find(s => s.id === stepId) ?? {};
911
+ // COMP-OBS-COST: accumulate step usage and emit step_usage event
912
+ if (stepUsage && (stepUsage.input_tokens > 0 || stepUsage.output_tokens > 0 || stepUsage.cost_usd > 0)) {
913
+ buildCostTotals.input_tokens += stepUsage.input_tokens ?? 0;
914
+ buildCostTotals.output_tokens += stepUsage.output_tokens ?? 0;
915
+ buildCostTotals.cost_usd += stepUsage.cost_usd ?? 0;
916
+ streamWriter.writeUsage(stepId, stepUsage);
917
+ }
918
+
919
+ // COMP-HEALTH: collect runtime violations for health score signal
920
+ const stepViolations = stepState.violations ?? [];
921
+ if (stepViolations.length > 0) {
922
+ allViolations.push(...stepViolations);
923
+ }
924
+
925
+ streamWriter.write({
926
+ type: 'build_step_done',
927
+ stepId,
928
+ summary: (result ?? {}).summary ?? 'Step complete',
929
+ retries: stepState.retries ?? 0,
930
+ violations: stepViolations,
931
+ flowId,
932
+ // COMP-OBS-COST: per-step and cumulative cost
933
+ input_tokens: stepUsage?.input_tokens ?? 0,
934
+ output_tokens: stepUsage?.output_tokens ?? 0,
935
+ cost_usd: stepUsage?.cost_usd ?? 0,
936
+ cumulative_cost_usd: buildCostTotals.cost_usd,
937
+ });
938
+
939
+ // COMP-UX-3c: 1-sentence console narration instead of full event dump
940
+ const stepSummary = (result ?? {}).summary ?? 'Step complete';
941
+ const retryNote = (stepState.retries ?? 0) > 0 ? ` (${stepState.retries} retr${stepState.retries === 1 ? 'y' : 'ies'})` : '';
942
+ console.log(` ${stepId}: ${stepSummary}${retryNote}`);
943
+
944
+ // COMP-OBS-GATES: classify this step as a tier and record result
945
+ {
946
+ const tierId = classifyStepAsTier(stepId);
947
+ if (tierId) {
948
+ const stepPassed = (result?.outcome ?? 'complete') !== 'failed';
949
+ tierResults[tierId] = stepPassed;
950
+ streamWriter.writeGateTier(stepId, tierId, stepPassed, result?.summary ?? null);
951
+
952
+ // If this tier failed, emit gate_tier_failed for early visibility
953
+ if (!stepPassed) {
954
+ streamWriter.write({
955
+ type: 'gate_tier_failed',
956
+ stepId,
957
+ tierId,
958
+ summary: result?.summary ?? 'Tier failed',
959
+ flowId,
960
+ });
961
+ }
962
+ }
963
+ }
964
+ }
965
+
966
+ } else if (response.status === 'await_gate') {
967
+ updateActiveBuildStep(dataDir, stepId);
968
+
969
+ // Gate enrichment extras for STRAT-COMP-6
970
+ const gateExtras = {
971
+ fromPhase: response.from_phase ?? null,
972
+ toPhase: response.to_phase ?? null,
973
+ artifact: response.artifact ?? null,
974
+ summary: response.summary ?? null,
975
+ };
976
+
977
+ // STRAT-IMMUTABLE: verify policy has not changed since build start.
978
+ verifyPolicyIntegrity(settingsPath, policyHash);
979
+
980
+ // ── Policy evaluation (ITEM-23) ────────────────────────────────────
981
+ const policy = evaluatePolicy(policySettings, stepId, {
982
+ fromPhase: response.from_phase,
983
+ toPhase: response.to_phase,
984
+ });
985
+
986
+ if (policy.mode === 'skip') {
987
+ // Silent pass-through — no gate record, no UI
988
+ response = await stratum.gateResolve(flowId, stepId, 'approve', policy.reason, 'system');
989
+ streamWriter.write({
990
+ type: 'build_gate_resolved',
991
+ stepId, outcome: 'approve', rationale: policy.reason, flowId, policyMode: 'skip',
992
+ });
993
+ stepHistory.push({ stepId, artifact: null, summary: `Gate skip: ${policy.reason}`, outcome: 'approve' });
994
+ syncStepHistory(dataDir, stepHistory);
995
+
996
+ } else if (policy.mode === 'flag') {
997
+ // Auto-approve — no gate record, stream event for audit
998
+ console.log(` Gate auto-approved (policy: flag) — ${policy.reason}`);
999
+ response = await stratum.gateResolve(flowId, stepId, 'approve', policy.reason, 'system');
1000
+ streamWriter.write({
1001
+ type: 'build_gate_resolved',
1002
+ stepId, outcome: 'approve', rationale: policy.reason, flowId, policyMode: 'flag',
1003
+ });
1004
+ stepHistory.push({ stepId, artifact: null, summary: `Gate flag: ${policy.reason}`, outcome: 'approve' });
1005
+ syncStepHistory(dataDir, stepHistory);
1006
+
1007
+ } else {
1008
+ // mode === 'gate' — human approval required (existing behavior)
1009
+ streamWriter.write({
1010
+ type: 'build_gate',
1011
+ stepId, flowId,
1012
+ gateType: response.gate_type ?? 'approval',
1013
+ policyMode: 'gate',
1014
+ });
1015
+
1016
+ progress.pause();
1017
+ console.log(`\nGate: ${stepId}`);
1018
+
1019
+ const askAgent = makeAskAgent(getConnector, context, response, gateExtras);
1020
+ const serverUp = await probeServer();
1021
+ let outcome, rationale;
1022
+
1023
+ if (serverUp) {
1024
+ const gateId = await visionWriter.createGate(flowId, stepId, itemId, { ...gateExtras, policyMode: 'gate' });
1025
+ console.log('Gate delegated to web UI. Waiting for resolution...');
1026
+ const resolved = await pollGateResolution(visionWriter, gateId);
1027
+ if (resolved) {
1028
+ outcome = resolved.outcome;
1029
+ rationale = resolved.comment ?? '';
1030
+ } else {
1031
+ const result = await promptGate(response, {
1032
+ ...(opts.gateOpts ?? {}),
1033
+ artifact: context.cwd,
1034
+ askAgent,
1035
+ gateExtras,
1036
+ });
1037
+ outcome = result.outcome;
1038
+ rationale = result.rationale;
1039
+ await visionWriter.resolveGate(gateId, outcome);
1040
+ try { await visionWriter._restResolveGate(gateId, outcome); } catch { /* ignore */ }
1041
+ }
1042
+ } else {
1043
+ const gateId = await visionWriter.createGate(flowId, stepId, itemId, { ...gateExtras, policyMode: 'gate' });
1044
+ const result = await promptGate(response, {
1045
+ ...(opts.gateOpts ?? {}),
1046
+ artifact: context.cwd,
1047
+ askAgent,
1048
+ gateExtras,
1049
+ });
1050
+ outcome = result.outcome;
1051
+ rationale = result.rationale;
1052
+ await visionWriter.resolveGate(gateId, outcome);
1053
+ }
1054
+
1055
+ stepHistory.push({
1056
+ stepId,
1057
+ artifact: null,
1058
+ summary: `Gate ${outcome}${rationale ? ': ' + rationale : ''}`,
1059
+ outcome,
1060
+ });
1061
+ syncStepHistory(dataDir, stepHistory);
1062
+
1063
+ // COMP-CTX item 102: append decision entry to docs/context/decisions.md
1064
+ appendDecisionEntry(contextDirPath, featureCode, stepId, outcome, rationale);
1065
+ // Clear ambient context cache so downstream steps see the new decision
1066
+ clearAmbientContextCache(contextDirPath);
1067
+
1068
+ response = await stratum.gateResolve(flowId, stepId, outcome, rationale, 'human');
1069
+ progress.resume();
1070
+
1071
+ // COMP-UX-3c: concise gate resolution narration
1072
+ if (outcome === 'approve') {
1073
+ const nextPhase = response?.step_id ?? 'next phase';
1074
+ console.log(` Approved -> moving to ${nextPhase}`);
1075
+ } else if (outcome === 'revise') {
1076
+ console.log(` Revising ${stepId}${rationale ? ': ' + rationale : ''}`);
1077
+ } else if (outcome === 'kill') {
1078
+ console.log(` Killed ${stepId}`);
1079
+ }
1080
+
1081
+ streamWriter.write({
1082
+ type: 'build_gate_resolved',
1083
+ stepId, outcome, rationale: rationale ?? '', flowId, policyMode: 'gate',
1084
+ });
1085
+ }
1086
+
1087
+ } else if (response.status === 'execute_flow') {
1088
+ // Flow dispatch shape: { parent_flow_id, parent_step_id, child_flow_id,
1089
+ // child_flow_name, child_step: { step dispatch or gate dispatch } }
1090
+ // Must execute the ENTIRE child flow to completion, then report
1091
+ // the child's output back to the parent via step_done on the parent step.
1092
+ const parentFlowId = response.parent_flow_id;
1093
+ const parentStepId = response.parent_step_id;
1094
+ const childFlowName = response.child_flow_name ?? 'sub-flow';
1095
+ progress.subFlowStep(childFlowName, '');
1096
+
1097
+ // COMP-QA items 113-116: before coverage_check, emit qa_scope event with affected routes.
1098
+ // Helps humans and future automation understand which routes need browser verification.
1099
+ if (childFlowName === 'coverage_check' && (context.filesChanged?.length ?? 0) > 0) {
1100
+ try {
1101
+ const qaScopeResult = mapFilesToRoutes(context.filesChanged ?? [], { cwd: agentCwd });
1102
+ const allKnown = []; // v1: no known-routes registry yet
1103
+ const { affected, adjacent } = classifyRoutes(qaScopeResult.affectedRoutes, allKnown);
1104
+ const skipCoverage = isDocsOnlyDiff(context.filesChanged ?? []);
1105
+ streamWriter?.write({
1106
+ type: 'qa_scope',
1107
+ affectedRoutes: affected,
1108
+ adjacentRoutes: adjacent,
1109
+ unmappedFiles: qaScopeResult.unmappedFiles,
1110
+ framework: qaScopeResult.framework,
1111
+ docsOnly: qaScopeResult.docsOnly,
1112
+ skipCoverage,
1113
+ reason: skipCoverage ? 'docs-only' : null,
1114
+ });
1115
+ } catch (qaScopeErr) {
1116
+ // Non-fatal — QA scope is informational only
1117
+ console.warn(` [qa_scope] Route mapping failed: ${qaScopeErr.message}`);
1118
+ }
1119
+ }
1120
+
1121
+ // COMP-TEST-BOOTSTRAP item 127: before coverage child flow, ensure test scaffold exists.
1122
+ // If no test framework is detected and no test directory exists, scaffold first.
1123
+ // If the project has no tests at all (truly empty), skip coverage gracefully.
1124
+ if (childFlowName === 'coverage_check') {
1125
+ const detected = detectTestFramework(agentCwd);
1126
+ const hasTestDir = existsSync(join(agentCwd, 'test')) ||
1127
+ existsSync(join(agentCwd, 'tests')) ||
1128
+ existsSync(join(agentCwd, '__tests__')) ||
1129
+ existsSync(join(agentCwd, 'spec'));
1130
+
1131
+ if (!detected && !hasTestDir) {
1132
+ // No framework AND no test directory — coverage would always fail.
1133
+ // Detect language from project files and scaffold a minimal framework.
1134
+ const hasPyFiles = existsSync(join(agentCwd, 'pyproject.toml')) ||
1135
+ existsSync(join(agentCwd, 'setup.py')) ||
1136
+ existsSync(join(agentCwd, 'setup.cfg'));
1137
+ const hasGoMod = existsSync(join(agentCwd, 'go.mod'));
1138
+ const hasCargoToml = existsSync(join(agentCwd, 'Cargo.toml'));
1139
+ const language = hasPyFiles ? 'python' : hasGoMod ? 'go' : hasCargoToml ? 'rust' : 'node';
1140
+
1141
+ try {
1142
+ const scaffolded = scaffoldTestFramework(agentCwd, language);
1143
+ if (progress) progress.info(` Test scaffold created: ${scaffolded.framework} (${scaffolded.configFile})`);
1144
+ console.log(` [coverage_check] No tests found — scaffolded ${scaffolded.framework}, command: ${scaffolded.command}`);
1145
+ // Annotate the child step intent so the agent knows about the scaffolded framework
1146
+ if (response.child_step?.intent !== undefined) {
1147
+ response = {
1148
+ ...response,
1149
+ child_step: {
1150
+ ...response.child_step,
1151
+ intent: `${response.child_step.intent} The test framework has been scaffolded (${scaffolded.framework}). Use "${scaffolded.command}" to run the suite. Generate 1-3 golden flow tests covering the core capability lifecycle before running.`,
1152
+ },
1153
+ };
1154
+ }
1155
+ } catch (scaffoldErr) {
1156
+ console.warn(` [coverage_check] Scaffold failed: ${scaffoldErr.message} — skipping coverage step`);
1157
+ // Skip coverage gracefully rather than failing the entire build
1158
+ verifyPipelineIntegrity(specPath, specFileHash);
1159
+ response = await stratum.stepDone(parentFlowId, parentStepId, {
1160
+ passing: true,
1161
+ summary: 'Coverage skipped — no test infrastructure found and scaffold failed',
1162
+ failures: [],
1163
+ });
1164
+ continue;
1165
+ }
1166
+ } else if (!detected && hasTestDir) {
1167
+ // Test directory exists but no recognized framework — agent will discover it
1168
+ console.log(` [coverage_check] Test directory found but no framework config detected — agent will discover the runner`);
1169
+ }
1170
+ }
1171
+
1172
+ let childResult = await executeChildFlow(
1173
+ response, stratum, getConnector, context,
1174
+ visionWriter, itemId, dataDir, opts.gateOpts ?? {}, progress,
1175
+ streamWriter
1176
+ );
1177
+
1178
+ // STRAT-REV-7: After review child flow completes, run cross-model synthesis if diff is large.
1179
+ // If cross-model ran, skip the pipeline's separate codex_review step (avoid duplicate Codex pass).
1180
+ if (childFlowName === 'parallel_review' && childResult?.output && (context.filesChanged?.length ?? 0) > 0) {
1181
+ const mergedResult = childResult.output;
1182
+ const synthesized = await runCrossModelReview(
1183
+ mergedResult,
1184
+ context.filesChanged ?? [],
1185
+ agentCwd,
1186
+ getConnector,
1187
+ streamWriter,
1188
+ opts
1189
+ );
1190
+ if (synthesized !== mergedResult) {
1191
+ childResult = { ...childResult, output: synthesized };
1192
+ context._crossModelCompleted = true; // flag to skip pipeline codex_review step
1193
+ }
1194
+ }
1195
+
1196
+ // STRAT-REV-7: Skip the pipeline's codex_review step if cross-model already ran
1197
+ if (childFlowName === 'review_check' && context._crossModelCompleted) {
1198
+ streamWriter?.write({ type: 'cross_model_review', status: 'codex_skipped', reason: 'Cross-model synthesis already ran Codex' });
1199
+ // Report synthetic clean result to skip this step
1200
+ childResult = { status: 'ok', output: { clean: true, summary: 'Skipped — cross-model synthesis already included Codex review', findings: [] } };
1201
+ }
1202
+
1203
+ // COMP-HEALTH: collect signals from child flows as they complete
1204
+ if (childFlowName === 'coverage_check' && childResult?.output != null) {
1205
+ buildSignals.test_coverage = childResult.output;
1206
+ } else if (childFlowName === 'parallel_review' && childResult?.output != null) {
1207
+ buildSignals.review_findings = childResult.output;
1208
+ }
1209
+
1210
+ // Report child completion envelope to parent flow step.
1211
+ // Stratum's step_done unwraps flow-step results via result.get("output"),
1212
+ // so we pass the full envelope { status, flow_id, output, trace, ... }.
1213
+ verifyPipelineIntegrity(specPath, specFileHash);
1214
+ response = await stratum.stepDone(parentFlowId, parentStepId, childResult);
1215
+
1216
+ } else if (response.status === 'ensure_failed' || response.status === 'schema_failed') {
1217
+ {
1218
+ // COMP-HEALTH: track contract compliance failure
1219
+ contractCompliance.push({ passed: false, stepId: response.step_id ?? stepId, status: response.status });
1220
+ buildSignals.contract_compliance = contractCompliance;
1221
+
1222
+ const currentState = readActiveBuild(dataDir);
1223
+ const violationList = (response.violations || []).slice(-10);
1224
+ updateActiveBuildStep(dataDir, response.step_id ?? stepId, {
1225
+ retries: ((currentState?.retries) || 0) + 1,
1226
+ violations: violationList,
1227
+ });
1228
+
1229
+ // COMP-UX-3c: 1-line iteration summary
1230
+ const iterN = ((currentState?.retries) || 0) + 1;
1231
+ const maxIter = 3; // stratum default max retries
1232
+ const topViolation = violationList[0] ?? 'postcondition failed';
1233
+ const iterSummary = typeof topViolation === 'string'
1234
+ ? topViolation
1235
+ : (topViolation.message ?? topViolation.text ?? JSON.stringify(topViolation));
1236
+ console.log(` Iteration ${iterN}/${maxIter} (${response.step_id ?? stepId}): ${iterSummary.slice(0, 80)}`);
1237
+ }
1238
+ progress.retry('build', stepId, response.agent);
1239
+ const violations = response.violations ?? [];
1240
+ if (violations.length > 0) progress.findings(violations);
1241
+ // STRAT-REV-5: defensive fallback — for non-flow-step paths named 'review'.
1242
+ // For flow-steps the ensure fires inside executeChildFlow (see handler below).
1243
+ if ((response.step_id ?? stepId) === 'review') {
1244
+ const lensesRun = response.output?.lenses_run ?? [];
1245
+ if (lensesRun.length > 0) {
1246
+ persistPriorDirtyLenses(composeDir, lensesRun);
1247
+ }
1248
+ }
1249
+ const retryStepId = response.step_id ?? stepId;
1250
+ const agentType = response.agent ?? 'claude';
1251
+ const prompt = buildRetryPrompt(response, violations, context, response.conflicts);
1252
+ const connector = getConnector(agentType, { cwd: agentCwd });
1253
+ const retryTimeout = STEP_TIMEOUT_MS[retryStepId] ?? DEFAULT_TIMEOUT_MS;
1254
+ let retryResult;
1255
+ try {
1256
+ retryResult = await runAndNormalize(connector, prompt, response, { progress, streamWriter, maxDurationMs: retryTimeout });
1257
+ } catch (err) {
1258
+ if (err instanceof AgentTimeoutError) {
1259
+ console.warn(`\n⚠ Agent timed out on retry "${retryStepId}" after ${Math.round(err.durationMs / 1000)}s`);
1260
+ retryResult = { text: '', result: { outcome: 'failed', summary: `Timed out after ${Math.round(err.durationMs / 1000)}s` } };
1261
+ } else {
1262
+ throw err;
1263
+ }
1264
+ }
1265
+ const { result } = retryResult;
1266
+
1267
+ // Update stepHistory with retry result (replace prior failed entry if present)
1268
+ const priorIdx = stepHistory.findIndex(h => h.stepId === retryStepId);
1269
+ const currentBuild = readActiveBuild(dataDir);
1270
+ const retryEntry = {
1271
+ stepId: retryStepId,
1272
+ artifact: result?.artifact ?? null,
1273
+ summary: result?.summary ?? 'Retry complete',
1274
+ outcome: result?.outcome ?? 'complete',
1275
+ agent: response.agent ?? 'claude',
1276
+ retries: currentBuild?.retries ?? 0,
1277
+ violations: currentBuild?.violations ?? [],
1278
+ };
1279
+ if (priorIdx !== -1) {
1280
+ stepHistory[priorIdx] = retryEntry;
1281
+ } else {
1282
+ stepHistory.push(retryEntry);
1283
+ }
1284
+
1285
+ verifyPipelineIntegrity(specPath, specFileHash);
1286
+ response = await stratum.stepDone(
1287
+ response.flow_id, retryStepId,
1288
+ result ?? { summary: 'Retry complete' }
1289
+ );
1290
+
1291
+ // Debug discipline enforcement on retry (COMP-DEBUG-1)
1292
+ if (retryStepId === 'fix' || retryStepId === 'diagnose') {
1293
+ const filesChanged = extractFilesChanged({ result });
1294
+ fixChainDetector.recordIteration(filesChanged);
1295
+ attemptCounter.record({ filesChanged });
1296
+
1297
+ // Validate trace evidence on diagnose retries
1298
+ if (retryStepId === 'diagnose' && result) {
1299
+ const traceResult = TraceValidator.validate(result);
1300
+ if (!traceResult.valid) {
1301
+ debugLedger.record({ type: 'trace_validation_failed', reason: traceResult.reason });
1302
+ if (progress) progress.warn(`Debug discipline: trace evidence insufficient — ${traceResult.reason}`);
1303
+ }
1304
+ const scopeCheck = crossLayerAudit.shouldExpand(result);
1305
+ if (scopeCheck.expand) {
1306
+ debugLedger.record({ type: 'scope_expansion_triggered', trigger: scopeCheck.trigger });
1307
+ if (progress) progress.warn(`Debug discipline: cross-layer change detected (${scopeCheck.trigger}) — scope_check step should audit all configured repos`);
1308
+ }
1309
+ }
1310
+
1311
+ const chains = fixChainDetector.detect();
1312
+ const intervention = attemptCounter.getIntervention();
1313
+
1314
+ if (chains.length > 0) {
1315
+ debugLedger.record({ type: 'fix_chain_detected', chains });
1316
+ }
1317
+
1318
+ if (intervention === 'escalate') {
1319
+ debugLedger.record({ type: 'escalation', attempt: attemptCounter.count, isVisual: attemptCounter.isVisual });
1320
+ if (streamWriter) streamWriter.write({ type: 'build_error', message: `Debug discipline: escalating after ${attemptCounter.count} attempts. Dispatching to cross-agent review.` });
1321
+ } else if (intervention === 'trace_refresh') {
1322
+ debugLedger.record({ type: 'trace_refresh_required', attempt: attemptCounter.count });
1323
+ if (progress) progress.warn(`Debug discipline: ${attemptCounter.count} attempts — fresh trace evidence required before next fix`);
1324
+ } else if (intervention === 'trace_reminder') {
1325
+ if (progress) progress.warn(`Debug discipline: ${attemptCounter.count} attempts on same target — verify trace evidence is current`);
1326
+ }
1327
+
1328
+ // Persist debug state
1329
+ try {
1330
+ writeFileSync(debugStatePath, JSON.stringify({
1331
+ fixChain: fixChainDetector.toJSON(),
1332
+ attempt: attemptCounter.toJSON(),
1333
+ }), 'utf-8');
1334
+ } catch { /* best-effort */ }
1335
+ }
1336
+
1337
+ } else if (response.status === 'parallel_dispatch') {
1338
+ verifyPipelineIntegrity(specPath, specFileHash);
1339
+ if (shouldUseServerDispatch(response)) {
1340
+ response = await executeParallelDispatchServer(
1341
+ response, stratum, context, progress, streamWriter, agentCwd,
1342
+ );
1343
+ } else {
1344
+ response = await executeParallelDispatch(
1345
+ response,
1346
+ stratum,
1347
+ getConnector,
1348
+ context,
1349
+ progress,
1350
+ streamWriter,
1351
+ agentCwd
1352
+ );
1353
+ }
1354
+
1355
+ } else {
1356
+ // Unknown status — log and try to continue
1357
+ console.warn(`Unknown dispatch status: ${response.status}`);
1358
+ break;
1359
+ }
1360
+ }
1361
+
1362
+ // Flow complete — write terminal state (file retained per STRAT-COMP-4 contract).
1363
+ // T2-F5-CONSUMER-MERGE-STATUS-COMPOSE: when the response carries a
1364
+ // client-side merge_status='conflict' signal (from the deferred-advance
1365
+ // path), resolveBuildStatusForCompleteResponse returns 'failed'; fall
1366
+ // through to the failure terminal block below instead of writing success.
1367
+ if (response.status === 'complete') {
1368
+ buildStatus = resolveBuildStatusForCompleteResponse(response);
1369
+ }
1370
+ if (response.status === 'complete' && buildStatus === 'complete') {
1371
+ console.log('\nBuild complete.');
1372
+ await visionWriter.updateItemStatus(itemId, 'complete');
1373
+ // COMP-QA: persist filesChanged so `compose qa-scope` can read them post-build
1374
+ updateFeature(cwd, featureCode, { status: 'COMPLETE', filesChanged: context.filesChanged ?? [] });
1375
+ const termState = readActiveBuild(dataDir);
1376
+ if (termState) {
1377
+ writeActiveBuild(dataDir, { ...termState, status: 'complete', completedAt: new Date().toISOString() });
1378
+ }
1379
+ clearPriorDirtyLenses(composeDir); // STRAT-REV-5: clean up sidecar on successful build
1380
+ } else if (response.status === 'killed') {
1381
+ buildStatus = 'killed';
1382
+ console.log('\nBuild killed.');
1383
+ await visionWriter.updateItemStatus(itemId, 'killed');
1384
+ updateFeature(cwd, featureCode, { status: 'PLANNED' });
1385
+ const termState = readActiveBuild(dataDir);
1386
+ if (termState) {
1387
+ writeActiveBuild(dataDir, { ...termState, status: 'aborted', completedAt: new Date().toISOString() });
1388
+ }
1389
+ } else if (buildStatus === 'failed') {
1390
+ // Ship failure or other explicit failure — write terminal state
1391
+ console.log('\nBuild failed.');
1392
+ await visionWriter.updateItemStatus(itemId, 'failed');
1393
+ updateFeature(cwd, featureCode, { status: 'PLANNED' });
1394
+ const termState = readActiveBuild(dataDir);
1395
+ if (termState) {
1396
+ writeActiveBuild(dataDir, { ...termState, status: 'failed', completedAt: new Date().toISOString() });
1397
+ }
1398
+ } else {
1399
+ buildStatus = 'failed';
1400
+ }
1401
+
1402
+ // COMP-HEALTH: finalize signals and compute composite health score
1403
+ if (streamWriter) {
1404
+ try {
1405
+ // Runtime errors signal — accumulated across all steps
1406
+ if (allViolations.length > 0) {
1407
+ buildSignals.runtime_errors = allViolations;
1408
+ } else if (!buildSignals.runtime_errors) {
1409
+ buildSignals.runtime_errors = [];
1410
+ }
1411
+
1412
+ // Doc freshness — check staleness of feature artifacts
1413
+ try {
1414
+ const { checkStaleness } = await import('./staleness.js');
1415
+ const currentPhase = stepHistory.length > 0
1416
+ ? stepHistory[stepHistory.length - 1].stepId
1417
+ : 'build';
1418
+ const stalenessResults = checkStaleness(join(cwd, 'docs', 'features', featureCode), currentPhase);
1419
+ buildSignals.doc_freshness = stalenessResults;
1420
+ } catch { /* staleness check is optional — skip on error */ }
1421
+
1422
+ const healthSettings = (() => {
1423
+ try {
1424
+ if (existsSync(settingsPath)) {
1425
+ const s = JSON.parse(readFileSync(settingsPath, 'utf-8'));
1426
+ return s?.health ?? {};
1427
+ }
1428
+ } catch { /* degraded */ }
1429
+ return {};
1430
+ })();
1431
+
1432
+ const { score, breakdown, missing } = computeCompositeScore(
1433
+ buildSignals,
1434
+ healthSettings.weights ?? {}
1435
+ );
1436
+
1437
+ // Emit to build stream
1438
+ streamWriter.writeHealthScore(score, breakdown, missing);
1439
+
1440
+ // Persist to history
1441
+ try {
1442
+ recordScore(cwd, { featureCode, phase: buildStatus, score, breakdown });
1443
+ } catch (err) {
1444
+ console.warn(`[health] Failed to persist score: ${err.message}`);
1445
+ }
1446
+
1447
+ // COMP-HEALTH item 119: gate threshold check (policy integration)
1448
+ // If health score is below the configured threshold, mark the build as failed
1449
+ // so downstream consumers (vision item status, exit code) reflect the rejection.
1450
+ const threshold = healthSettings.gate_threshold;
1451
+ if (typeof threshold === 'number' && score < threshold) {
1452
+ streamWriter.write({
1453
+ type: 'gate_health_rejection',
1454
+ featureCode,
1455
+ score,
1456
+ threshold,
1457
+ reason: `Health score ${score} below threshold ${threshold}`,
1458
+ });
1459
+ console.warn(` [health] Build health score ${score} is below gate threshold ${threshold} — marking build as failed`);
1460
+ // Enforce: downgrade build status so the build is reported as failed
1461
+ buildStatus = 'failed';
1462
+ }
1463
+
1464
+ console.log(` Health score: ${score}/100 (${Object.keys(breakdown).length} dimensions scored)`);
1465
+ } catch (err) {
1466
+ // Non-fatal — health scoring never blocks the build
1467
+ console.warn(`[health] Score computation failed: ${err.message}`);
1468
+ }
1469
+ }
1470
+
1471
+ // COMP-OBS-GATES: emit gate_tier_summary and persist savings on build completion
1472
+ if (streamWriter && Object.keys(tierResults).length > 0) {
1473
+ const tierSummary = evaluateTiers(tierResults);
1474
+ streamWriter.write({
1475
+ type: 'gate_tier_summary',
1476
+ featureCode,
1477
+ passed: tierSummary.passed,
1478
+ tierThatFailed: tierSummary.tierThatFailed,
1479
+ tiersRun: tierSummary.tiersRun,
1480
+ tiersSkipped: tierSummary.tiersSkipped,
1481
+ costSaved: tierSummary.costSaved,
1482
+ });
1483
+
1484
+ // Persist savings entry to .compose/data/gate-savings.json
1485
+ if (tierSummary.tiersSkipped.length > 0 && tierSummary.costSaved > 0) {
1486
+ try {
1487
+ const savingsPath = join(dataDir, 'gate-savings.json');
1488
+ let savingsData = { entries: [] };
1489
+ if (existsSync(savingsPath)) {
1490
+ try { savingsData = JSON.parse(readFileSync(savingsPath, 'utf-8')); } catch { /* corrupt — start fresh */ }
1491
+ }
1492
+ if (!Array.isArray(savingsData.entries)) savingsData.entries = [];
1493
+ savingsData.entries.push({
1494
+ featureCode,
1495
+ date: new Date().toISOString(),
1496
+ cost_saved: Math.round(tierSummary.costSaved * 10000) / 10000,
1497
+ tiers_skipped: tierSummary.tiersSkipped,
1498
+ });
1499
+ mkdirSync(dataDir, { recursive: true });
1500
+ writeFileSync(savingsPath, JSON.stringify(savingsData, null, 2));
1501
+ } catch (err) {
1502
+ console.warn(`[gate-tiers] Failed to persist savings: ${err.message}`);
1503
+ }
1504
+ }
1505
+ }
1506
+
1507
+ // Write audit trace from the completion/killed envelope.
1508
+ // Stratum deletes persisted flows on completion, so stratum_audit()
1509
+ // would return flow_not_found. The completion envelope already includes
1510
+ // { trace, total_duration_ms, output, flow_id }.
1511
+ if (response.trace) {
1512
+ try {
1513
+ mkdirSync(featureDir, { recursive: true });
1514
+ writeFileSync(
1515
+ join(featureDir, 'audit.json'),
1516
+ JSON.stringify(response, null, 2)
1517
+ );
1518
+ console.log(`Audit trace written to docs/features/${featureCode}/audit.json`);
1519
+ } catch (err) {
1520
+ console.warn(`Warning: could not write audit trace: ${err.message}`);
1521
+ }
1522
+ } else {
1523
+ // Fallback: try stratum_audit (works for killed flows that may still be persisted)
1524
+ try {
1525
+ const audit = await stratum.audit(response.flow_id);
1526
+ mkdirSync(featureDir, { recursive: true });
1527
+ writeFileSync(
1528
+ join(featureDir, 'audit.json'),
1529
+ JSON.stringify(audit, null, 2)
1530
+ );
1531
+ console.log(`Audit trace written to docs/features/${featureCode}/audit.json`);
1532
+ } catch (err) {
1533
+ console.warn(`Warning: could not write audit trace: ${err.message}`);
1534
+ }
1535
+ }
1536
+
1537
+ // File retained on disk per STRAT-COMP-4 — overwritten on next build start
1538
+
1539
+ } finally {
1540
+ // Close stream writer with appropriate status (idempotent — signal handler may have already closed)
1541
+ if (streamWriter) {
1542
+ streamWriter.close(buildStatus, buildCostTotals);
1543
+ }
1544
+ if (signalHandler) {
1545
+ process.removeListener('SIGINT', signalHandler);
1546
+ process.removeListener('SIGTERM', signalHandler);
1547
+ }
1548
+ progress.finish();
1549
+ await stratum.close();
1550
+ }
1551
+ }
1552
+
1553
+ // ---------------------------------------------------------------------------
1554
+ // Helpers
1555
+ // ---------------------------------------------------------------------------
1556
+
1557
+ // ---------------------------------------------------------------------------
1558
+ // Ship step — runs git commit in-process (not via agent)
1559
+ // ---------------------------------------------------------------------------
1560
+
1561
+ /**
1562
+ * Execute the ship step: run tests, stage feature files, commit.
1563
+ * Returns a PhaseResult-shaped object.
1564
+ */
1565
+ async function executeShipStep(featureCode, agentCwd, cwd, context, description, progress) {
1566
+ const featureDir = `docs/features/${featureCode}`;
1567
+
1568
+ try {
1569
+ // 0. Check if we're in a git repository — if not, skip git operations
1570
+ let isGitRepo = false;
1571
+ try {
1572
+ execSync('git rev-parse --is-inside-work-tree', { cwd: agentCwd, encoding: 'utf-8', timeout: 5000, stdio: 'pipe' });
1573
+ isGitRepo = true;
1574
+ } catch { /* not a git repo */ }
1575
+
1576
+ if (!isGitRepo) {
1577
+ return {
1578
+ phase: 'ship',
1579
+ artifact: 'no-git',
1580
+ outcome: 'complete',
1581
+ summary: 'No git repository — commit skipped (non-fatal)',
1582
+ };
1583
+ }
1584
+
1585
+ // 1. Run feature-relevant tests (best-effort — don't block ship on test infra issues)
1586
+ if (progress) progress.toolUse('ship', 'Running tests...');
1587
+ try {
1588
+ // COMP-TEST-BOOTSTRAP item 128: use detected test command instead of hard-coded npm test
1589
+ const testFramework = detectTestFramework(agentCwd);
1590
+ const testCommand = testFramework?.command ?? 'npm test';
1591
+ execSync(`${testCommand} 2>&1 || true`, { cwd: agentCwd, encoding: 'utf-8', timeout: 120_000 });
1592
+ } catch { /* test runner not available or timed out — proceed */ }
1593
+
1594
+ // 2. Collect files to stage
1595
+ const filesToStage = new Set();
1596
+
1597
+ // Feature docs
1598
+ filesToStage.add(featureDir);
1599
+
1600
+ // Files changed during this build (tracked by context)
1601
+ if (context.filesChanged?.length > 0) {
1602
+ for (const f of context.filesChanged) filesToStage.add(f);
1603
+ }
1604
+
1605
+ // Also catch any unstaged changes via git
1606
+ try {
1607
+ const dirty = execSync(
1608
+ 'git diff --name-only HEAD 2>/dev/null; git ls-files --others --exclude-standard 2>/dev/null',
1609
+ { cwd: agentCwd, encoding: 'utf-8', timeout: 5000 }
1610
+ ).trim();
1611
+ if (dirty) {
1612
+ for (const f of dirty.split('\n').filter(Boolean)) filesToStage.add(f);
1613
+ }
1614
+ } catch { /* no git or no changes */ }
1615
+
1616
+ // Filter to only files that belong to this feature (feature docs, CHANGELOG, ROADMAP, README)
1617
+ const ownedPrefixes = [featureDir, 'CHANGELOG.md', 'ROADMAP.md', 'README.md', 'CLAUDE.md'];
1618
+ const featureFiles = [...filesToStage].filter(f => {
1619
+ // Feature docs always included
1620
+ if (f.startsWith(featureDir)) return true;
1621
+ // Doc updates
1622
+ if (ownedPrefixes.some(p => f === p || f.endsWith('/' + p))) return true;
1623
+ // Source files from context.filesChanged (the build created/modified these)
1624
+ if (context.filesChanged?.includes(f)) return true;
1625
+ return false;
1626
+ });
1627
+
1628
+ if (featureFiles.length === 0) {
1629
+ return {
1630
+ phase: 'ship',
1631
+ artifact: 'no-changes',
1632
+ outcome: 'complete',
1633
+ summary: 'No files to commit — nothing to ship',
1634
+ };
1635
+ }
1636
+
1637
+ // 3. Stage files
1638
+ if (progress) progress.toolUse('ship', `Staging ${featureFiles.length} files...`);
1639
+ for (const f of featureFiles) {
1640
+ try {
1641
+ execSync(`git add "${f}"`, { cwd: agentCwd, encoding: 'utf-8', timeout: 5000 });
1642
+ } catch { /* file might not exist or already staged */ }
1643
+ }
1644
+
1645
+ // 4. Check if there's anything to commit
1646
+ const staged = execSync('git diff --cached --name-only', {
1647
+ cwd: agentCwd, encoding: 'utf-8', timeout: 5000,
1648
+ }).trim();
1649
+
1650
+ if (!staged) {
1651
+ return {
1652
+ phase: 'ship',
1653
+ artifact: 'no-changes',
1654
+ outcome: 'complete',
1655
+ summary: 'All changes already committed',
1656
+ };
1657
+ }
1658
+
1659
+ // 5. Build commit message
1660
+ const shortDesc = description.split('\n')[0].slice(0, 72);
1661
+ const commitMsg = `feat(${featureCode}): ${shortDesc}`;
1662
+
1663
+ // 6. Commit
1664
+ if (progress) progress.toolUse('ship', 'Committing...');
1665
+ execSync(`git commit -m "${commitMsg.replace(/"/g, '\\"')}"`, {
1666
+ cwd: agentCwd, encoding: 'utf-8', timeout: 30_000,
1667
+ });
1668
+
1669
+ // 7. Get the commit SHA
1670
+ const sha = execSync('git rev-parse HEAD', {
1671
+ cwd: agentCwd, encoding: 'utf-8', timeout: 5000,
1672
+ }).trim();
1673
+
1674
+ const stagedFiles = staged.split('\n').filter(Boolean);
1675
+ if (progress) progress.toolUse('ship', `Committed ${sha.slice(0, 8)} (${stagedFiles.length} files)`);
1676
+
1677
+ return {
1678
+ phase: 'ship',
1679
+ artifact: sha,
1680
+ outcome: 'complete',
1681
+ summary: `Committed ${sha.slice(0, 8)}: ${commitMsg} (${stagedFiles.length} files)`,
1682
+ };
1683
+
1684
+ } catch (err) {
1685
+ return {
1686
+ phase: 'ship',
1687
+ artifact: '',
1688
+ outcome: 'failed',
1689
+ summary: `Ship failed: ${err.message}`,
1690
+ };
1691
+ }
1692
+ }
1693
+
1694
+ // ---------------------------------------------------------------------------
1695
+ // STRAT-REV-7: Cross-model (Codex) review and synthesis
1696
+ // ---------------------------------------------------------------------------
1697
+
1698
+ /**
1699
+ * Run Codex review of the diff and synthesize findings with Claude's MergedReviewResult.
1700
+ *
1701
+ * Opt-out: pass opts.skipCrossModel=true or set COMPOSE_CROSS_MODEL=0 env var.
1702
+ * Graceful skip: if CodexConnector construction fails (opencode not installed).
1703
+ *
1704
+ * @param {object} mergedResult - MergedReviewResult from the parallel_review child flow
1705
+ * @param {string[]} filesChanged - list of changed file paths
1706
+ * @param {string} cwd - working directory
1707
+ * @param {object} getConnector - connector factory
1708
+ * @param {BuildStreamWriter|null} streamWriter
1709
+ * @param {object} opts
1710
+ * @param {boolean} [opts.skipCrossModel] - explicit opt-out
1711
+ * @returns {Promise<object>} updated MergedReviewResult with crossModelSynthesis field,
1712
+ * or original mergedResult if skipped
1713
+ */
1714
+ async function runCrossModelReview(mergedResult, filesChanged, cwd, getConnector, streamWriter, opts = {}) {
1715
+ // --- Opt-out checks ---
1716
+ if (opts.skipCrossModel) {
1717
+ if (streamWriter) streamWriter.write({ type: 'cross_model_review', status: 'skipped', reason: 'skipCrossModel flag set' });
1718
+ return mergedResult;
1719
+ }
1720
+ if (process.env.COMPOSE_CROSS_MODEL === '0') {
1721
+ if (streamWriter) streamWriter.write({ type: 'cross_model_review', status: 'skipped', reason: 'COMPOSE_CROSS_MODEL=0' });
1722
+ return mergedResult;
1723
+ }
1724
+ if (!shouldRunCrossModel(filesChanged)) {
1725
+ return mergedResult; // small/medium diff — skip silently
1726
+ }
1727
+
1728
+ // --- Codex availability check ---
1729
+ let codexConnector;
1730
+ try {
1731
+ codexConnector = new CodexConnector({ cwd });
1732
+ } catch (err) {
1733
+ const msg = `cross-model review skipped: Codex unavailable (${err.message})`;
1734
+ console.warn(` [cross-model] ${msg}`);
1735
+ if (streamWriter) streamWriter.write({ type: 'cross_model_review', status: 'skipped', reason: msg });
1736
+ return mergedResult;
1737
+ }
1738
+
1739
+ if (streamWriter) {
1740
+ streamWriter.write({ type: 'cross_model_review', status: 'started', filesChanged: filesChanged.length });
1741
+ }
1742
+
1743
+ // --- Codex review pass ---
1744
+ const codexPrompt =
1745
+ `You are a senior code reviewer. Review these changed files:\n` +
1746
+ filesChanged.map(f => `- ${f}`).join('\n') +
1747
+ `\n\nWorking directory: ${cwd}\n` +
1748
+ `Read the git diff or the changed files and identify any issues: bugs, security problems, ` +
1749
+ `missing error handling, contract violations, or poor patterns.\n\n` +
1750
+ `Output a JSON array of strings — one string per finding. Each finding should be a ` +
1751
+ `concise, actionable sentence. Example:\n` +
1752
+ `["Missing null check in auth.js:42", "SQL query is not parameterized in db.js:15"]\n\n` +
1753
+ `If you find no issues, output: []\n` +
1754
+ `Output ONLY the JSON array, no prose before or after.`;
1755
+
1756
+ let codexFindings = [];
1757
+ try {
1758
+ const codexTimeout = STEP_TIMEOUT_MS.codex_review ?? 10 * 60_000;
1759
+ const syntheticStep = { step_id: 'codex_review', ensure: [], output_fields: {} };
1760
+ const { text: codexText } = await runAndNormalize(codexConnector, codexPrompt, syntheticStep, {
1761
+ streamWriter,
1762
+ maxDurationMs: codexTimeout,
1763
+ });
1764
+
1765
+ // Parse findings: look for a JSON array in the response text
1766
+ const match = codexText.match(/\[[\s\S]*\]/);
1767
+ if (match) {
1768
+ try {
1769
+ const parsed = JSON.parse(match[0]);
1770
+ if (Array.isArray(parsed)) {
1771
+ codexFindings = parsed.filter(f => typeof f === 'string' && f.trim().length > 0);
1772
+ }
1773
+ } catch { /* unparseable — treat as clean */ }
1774
+ }
1775
+ } catch (err) {
1776
+ const msg = `Codex review error: ${err.message}`;
1777
+ console.warn(` [cross-model] ${msg}`);
1778
+ if (streamWriter) streamWriter.write({ type: 'cross_model_review', status: 'error', error: msg });
1779
+ return mergedResult; // fail-open: don't block the build on Codex errors
1780
+ }
1781
+
1782
+ // Codex clean — nothing to synthesize
1783
+ if (codexFindings.length === 0) {
1784
+ if (streamWriter) {
1785
+ streamWriter.write({ type: 'cross_model_review', status: 'complete', consensus: 0, claudeOnly: 0, codexOnly: 0 });
1786
+ }
1787
+ return mergedResult;
1788
+ }
1789
+
1790
+ // --- Synthesis pass ---
1791
+ const claudeFindings = mergedResult.findings ?? [];
1792
+ const synthesisPrompt =
1793
+ `You are synthesizing code review findings from two models.\n\n` +
1794
+ `## Claude findings (structured LensFinding objects)\n` +
1795
+ JSON.stringify(claudeFindings, null, 2) +
1796
+ `\n\n## Codex findings (plain strings)\n` +
1797
+ JSON.stringify(codexFindings, null, 2) +
1798
+ `\n\n## Task\n` +
1799
+ `Classify each finding as:\n` +
1800
+ `- CONSENSUS: both models flagged the same issue (same file, similar concern)\n` +
1801
+ `- CLAUDE_ONLY: only Claude found it\n` +
1802
+ `- CODEX_ONLY: only Codex found it\n\n` +
1803
+ `Return a JSON object with this exact shape:\n` +
1804
+ `{\n` +
1805
+ ` "consensus": [<LensFinding objects from Claude, with codexNote field added>],\n` +
1806
+ ` "claude_only": [<LensFinding objects>],\n` +
1807
+ ` "codex_only": [{"file":"?","line":0,"severity":"medium","finding":"<codex text>","confidence":70,"source":"codex"}]\n` +
1808
+ `}\n\n` +
1809
+ `For CODEX_ONLY findings, create LensFinding-shaped objects with file="" if the file is not clear.\n` +
1810
+ `Output ONLY the JSON object, no prose.`;
1811
+
1812
+ // Fallback preserves Codex findings as codex_only so they're never silently dropped
1813
+ const codexAsFallback = codexFindings.map(f => ({ file: '', line: 0, severity: 'medium', finding: f, confidence: 60, source: 'codex' }));
1814
+ let synthesis = { consensus: [], claude_only: claudeFindings, codex_only: codexAsFallback };
1815
+ try {
1816
+ const claudeConnector = getConnector('claude', { cwd });
1817
+ const syntheticStep = { step_id: 'synthesis', ensure: [], output_fields: {} };
1818
+ const { text: synthText } = await runAndNormalize(claudeConnector, synthesisPrompt, syntheticStep, {
1819
+ streamWriter,
1820
+ maxDurationMs: 3 * 60_000,
1821
+ });
1822
+
1823
+ const synthMatch = synthText.match(/\{[\s\S]*\}/);
1824
+ if (synthMatch) {
1825
+ try {
1826
+ const parsed = JSON.parse(synthMatch[0]);
1827
+ if (parsed && typeof parsed === 'object') {
1828
+ synthesis = {
1829
+ consensus: Array.isArray(parsed.consensus) ? parsed.consensus : [],
1830
+ claude_only: Array.isArray(parsed.claude_only) ? parsed.claude_only : claudeFindings,
1831
+ codex_only: Array.isArray(parsed.codex_only) ? parsed.codex_only : codexAsFallback,
1832
+ };
1833
+ }
1834
+ } catch { /* keep fallback */ }
1835
+ }
1836
+ } catch (err) {
1837
+ console.warn(` [cross-model] synthesis error: ${err.message}`);
1838
+ // Fall through with default synthesis
1839
+ }
1840
+
1841
+ const allFindings = [
1842
+ ...synthesis.consensus,
1843
+ ...synthesis.claude_only,
1844
+ ...synthesis.codex_only,
1845
+ ];
1846
+ const consensusCount = synthesis.consensus.length;
1847
+ const claudeOnlyCount = synthesis.claude_only.length;
1848
+ const codexOnlyCount = synthesis.codex_only.length;
1849
+
1850
+ if (streamWriter) {
1851
+ streamWriter.write({
1852
+ type: 'cross_model_review',
1853
+ status: 'complete',
1854
+ consensus: consensusCount,
1855
+ claudeOnly: claudeOnlyCount,
1856
+ codexOnly: codexOnlyCount,
1857
+ });
1858
+ }
1859
+
1860
+ return {
1861
+ ...mergedResult,
1862
+ clean: allFindings.length === 0,
1863
+ summary: `Cross-model synthesis: ${consensusCount} consensus, ${claudeOnlyCount} Claude-only, ${codexOnlyCount} Codex-only`,
1864
+ findings: allFindings,
1865
+ crossModelSynthesis: synthesis,
1866
+ };
1867
+ }
1868
+
1869
+ /**
1870
+ * Execute a child flow to completion, returning the child's completion envelope.
1871
+ * Handles the child's internal step loop (execute_step, await_gate, ensure_failed, etc.)
1872
+ * including nested execute_flow (recursive).
1873
+ */
1874
+ async function executeChildFlow(
1875
+ flowDispatch, stratum, getConnector, context,
1876
+ visionWriter, itemId, dataDir, gateOpts, progress,
1877
+ streamWriter
1878
+ ) {
1879
+ let resp = flowDispatch.child_step;
1880
+ const childFlowId = flowDispatch.child_flow_id;
1881
+ const parentFlowId = flowDispatch.parent_flow_id;
1882
+ const childFlowName = flowDispatch.child_flow_name ?? 'sub-flow';
1883
+
1884
+ while (resp.status !== 'complete' && resp.status !== 'killed') {
1885
+ if (resp.status === 'execute_step') {
1886
+ if (progress) {
1887
+ progress.subFlowStep(childFlowName, resp.step_id);
1888
+ } else {
1889
+ console.log(` [${childFlowName}] ${resp.step_id}...`);
1890
+ }
1891
+ await visionWriter.updateItemPhase(itemId, `${childFlowName}:${resp.step_id}`);
1892
+ updateActiveBuildStep(dataDir, resp.step_id);
1893
+
1894
+ // Stream: child step start
1895
+ if (streamWriter) {
1896
+ streamWriter.write({
1897
+ type: 'build_step_start',
1898
+ stepId: resp.step_id,
1899
+ stepNum: resp.step_number ?? '?',
1900
+ totalSteps: resp.total_steps ?? '?',
1901
+ agent: resp.agent ?? 'claude',
1902
+ flowId: childFlowId,
1903
+ parentFlowId,
1904
+ });
1905
+ }
1906
+
1907
+ const agentType = resp.agent ?? 'claude';
1908
+ const prompt = buildStepPrompt(resp, context);
1909
+ const connector = getConnector(agentType, { cwd: context.cwd });
1910
+ const childStepTimeout = STEP_TIMEOUT_MS[resp.step_id] ?? DEFAULT_TIMEOUT_MS;
1911
+ // COMP-CAPS-ENFORCE: tap tool_use events in child flow steps too
1912
+ const childObservedTools = [];
1913
+ const childOnToolUse = (ev) => childObservedTools.push(ev);
1914
+ let childMainResult;
1915
+ try {
1916
+ childMainResult = await runAndNormalize(connector, prompt, resp, { progress, streamWriter, maxDurationMs: childStepTimeout, onToolUse: childOnToolUse });
1917
+ } catch (err) {
1918
+ if (err instanceof UserInterruptError) {
1919
+ if (err.action === 'skip') {
1920
+ if (progress) progress.info(` ⏭ Skipped child step "${resp.step_id}"`);
1921
+ childMainResult = { text: '', result: { outcome: 'skipped', summary: `Skipped by user` } };
1922
+ } else {
1923
+ if (progress) progress.info(` ↻ Retrying child step "${resp.step_id}"`);
1924
+ childMainResult = { text: '', result: { outcome: 'failed', summary: `Retry requested by user` } };
1925
+ }
1926
+ } else if (err instanceof AgentTimeoutError) {
1927
+ console.warn(`\n⚠ Agent timed out on child step "${resp.step_id}" after ${Math.round(err.durationMs / 1000)}s`);
1928
+ childMainResult = { text: '', result: { outcome: 'failed', summary: `Timed out after ${Math.round(err.durationMs / 1000)}s` } };
1929
+ } else {
1930
+ if (streamWriter) streamWriter.write({ type: 'build_error', message: err.message, stepId: resp.step_id });
1931
+ throw err;
1932
+ }
1933
+ }
1934
+ const { result } = childMainResult;
1935
+
1936
+ const completedStepId = resp.step_id;
1937
+
1938
+ // Emit capability_profile event for child step (informational, never blocking)
1939
+ if (streamWriter) {
1940
+ const { template: childTemplate, allowedTools: childAllowed, disallowedTools: childDisallowed } = resolveAgentConfig(agentType);
1941
+ if (childTemplate) {
1942
+ streamWriter.writeCapabilityProfile(completedStepId, agentType, childTemplate, childAllowed, childDisallowed);
1943
+ }
1944
+ // COMP-CAPS-ENFORCE: check child step tool_use events against template
1945
+ for (const ev of childObservedTools) {
1946
+ const check = checkCapabilityViolation(ev.tool, agentType);
1947
+ if (check.violation) {
1948
+ streamWriter.writeViolation(completedStepId, agentType, childTemplate, `${ev.tool}: ${check.reason}`);
1949
+ console.log(` [caps] ${ev.tool} used by ${agentType} — violates ${childTemplate} profile`);
1950
+ }
1951
+ }
1952
+ }
1953
+
1954
+ // Accumulate child step results into shared stepHistory
1955
+ if (context.stepHistory) {
1956
+ context.stepHistory.push({
1957
+ stepId: `${childFlowName}:${completedStepId}`,
1958
+ artifact: result?.artifact ?? null,
1959
+ summary: result?.summary ?? 'Step complete',
1960
+ outcome: result?.outcome ?? 'complete',
1961
+ });
1962
+ }
1963
+
1964
+ resp = await stratum.stepDone(
1965
+ childFlowId, completedStepId,
1966
+ result ?? { summary: 'Step complete' }
1967
+ );
1968
+
1969
+ // Stream: child step done
1970
+ if (streamWriter) {
1971
+ streamWriter.write({
1972
+ type: 'build_step_done',
1973
+ stepId: completedStepId,
1974
+ summary: (result ?? {}).summary ?? 'Step complete',
1975
+ retries: 0,
1976
+ violations: [],
1977
+ flowId: childFlowId,
1978
+ parentFlowId,
1979
+ });
1980
+ }
1981
+
1982
+ } else if (resp.status === 'await_gate') {
1983
+ updateActiveBuildStep(dataDir, resp.step_id);
1984
+
1985
+ // Stream: child gate pending
1986
+ if (streamWriter) {
1987
+ streamWriter.write({
1988
+ type: 'build_gate',
1989
+ stepId: resp.step_id,
1990
+ gateType: resp.gate_type ?? 'approval',
1991
+ flowId: childFlowId,
1992
+ parentFlowId,
1993
+ });
1994
+ }
1995
+
1996
+ if (progress) progress.pause();
1997
+ console.log(` [${childFlowName}] Gate: ${resp.step_id}`);
1998
+ const gateId = await visionWriter.createGate(childFlowId, resp.step_id, itemId);
1999
+ const childAskAgent = makeAskAgent(getConnector, context, resp, null);
2000
+
2001
+ const childGateExtras = {
2002
+ fromPhase: resp.from_phase ?? null,
2003
+ toPhase: resp.to_phase ?? null,
2004
+ };
2005
+ const { outcome, rationale } = await promptGate(resp, {
2006
+ ...gateOpts,
2007
+ artifact: context.cwd,
2008
+ askAgent: childAskAgent,
2009
+ gateExtras: childGateExtras,
2010
+ });
2011
+ await visionWriter.resolveGate(gateId, outcome);
2012
+ const gateStepId = resp.step_id;
2013
+
2014
+ // Inject gate decision into step history so the re-run step sees it
2015
+ if (context.stepHistory) {
2016
+ context.stepHistory.push({
2017
+ stepId: `${childFlowName}:${gateStepId}`,
2018
+ artifact: null,
2019
+ summary: `Gate ${outcome}${rationale ? ': ' + rationale : ''}`,
2020
+ outcome,
2021
+ });
2022
+ }
2023
+
2024
+ resp = await stratum.gateResolve(childFlowId, gateStepId, outcome, rationale, 'human');
2025
+ if (progress) progress.resume();
2026
+
2027
+ // Stream: child gate resolved
2028
+ if (streamWriter) {
2029
+ streamWriter.write({
2030
+ type: 'build_gate_resolved',
2031
+ stepId: gateStepId,
2032
+ outcome, rationale: rationale ?? '',
2033
+ flowId: childFlowId,
2034
+ parentFlowId,
2035
+ });
2036
+ }
2037
+
2038
+ } else if (resp.status === 'ensure_failed' || resp.status === 'schema_failed') {
2039
+ {
2040
+ const currentState = readActiveBuild(dataDir);
2041
+ const violationList = (resp.violations || []).slice(-10);
2042
+ updateActiveBuildStep(dataDir, resp.step_id, {
2043
+ retries: ((currentState?.retries) || 0) + 1,
2044
+ violations: violationList,
2045
+ });
2046
+ }
2047
+ const violations = resp.violations ?? [];
2048
+ if (violations.length > 0 && progress) progress.findings(violations);
2049
+ // STRAT-REV-5: flow-step ensures fire here (inside executeChildFlow), not in the
2050
+ // main dispatch loop — persist dirty lenses so triage selectively re-runs on retry.
2051
+ if (resp.step_id === 'review') {
2052
+ const composeDir = join(context.cwd, '.compose');
2053
+ const lensesRun = resp.output?.lenses_run ?? [];
2054
+ if (lensesRun.length > 0) {
2055
+ persistPriorDirtyLenses(composeDir, lensesRun);
2056
+ }
2057
+ }
2058
+ const stepAgent = resp.agent ?? 'claude';
2059
+ const fixAgent = stepAgent === 'codex' ? 'claude' : stepAgent;
2060
+
2061
+ if (progress) {
2062
+ progress.fix(childFlowName, fixAgent, resp.step_id);
2063
+ } else {
2064
+ console.log(` [${childFlowName}] ↻ Fix (${fixAgent}) for ${resp.step_id}`);
2065
+ }
2066
+ const fixPrompt =
2067
+ `Fix step "${resp.step_id}" — postconditions failed:\n` +
2068
+ violations.map(v => `- ${v}`).join('\n') + '\n\n' +
2069
+ `Fix every issue. Do not skip any.\n\n` +
2070
+ `## Context\nWorking directory: ${context.cwd}\nFeature: ${context.featureCode}`;
2071
+ const fixConnector = getConnector(fixAgent, { cwd: context.cwd });
2072
+ const fixTimeout = STEP_TIMEOUT_MS[resp.step_id] ?? DEFAULT_TIMEOUT_MS;
2073
+ try {
2074
+ await runAndNormalize(fixConnector, fixPrompt, resp, { progress, streamWriter, maxDurationMs: fixTimeout });
2075
+ } catch (err) {
2076
+ if (!(err instanceof AgentTimeoutError)) throw err;
2077
+ console.warn(`\n⚠ Fix agent timed out on "${resp.step_id}"`);
2078
+ }
2079
+
2080
+ if (progress) {
2081
+ progress.retry(childFlowName, resp.step_id, stepAgent);
2082
+ } else {
2083
+ console.log(` [${childFlowName}] ↻ Retrying ${resp.step_id} (${stepAgent})`);
2084
+ }
2085
+ const prompt = buildRetryPrompt(resp, violations, context, resp.conflicts);
2086
+ const connector = getConnector(stepAgent, { cwd: context.cwd });
2087
+ let childRetryResult;
2088
+ try {
2089
+ childRetryResult = await runAndNormalize(connector, prompt, resp, { progress, streamWriter, maxDurationMs: fixTimeout });
2090
+ } catch (err) {
2091
+ if (err instanceof AgentTimeoutError) {
2092
+ console.warn(`\n⚠ Retry agent timed out on "${resp.step_id}"`);
2093
+ childRetryResult = { text: '', result: { outcome: 'failed', summary: `Timed out` } };
2094
+ } else { throw err; }
2095
+ }
2096
+ const { result } = childRetryResult;
2097
+
2098
+ resp = await stratum.stepDone(
2099
+ resp.flow_id ?? childFlowId, resp.step_id,
2100
+ result ?? { summary: 'Retry complete' }
2101
+ );
2102
+
2103
+ } else if (resp.status === 'execute_flow') {
2104
+ // Nested sub-flow — recurse
2105
+ const nestedParentFlowId = resp.parent_flow_id;
2106
+ const nestedParentStepId = resp.parent_step_id;
2107
+ const nestedResult = await executeChildFlow(
2108
+ resp, stratum, getConnector, context,
2109
+ visionWriter, itemId, dataDir, gateOpts, progress,
2110
+ streamWriter
2111
+ );
2112
+ // Pass full envelope — server unwraps via result.get("output")
2113
+ resp = await stratum.stepDone(
2114
+ nestedParentFlowId, nestedParentStepId, nestedResult
2115
+ );
2116
+
2117
+ } else if (resp.status === 'parallel_dispatch') {
2118
+ resp = await executeParallelDispatch(
2119
+ resp,
2120
+ stratum,
2121
+ getConnector,
2122
+ context,
2123
+ progress,
2124
+ streamWriter,
2125
+ context.cwd,
2126
+ parentFlowId
2127
+ );
2128
+
2129
+ } else {
2130
+ console.warn(` [${childFlowName}] Unknown status: ${resp.status}`);
2131
+ break;
2132
+ }
2133
+ }
2134
+
2135
+ return resp; // completion or killed envelope with { output, trace, ... }
2136
+ }
2137
+
2138
+ /**
2139
+ * Decide whether to use server-side dispatch for a parallel_dispatch step.
2140
+ * Strict opt-in: requires both flag=1 AND isolation='none' (the only shape
2141
+ * Stratum v1 server-dispatch can handle safely). isolation='worktree' paths
2142
+ * remain on consumer-dispatch pending T2-F5-DIFF-EXPORT.
2143
+ */
2144
+ /**
2145
+ * Determine the final buildStatus for a terminal 'complete' response.
2146
+ * Returns 'failed' specifically when the response carries a
2147
+ * merge_status='conflict' signal from the deferred-advance parallel_dispatch
2148
+ * path (T2-F5-CONSUMER-MERGE-STATUS-COMPOSE). Otherwise 'complete'.
2149
+ *
2150
+ * Other failure modes are handled by their own terminal branches in the
2151
+ * dispatch loop; this helper narrowly covers the client-side merge conflict
2152
+ * case where Stratum advances with {status: 'complete', output:
2153
+ * {outcome: 'failed', merge_status: 'conflict'}}.
2154
+ */
2155
+ export function resolveBuildStatusForCompleteResponse(response) {
2156
+ if (response?.output?.merge_status === 'conflict') return 'failed';
2157
+ return 'complete';
2158
+ }
2159
+
2160
+ export function shouldUseServerDispatch(dispatchResponse) {
2161
+ if (process.env.COMPOSE_SERVER_DISPATCH !== '1') return false;
2162
+ const isolation = dispatchResponse?.isolation ?? 'worktree';
2163
+ if (isolation === 'none') return true;
2164
+ if (isolation === 'worktree' && dispatchResponse?.capture_diff === true) return true;
2165
+ return false;
2166
+ }
2167
+
2168
+ // T2-F5-COMPOSE-MIGRATE — server-dispatch poll interval (env-overridable for tests).
2169
+ const SERVER_DISPATCH_POLL_MS = () =>
2170
+ Number(process.env.COMPOSE_SERVER_DISPATCH_POLL_MS) || 500;
2171
+
2172
+ /**
2173
+ * Emit per-task state-transition events. Uses build_task_start/done subtypes
2174
+ * (distinct from build_step_start/done) to avoid stepId key collisions in
2175
+ * downstream consumers.
2176
+ */
2177
+ function emitPerTaskProgress(streamWriter, pollResult, emittedStates) {
2178
+ if (!streamWriter) return;
2179
+ const stepId = pollResult.step_id;
2180
+ for (const [taskId, ts] of Object.entries(pollResult.tasks ?? {})) {
2181
+ const prev = emittedStates.get(taskId);
2182
+ if (prev === ts.state) continue;
2183
+ emittedStates.set(taskId, ts.state);
2184
+ if (ts.state === 'running') {
2185
+ streamWriter.write({ type: 'system', subtype: 'build_task_start', stepId, taskId, parallel: true });
2186
+ } else if (ts.state === 'complete' || ts.state === 'failed' || ts.state === 'cancelled') {
2187
+ streamWriter.write({
2188
+ type: 'system', subtype: 'build_task_done',
2189
+ stepId, taskId, parallel: true,
2190
+ status: ts.state, error: ts.error ?? null,
2191
+ });
2192
+ }
2193
+ }
2194
+ }
2195
+
2196
+ /**
2197
+ * Server-dispatch execution path for parallel_dispatch steps. Called only
2198
+ * when COMPOSE_SERVER_DISPATCH=1 AND isolation='none'. Returns the next-step
2199
+ * dispatch envelope produced by stratum's auto-advance logic.
2200
+ */
2201
+ export async function executeParallelDispatchServer(
2202
+ dispatchResponse,
2203
+ stratum,
2204
+ context,
2205
+ progress,
2206
+ streamWriter,
2207
+ baseCwd,
2208
+ ) {
2209
+ const { flow_id: flowId, step_id: stepId,
2210
+ step_number: stepNum, total_steps: totalSteps,
2211
+ tasks } = dispatchResponse;
2212
+ const emittedStates = new Map();
2213
+
2214
+ if (streamWriter) {
2215
+ streamWriter.write({
2216
+ type: 'build_step_start', stepId,
2217
+ stepNum: `∥${stepNum}`, totalSteps,
2218
+ intent: `parallel_dispatch: ${tasks.length} task${tasks.length !== 1 ? 's' : ''}`,
2219
+ parallel: true, flowId,
2220
+ });
2221
+ }
2222
+ if (progress) progress.stepStart(`∥${stepNum}`, totalSteps, stepId);
2223
+
2224
+ // Start; tolerate already_started for crash-recovery
2225
+ const startResult = await stratum.parallelStart(flowId, stepId);
2226
+ if (startResult?.error) {
2227
+ if (startResult.error !== 'already_started') {
2228
+ throw new Error(
2229
+ `stratum_parallel_start failed: ${startResult.error}: ${startResult.message || ''}`,
2230
+ );
2231
+ }
2232
+ } else if (startResult?.status !== 'started') {
2233
+ throw new Error(
2234
+ `stratum_parallel_start returned unexpected envelope: ${JSON.stringify(startResult)}`,
2235
+ );
2236
+ }
2237
+
2238
+ // Poll until outcome is present (NOT can_advance — see design §3)
2239
+ let pollResult;
2240
+ const intervalMs = SERVER_DISPATCH_POLL_MS();
2241
+ while (true) {
2242
+ pollResult = await stratum.parallelPoll(flowId, stepId);
2243
+ if (pollResult?.error) {
2244
+ throw new Error(
2245
+ `stratum_parallel_poll failed: ${pollResult.error}: ${pollResult.message || ''}`,
2246
+ );
2247
+ }
2248
+ emitPerTaskProgress(streamWriter, pollResult, emittedStates);
2249
+ if (pollResult.outcome != null) break;
2250
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
2251
+ }
2252
+
2253
+ if (pollResult.outcome.status === 'already_advanced') {
2254
+ throw new Error(
2255
+ `stratum_parallel_poll returned already_advanced for step ${stepId} — ` +
2256
+ `flow state desync. Aggregate: ${JSON.stringify(pollResult.outcome.aggregate)}`,
2257
+ );
2258
+ }
2259
+
2260
+ // T2-F5-CONSUMER-MERGE-STATUS-COMPOSE: branch on defer-advance sentinel.
2261
+ // hasServerMerge is true only when the spec declared both isolation:worktree AND capture_diff:true.
2262
+ const isolation = dispatchResponse.isolation ?? 'worktree';
2263
+ const hasServerMerge = isolation === 'worktree' && dispatchResponse.capture_diff === true;
2264
+
2265
+ // Defensive: spec declared defer_advance:true but misses the companions
2266
+ // (isolation:worktree + capture_diff:true). The poll still returns the sentinel
2267
+ // but we have nothing to merge. Call advance with 'clean' to unblock the flow
2268
+ // before any worktree-merge block runs.
2269
+ if (pollResult.outcome?.status === 'awaiting_consumer_advance' && !hasServerMerge) {
2270
+ if (streamWriter) {
2271
+ streamWriter.write({
2272
+ type: 'build_error', stepId,
2273
+ message:
2274
+ `Spec declared defer_advance:true without (isolation:worktree + capture_diff:true); ` +
2275
+ `no diffs to merge. Calling parallelAdvance with merge_status='clean' to unblock the flow.`,
2276
+ });
2277
+ }
2278
+ const advanceResult = await stratum.parallelAdvance(flowId, stepId, 'clean');
2279
+ if (advanceResult?.error) {
2280
+ throw new Error(
2281
+ `stratum_parallel_advance failed: ${advanceResult.error}: ${advanceResult.message || ''}`,
2282
+ );
2283
+ }
2284
+ pollResult.outcome = advanceResult;
2285
+ }
2286
+
2287
+ if (hasServerMerge) {
2288
+ if (pollResult.outcome?.status === 'awaiting_consumer_advance') {
2289
+ // DEFER PATH: merge locally, report merge_status, let flow advance with truth.
2290
+ const { mergeStatus, conflictedTaskId, conflictError } = applyServerDispatchDiffsCore(
2291
+ dispatchResponse.tasks ?? [],
2292
+ pollResult.tasks,
2293
+ baseCwd,
2294
+ streamWriter,
2295
+ stepId,
2296
+ context,
2297
+ );
2298
+
2299
+ if (mergeStatus === 'conflict' && streamWriter) {
2300
+ streamWriter.write({
2301
+ type: 'build_error', stepId,
2302
+ message:
2303
+ `Client-side merge conflict on task ${conflictedTaskId}: ${conflictError}. ` +
2304
+ `Reporting merge_status='conflict' to Stratum; flow will route through its failure handler.`,
2305
+ });
2306
+ }
2307
+
2308
+ const advanceResult = await stratum.parallelAdvance(flowId, stepId, mergeStatus);
2309
+ if (advanceResult?.error) {
2310
+ throw new Error(
2311
+ `stratum_parallel_advance failed: ${advanceResult.error}: ${advanceResult.message || ''}`,
2312
+ );
2313
+ }
2314
+ pollResult.outcome = advanceResult;
2315
+ } else {
2316
+ // LEGACY PATH: non-deferred spec. Throwing wrapper preserves pre-defer behavior.
2317
+ try {
2318
+ applyServerDispatchDiffs(
2319
+ dispatchResponse.tasks ?? [],
2320
+ pollResult.tasks,
2321
+ baseCwd,
2322
+ streamWriter,
2323
+ stepId,
2324
+ context,
2325
+ );
2326
+ } catch (err) {
2327
+ if (streamWriter) {
2328
+ streamWriter.write({
2329
+ type: 'build_step_done', stepId,
2330
+ parallel: true,
2331
+ summary: { ...pollResult.summary, merge_status: 'conflict' },
2332
+ flowId,
2333
+ });
2334
+ }
2335
+ throw err;
2336
+ }
2337
+ }
2338
+ }
2339
+
2340
+ if (streamWriter) {
2341
+ streamWriter.write({
2342
+ type: 'build_step_done', stepId,
2343
+ parallel: true,
2344
+ summary: pollResult.summary, flowId,
2345
+ });
2346
+ }
2347
+
2348
+ return pollResult.outcome;
2349
+ }
2350
+
2351
+ /**
2352
+ * Apply per-task unified diffs to a base working tree in topological order.
2353
+ * Shared between consumer-dispatch (executeParallelDispatch) and server-dispatch
2354
+ * (executeParallelDispatchServer via applyServerDispatchDiffs).
2355
+ *
2356
+ * @param {object[]} tasks — ordered task definitions carrying `id` and optional `depends_on`
2357
+ * @param {Map<string,string>} diffMap — taskId → unified diff text
2358
+ * @param {string} baseCwd — target repo root
2359
+ * @param {object} streamWriter — stream for build events (nullable)
2360
+ * @param {string} stepId — parent step id for event attribution
2361
+ * @param {string} patchDir — directory to write temporary .patch files
2362
+ * @returns {{mergeStatus:'clean'|'conflict', appliedFiles:string[], conflictedTaskId:string|null, conflictError:string|null}}
2363
+ */
2364
+ function applyTaskDiffsToBaseCwd(tasks, diffMap, baseCwd, streamWriter, stepId, patchDir) {
2365
+ if (diffMap.size === 0) {
2366
+ return { mergeStatus: 'clean', appliedFiles: [], conflictedTaskId: null, conflictError: null };
2367
+ }
2368
+
2369
+ // Topological sort on depends_on edges (DFS)
2370
+ const taskMap = new Map(tasks.map(t => [t.id, t]));
2371
+ const topoOrder = [];
2372
+ const visited = new Set();
2373
+ const visiting = new Set();
2374
+ const topoVisit = (id) => {
2375
+ if (visited.has(id)) return;
2376
+ if (visiting.has(id)) return;
2377
+ visiting.add(id);
2378
+ const t = taskMap.get(id);
2379
+ if (t) {
2380
+ for (const dep of (t.depends_on ?? [])) topoVisit(dep);
2381
+ }
2382
+ visiting.delete(id);
2383
+ visited.add(id);
2384
+ topoOrder.push(id);
2385
+ };
2386
+ for (const t of tasks) topoVisit(t.id);
2387
+
2388
+ let stashCreated = false;
2389
+ try {
2390
+ const stashOut = execSync('git stash push -u -m "parallel-merge-snapshot"', {
2391
+ cwd: baseCwd, encoding: 'utf-8', timeout: 30000, stdio: 'pipe',
2392
+ }).trim();
2393
+ stashCreated = !stashOut.includes('No local changes');
2394
+ } catch { /* no changes to stash */ }
2395
+
2396
+ let mergeStatus = 'clean';
2397
+ let conflictedTaskId = null;
2398
+ let conflictError = null;
2399
+ const appliedFiles = new Set();
2400
+
2401
+ for (const taskId of topoOrder) {
2402
+ const diff = diffMap.get(taskId);
2403
+ if (!diff) continue;
2404
+
2405
+ const diffPath = join(patchDir, `${taskId}.patch`);
2406
+ try {
2407
+ writeFileSync(diffPath, diff, 'utf-8');
2408
+ execSync(`git apply --check "${diffPath}"`, {
2409
+ cwd: baseCwd, encoding: 'utf-8', timeout: 30000, stdio: 'pipe',
2410
+ });
2411
+ execSync(`git apply "${diffPath}"`, {
2412
+ cwd: baseCwd, encoding: 'utf-8', timeout: 30000, stdio: 'pipe',
2413
+ });
2414
+
2415
+ try {
2416
+ const applied = execSync('git diff --name-only HEAD', {
2417
+ cwd: baseCwd, encoding: 'utf-8', timeout: 5000, stdio: 'pipe',
2418
+ }).trim();
2419
+ if (applied) {
2420
+ for (const f of applied.split('\n').filter(Boolean)) appliedFiles.add(f);
2421
+ }
2422
+ } catch { /* ignore */ }
2423
+ } catch (err) {
2424
+ mergeStatus = 'conflict';
2425
+ conflictedTaskId = taskId;
2426
+ conflictError = err.message;
2427
+ if (streamWriter) {
2428
+ streamWriter.write({
2429
+ type: 'build_error',
2430
+ message: `merge conflict applying ${taskId}: ${err.message}`,
2431
+ stepId,
2432
+ });
2433
+ }
2434
+
2435
+ try {
2436
+ execSync('git checkout -- .', {
2437
+ cwd: baseCwd, encoding: 'utf-8', timeout: 30000, stdio: 'pipe',
2438
+ });
2439
+ execSync('git clean -fd', {
2440
+ cwd: baseCwd, encoding: 'utf-8', timeout: 30000, stdio: 'pipe',
2441
+ });
2442
+ } catch { /* best-effort */ }
2443
+
2444
+ break;
2445
+ } finally {
2446
+ try { unlinkSync(diffPath); } catch { /* ignore */ }
2447
+ }
2448
+ }
2449
+
2450
+ if (stashCreated) {
2451
+ try {
2452
+ execSync('git stash pop', {
2453
+ cwd: baseCwd, encoding: 'utf-8', timeout: 30000, stdio: 'pipe',
2454
+ });
2455
+ } catch { /* stash may have been consumed or conflict with patches */ }
2456
+ }
2457
+
2458
+ return {
2459
+ mergeStatus,
2460
+ appliedFiles: [...appliedFiles],
2461
+ conflictedTaskId,
2462
+ conflictError,
2463
+ };
2464
+ }
2465
+
2466
+ /**
2467
+ * Server-dispatch helper: read per-task diffs from the poll envelope, apply
2468
+ * them topologically to baseCwd, and on conflict throw so the CLI halts.
2469
+ * Called from executeParallelDispatchServer when isolation:worktree + capture_diff.
2470
+ *
2471
+ * On conflict: emits build_error, then throws. Flow state is advanced server-side
2472
+ * (merge_status hardcoded "clean" by stratum); manual resume after resolution.
2473
+ */
2474
+ /**
2475
+ * Apply per-task diffs from a poll envelope to baseCwd. Pure — returns the
2476
+ * merge result without throwing on conflict. Callers decide what to do:
2477
+ * - Legacy (non-deferred) path uses the throwing wrapper applyServerDispatchDiffs.
2478
+ * - Deferred path calls this directly and reports mergeStatus via parallelAdvance.
2479
+ */
2480
+ function applyServerDispatchDiffsCore(taskList, pollTasks, baseCwd, streamWriter, stepId, context) {
2481
+ const diffMap = new Map();
2482
+ for (const [taskId, ts] of Object.entries(pollTasks ?? {})) {
2483
+ if (ts?.state !== 'complete') continue;
2484
+ if (ts?.diff_error) {
2485
+ if (streamWriter) {
2486
+ streamWriter.write({
2487
+ type: 'build_error', stepId,
2488
+ message: `Task ${taskId} completed but diff capture failed: ${ts.diff_error}. Its changes were NOT applied.`,
2489
+ });
2490
+ }
2491
+ continue;
2492
+ }
2493
+ if (ts?.diff != null) diffMap.set(taskId, ts.diff);
2494
+ }
2495
+
2496
+ if (diffMap.size === 0) {
2497
+ return { mergeStatus: 'clean', conflictedTaskId: null, conflictError: null, appliedFiles: [] };
2498
+ }
2499
+
2500
+ const patchDir = mkdtempSync(join(tmpdir(), 'compose-server-patch-'));
2501
+ try {
2502
+ const { mergeStatus, conflictedTaskId, conflictError, appliedFiles } =
2503
+ applyTaskDiffsToBaseCwd(taskList, diffMap, baseCwd, streamWriter, stepId, patchDir);
2504
+
2505
+ if (mergeStatus !== 'conflict' && appliedFiles.length > 0 && context) {
2506
+ const set = new Set(context.filesChanged ?? []);
2507
+ for (const f of appliedFiles) set.add(f);
2508
+ context.filesChanged = [...set];
2509
+ }
2510
+
2511
+ return { mergeStatus, conflictedTaskId, conflictError, appliedFiles };
2512
+ } finally {
2513
+ try { rmSync(patchDir, { recursive: true, force: true }); } catch { /* best-effort */ }
2514
+ }
2515
+ }
2516
+
2517
+ /**
2518
+ * Legacy throwing wrapper — preserves the existing throw-on-conflict semantics
2519
+ * for specs that haven't opted into defer_advance. On conflict, emits a build_error
2520
+ * pointing at the missing flag and throws to halt the CLI.
2521
+ *
2522
+ * Deferred-advance specs route around this via applyServerDispatchDiffsCore
2523
+ * (see executeParallelDispatchServer's sentinel branch).
2524
+ */
2525
+ function applyServerDispatchDiffs(taskList, pollTasks, baseCwd, streamWriter, stepId, context) {
2526
+ const result = applyServerDispatchDiffsCore(taskList, pollTasks, baseCwd, streamWriter, stepId, context);
2527
+ if (result.mergeStatus === 'conflict') {
2528
+ if (streamWriter) {
2529
+ streamWriter.write({
2530
+ type: 'build_error', stepId,
2531
+ message:
2532
+ `CLIENT-SIDE MERGE CONFLICT applying diff for task ${result.conflictedTaskId}: ${result.conflictError}. ` +
2533
+ `Flow has already advanced server-side (merge_status reported as "clean" — spec missing defer_advance: true). ` +
2534
+ `Working tree may contain partial merge state — resolve manually before resuming.`,
2535
+ });
2536
+ }
2537
+ throw new Error(
2538
+ `parallel_dispatch[${stepId}]: client-side merge conflict on task ${result.conflictedTaskId}`,
2539
+ );
2540
+ }
2541
+ }
2542
+
2543
+ async function executeParallelDispatch(
2544
+ dispatchResponse,
2545
+ stratum,
2546
+ getConnector,
2547
+ context,
2548
+ progress,
2549
+ streamWriter,
2550
+ baseCwd,
2551
+ parentFlowId = null
2552
+ ) {
2553
+ // STRAT-PAR-4 — Parallel task dispatch with git worktree isolation.
2554
+ // Each task gets its own worktree; diffs are collected and applied
2555
+ // to the main worktree in topo order after all tasks complete.
2556
+ const tasks = dispatchResponse.tasks ?? [];
2557
+ const intentTemplate = dispatchResponse.intent_template ?? '';
2558
+ const agentType = dispatchResponse.agent ?? 'claude';
2559
+ const dispFlowId = dispatchResponse.flow_id;
2560
+ const dispStepId = dispatchResponse.step_id;
2561
+ const dispStepNum = dispatchResponse.step_number ?? '?';
2562
+ const dispTotalSteps = dispatchResponse.total_steps ?? '?';
2563
+ const useWorktrees = (dispatchResponse.isolation ?? 'worktree') === 'worktree';
2564
+
2565
+ if (streamWriter) {
2566
+ streamWriter.write({
2567
+ type: 'build_step_start', stepId: dispStepId,
2568
+ stepNum: dispStepNum, totalSteps: dispTotalSteps,
2569
+ agent: agentType,
2570
+ intent: `parallel_dispatch: ${tasks.length} task${tasks.length !== 1 ? 's' : ''}`,
2571
+ flowId: dispFlowId, parallel: true,
2572
+ ...(parentFlowId ? { parentFlowId } : {}),
2573
+ });
2574
+ }
2575
+ if (progress) progress.stepStart(dispStepNum, dispTotalSteps, dispStepId);
2576
+
2577
+ const parDir = join(baseCwd, '.compose', 'par');
2578
+
2579
+ let isGitRepo = false;
2580
+ if (useWorktrees) {
2581
+ try {
2582
+ execSync('git rev-parse --is-inside-work-tree', { cwd: baseCwd, encoding: 'utf-8', timeout: 5000, stdio: 'pipe' });
2583
+ isGitRepo = true;
2584
+ mkdirSync(parDir, { recursive: true });
2585
+ } catch { /* not a git repo — fall back to shared cwd */ }
2586
+ }
2587
+ const worktreeIsolation = useWorktrees && isGitRepo;
2588
+
2589
+ const maxConcurrent = Math.max(1, dispatchResponse.max_concurrent ?? 3);
2590
+ let activeSlots = 0;
2591
+ const slotWaiters = [];
2592
+ const acquireSlot = () => {
2593
+ if (activeSlots < maxConcurrent) { activeSlots++; return Promise.resolve(); }
2594
+ return new Promise(res => slotWaiters.push(res));
2595
+ };
2596
+ const releaseSlot = () => {
2597
+ activeSlots--;
2598
+ if (slotWaiters.length > 0) { activeSlots++; slotWaiters.shift()(); }
2599
+ };
2600
+
2601
+ const worktreePaths = new Map();
2602
+ const taskDiffs = new Map();
2603
+
2604
+ const settled = await Promise.allSettled(
2605
+ tasks.map(async (task) => {
2606
+ await acquireSlot();
2607
+ const taskId = task.id;
2608
+ let taskCwd = baseCwd;
2609
+ const wtPath = join(parDir, taskId);
2610
+
2611
+ if (worktreeIsolation) {
2612
+ try {
2613
+ execSync(`git worktree add "${wtPath}" --detach HEAD`, {
2614
+ cwd: baseCwd, encoding: 'utf-8', timeout: 30000, stdio: 'pipe',
2615
+ });
2616
+ worktreePaths.set(taskId, wtPath);
2617
+ taskCwd = wtPath;
2618
+ try {
2619
+ writeFileSync(join(wtPath, '.owner'), String(process.pid), 'utf-8');
2620
+ } catch { /* best-effort */ }
2621
+ } catch (err) {
2622
+ if (streamWriter) {
2623
+ streamWriter.write({ type: 'build_error', message: `worktree create failed for ${taskId}: ${err.message}`, stepId: taskId });
2624
+ }
2625
+ releaseSlot();
2626
+ return { taskId, status: 'failed', error: `worktree create failed: ${err.message}` };
2627
+ }
2628
+ }
2629
+
2630
+ let taskIntent = intentTemplate
2631
+ .replace(/\{task\.description\}/g, task.description ?? '')
2632
+ .replace(/\{task\.files_owned\}/g, (task.files_owned ?? []).join(', '))
2633
+ .replace(/\{task\.files_read\}/g, (task.files_read ?? []).join(', '))
2634
+ .replace(/\{task\.depends_on\}/g, (task.depends_on ?? []).join(', '))
2635
+ .replace(/\{task\.id\}/g, taskId)
2636
+ .replace(/\{lens_name\}/g, task.lens_name ?? '')
2637
+ .replace(/\{lens_focus\}/g, task.lens_focus ?? '')
2638
+ .replace(/\{confidence_gate\}/g, String(task.confidence_gate ?? ''))
2639
+ .replace(/\{exclusions\}/g, task.exclusions ?? '');
2640
+
2641
+ // STRAT-CERT: inject reasoning template for Claude-family agents (CERT-WIRE-1/7)
2642
+ if (agentType.startsWith('claude') && task.lens_name) {
2643
+ const lensDef = LENS_DEFINITIONS[task.lens_name];
2644
+ if (lensDef?.reasoning_template) {
2645
+ taskIntent = injectCertInstructions(taskIntent, lensDef.reasoning_template);
2646
+ }
2647
+ }
2648
+
2649
+ const syntheticDispatch = {
2650
+ step_id: taskId,
2651
+ intent: taskIntent,
2652
+ inputs: {
2653
+ featureCode: context.featureCode,
2654
+ taskId,
2655
+ description: task.description ?? '',
2656
+ lens_name: task.lens_name,
2657
+ },
2658
+ output_fields: dispatchResponse.output_fields ?? {},
2659
+ ensure: dispatchResponse.ensure ?? [],
2660
+ };
2661
+
2662
+ if (streamWriter) {
2663
+ streamWriter.write({
2664
+ type: 'build_step_start', stepId: taskId,
2665
+ stepNum: `∥${taskId}`, totalSteps: dispTotalSteps,
2666
+ agent: agentType, intent: taskIntent,
2667
+ flowId: dispFlowId, parallel: true,
2668
+ ...(parentFlowId ? { parentFlowId } : {}),
2669
+ });
2670
+ }
2671
+
2672
+ try {
2673
+ const prompt = buildStepPrompt(syntheticDispatch, context);
2674
+ const connector = getConnector(agentType, { cwd: taskCwd });
2675
+ const taskTimeout = STEP_TIMEOUT_MS[dispStepId] ?? DEFAULT_TIMEOUT_MS;
2676
+ const taskResult = await runAndNormalize(connector, prompt, syntheticDispatch, { progress, streamWriter, maxDurationMs: taskTimeout });
2677
+
2678
+ if (worktreeIsolation && worktreePaths.has(taskId)) {
2679
+ const diskQuotaMB = dispatchResponse.diskQuotaMB ?? 500;
2680
+ try {
2681
+ const duOut = execSync(`du -sk "${wtPath}"`, { encoding: 'utf-8', timeout: 10000, stdio: 'pipe' }).trim();
2682
+ const sizeKB = parseInt(duOut.split(/\s+/)[0], 10);
2683
+ if (sizeKB / 1024 > diskQuotaMB) {
2684
+ if (streamWriter) {
2685
+ streamWriter.write({ type: 'build_error', message: `Worktree ${taskId} exceeds ${diskQuotaMB}MB quota (${Math.round(sizeKB / 1024)}MB), skipping merge`, stepId: taskId });
2686
+ }
2687
+ return { taskId, status: 'failed', error: `Disk quota exceeded: ${Math.round(sizeKB / 1024)}MB > ${diskQuotaMB}MB` };
2688
+ }
2689
+ } catch { /* du failed — proceed anyway */ }
2690
+
2691
+ try {
2692
+ execSync('git add -A', { cwd: wtPath, encoding: 'utf-8', timeout: 10000, stdio: 'pipe' });
2693
+ const diff = execSync('git diff --cached HEAD', {
2694
+ cwd: wtPath, encoding: 'utf-8', timeout: 30000, stdio: 'pipe',
2695
+ });
2696
+ if (diff.trim()) taskDiffs.set(taskId, diff);
2697
+ } catch (err) {
2698
+ if (streamWriter) {
2699
+ streamWriter.write({ type: 'build_error', message: `diff collect failed for ${taskId}: ${err.message}`, stepId: taskId });
2700
+ }
2701
+ }
2702
+ }
2703
+
2704
+ if (streamWriter) {
2705
+ streamWriter.write({
2706
+ type: 'build_step_done', stepId: taskId,
2707
+ summary: (taskResult.result ?? {}).summary ?? 'Task complete',
2708
+ retries: 0,
2709
+ violations: [],
2710
+ flowId: dispFlowId, parallel: true,
2711
+ ...(parentFlowId ? { parentFlowId } : {}),
2712
+ });
2713
+ }
2714
+
2715
+ return { taskId, status: 'complete', result: taskResult.result ?? { summary: 'Task complete' } };
2716
+ } catch (err) {
2717
+ if (streamWriter) streamWriter.write({ type: 'build_error', message: err.message, stepId: taskId });
2718
+ return { taskId, status: 'failed', error: err.message };
2719
+ } finally {
2720
+ if (worktreeIsolation && worktreePaths.has(taskId)) {
2721
+ try {
2722
+ execSync(`git worktree remove "${wtPath}" --force`, {
2723
+ cwd: baseCwd, encoding: 'utf-8', timeout: 30000, stdio: 'pipe',
2724
+ });
2725
+ } catch { /* best-effort cleanup */ }
2726
+ }
2727
+ releaseSlot();
2728
+ }
2729
+ })
2730
+ );
2731
+
2732
+ const taskResults = settled.map(outcome => {
2733
+ if (outcome.status === 'rejected') {
2734
+ return { task_id: 'unknown', status: 'failed', error: String(outcome.reason) };
2735
+ }
2736
+ const { taskId, status, result, error } = outcome.value;
2737
+ return status === 'complete'
2738
+ ? { task_id: taskId, status: 'complete', result }
2739
+ : { task_id: taskId, status: 'failed', error };
2740
+ });
2741
+
2742
+ let mergeStatus = 'clean';
2743
+ if (worktreeIsolation && taskDiffs.size > 0) {
2744
+ const result = applyTaskDiffsToBaseCwd(
2745
+ tasks, taskDiffs, baseCwd, streamWriter, dispStepId, parDir,
2746
+ );
2747
+ mergeStatus = result.mergeStatus;
2748
+
2749
+ // Merge applied files into context (matches existing behavior)
2750
+ if (mergeStatus !== 'conflict' && result.appliedFiles.length > 0) {
2751
+ const existing = new Set(context.filesChanged ?? []);
2752
+ for (const f of result.appliedFiles) existing.add(f);
2753
+ context.filesChanged = [...existing];
2754
+ }
2755
+
2756
+ // Mark conflicted task as failed in taskResults (matches existing behavior)
2757
+ if (mergeStatus === 'conflict' && result.conflictedTaskId) {
2758
+ const idx = taskResults.findIndex(r => r.task_id === result.conflictedTaskId);
2759
+ if (idx >= 0) {
2760
+ taskResults[idx].status = 'failed';
2761
+ taskResults[idx].error = `merge conflict: ${result.conflictError}`;
2762
+ }
2763
+ }
2764
+ }
2765
+
2766
+ if (worktreeIsolation) {
2767
+ try { execSync(`rm -rf "${parDir}"`, { cwd: baseCwd, timeout: 5000, stdio: 'pipe' }); } catch { /* ignore */ }
2768
+ }
2769
+
2770
+ const nComplete = taskResults.filter(r => r.status === 'complete').length;
2771
+ if (streamWriter) {
2772
+ streamWriter.write({
2773
+ type: 'build_step_done', stepId: dispStepId,
2774
+ summary: `parallel_dispatch: ${nComplete}/${taskResults.length} tasks ${mergeStatus === 'clean' ? 'merged' : 'conflict'}`,
2775
+ retries: 0,
2776
+ violations: [],
2777
+ flowId: dispFlowId, parallel: true,
2778
+ ...(parentFlowId ? { parentFlowId } : {}),
2779
+ });
2780
+ }
2781
+
2782
+ return stratum.parallelDone(dispFlowId, dispStepId, taskResults, mergeStatus);
2783
+ }
2784
+
2785
+ async function startFresh(stratum, specYaml, featureCode, description, dataDir, templateName) {
2786
+ const flowName = extractFlowName(specYaml, templateName);
2787
+ console.log(`Starting ${flowName} for ${featureCode}...`);
2788
+ const response = await stratum.plan(specYaml, flowName, { featureCode, description });
2789
+
2790
+ writeActiveBuild(dataDir, {
2791
+ featureCode,
2792
+ flowId: response.flow_id,
2793
+ pipeline: flowName,
2794
+ currentStepId: response.step_id,
2795
+ specPath: `pipelines/${templateName}.stratum.yaml`,
2796
+ stepNum: response.step_number ?? 1,
2797
+ totalSteps: response.total_steps ?? null,
2798
+ retries: 0,
2799
+ violations: [],
2800
+ status: 'running',
2801
+ startedAt: new Date().toISOString(),
2802
+ });
2803
+
2804
+ return response;
2805
+ }
2806
+
2807
+ function updateActiveBuildStep(dataDir, stepId, extra = {}) {
2808
+ const state = readActiveBuild(dataDir);
2809
+ if (state) {
2810
+ // Reset retries/violations when switching to a new step
2811
+ if (state.currentStepId !== stepId) {
2812
+ state.retries = 0;
2813
+ state.violations = [];
2814
+ }
2815
+ state.currentStepId = stepId;
2816
+ Object.assign(state, extra);
2817
+ writeActiveBuild(dataDir, state);
2818
+ }
2819
+ }
2820
+
2821
+ /**
2822
+ * Sync stepHistory into active-build.json so the UI can read per-step results.
2823
+ * Called after each step completes (execute or gate).
2824
+ */
2825
+ function syncStepHistory(dataDir, stepHistory) {
2826
+ const state = readActiveBuild(dataDir);
2827
+ if (state) {
2828
+ // Top-level retries/violations on active-build apply to the current step
2829
+ const currentStepId = state.currentStepId;
2830
+ const topRetries = state.retries || 0;
2831
+ const topViolations = state.violations || [];
2832
+
2833
+ let cumulativeCostUsd = 0;
2834
+ let cumulativeInputTokens = 0;
2835
+ let cumulativeOutputTokens = 0;
2836
+ state.steps = stepHistory.map(h => {
2837
+ const isCurrent = h.stepId === currentStepId;
2838
+ cumulativeCostUsd += h.cost_usd ?? 0;
2839
+ cumulativeInputTokens += h.input_tokens ?? 0;
2840
+ cumulativeOutputTokens += h.output_tokens ?? 0;
2841
+ return {
2842
+ id: h.stepId,
2843
+ status: h.outcome === 'complete' ? 'done'
2844
+ : h.outcome === 'failed' ? 'failed'
2845
+ : h.outcome === 'approve' ? 'done'
2846
+ : h.outcome === 'revise' ? 'revised'
2847
+ : h.outcome === 'kill' ? 'killed'
2848
+ : h.outcome ?? 'done',
2849
+ summary: h.summary ?? null,
2850
+ artifact: h.artifact ?? null,
2851
+ agent: h.agent ?? null,
2852
+ durationMs: h.durationMs ?? null,
2853
+ filesChanged: h.filesChanged ?? null,
2854
+ retries: isCurrent ? topRetries : (h.retries ?? 0),
2855
+ violations: isCurrent ? topViolations : (h.violations ?? []),
2856
+ // COMP-OBS-COST: per-step token/cost data
2857
+ input_tokens: h.input_tokens ?? 0,
2858
+ output_tokens: h.output_tokens ?? 0,
2859
+ cost_usd: h.cost_usd ?? 0,
2860
+ };
2861
+ });
2862
+ // COMP-OBS-COST: persist cumulative build cost/tokens to active-build.json
2863
+ // so resumed builds can seed their accumulators correctly
2864
+ state.cumulative_cost_usd = cumulativeCostUsd;
2865
+ state.total_input_tokens = cumulativeInputTokens;
2866
+ state.total_output_tokens = cumulativeOutputTokens;
2867
+ writeActiveBuild(dataDir, state);
2868
+ }
2869
+ }
2870
+
2871
+ /**
2872
+ * Poll gate resolution via REST. Returns resolved gate or null on server loss.
2873
+ * @param {VisionWriter} visionWriter
2874
+ * @param {string} gateId
2875
+ * @param {number} [intervalMs=2000]
2876
+ * @returns {Promise<object|null>} resolved gate or null (server lost mid-poll)
2877
+ */
2878
+ async function pollGateResolution(visionWriter, gateId, intervalMs = 2000) {
2879
+ let consecutiveFailures = 0;
2880
+ while (true) {
2881
+ try {
2882
+ const gate = await visionWriter.getGate(gateId, { requireServer: true });
2883
+ consecutiveFailures = 0;
2884
+ if (!gate) throw new Error(`Gate ${gateId} not found (404)`);
2885
+ if (gate.status === 'expired') throw new Error(`Gate ${gateId} expired`);
2886
+ if (gate.status !== 'pending') return gate;
2887
+ } catch (err) {
2888
+ if (err instanceof ServerUnreachableError) {
2889
+ consecutiveFailures++;
2890
+ if (consecutiveFailures >= 3) {
2891
+ console.log('Server lost during gate poll — falling back to readline.');
2892
+ return null;
2893
+ }
2894
+ } else {
2895
+ throw err;
2896
+ }
2897
+ }
2898
+ await new Promise(r => setTimeout(r, intervalMs));
2899
+ }
2900
+ }
2901
+
2902
+ /**
2903
+ * Append a decision log entry to docs/context/decisions.md.
2904
+ * Only writes if the file already exists (created by `compose init`).
2905
+ *
2906
+ * @param {string} contextDir - Absolute path to docs/context/
2907
+ * @param {string} featureCode
2908
+ * @param {string} stepId
2909
+ * @param {string} outcome - 'approve' | 'revise' | 'kill'
2910
+ * @param {string} [rationale]
2911
+ */
2912
+ function appendDecisionEntry(contextDir, featureCode, stepId, outcome, rationale) {
2913
+ const decisionsPath = join(contextDir, 'decisions.md');
2914
+ if (!existsSync(decisionsPath)) return;
2915
+
2916
+ const today = new Date().toISOString().slice(0, 10);
2917
+ const entry = [
2918
+ '',
2919
+ `## [${today}] ${featureCode} — ${stepId}`,
2920
+ `**Outcome:** ${outcome}`,
2921
+ rationale ? `**Rationale:** ${rationale}` : null,
2922
+ ].filter(l => l !== null).join('\n');
2923
+
2924
+ try {
2925
+ const current = readFileSync(decisionsPath, 'utf-8');
2926
+ writeFileSync(decisionsPath, current.trimEnd() + '\n' + entry + '\n');
2927
+ } catch {
2928
+ // If we can't write, don't crash the build
2929
+ }
2930
+ }
2931
+
2932
+ function loadFeatureDescription(featureDir, featureCode) {
2933
+ // Try design.md, then spec.md, then fall back to feature code
2934
+ for (const name of ['design.md', 'spec.md']) {
2935
+ const p = join(featureDir, name);
2936
+ if (existsSync(p)) {
2937
+ const content = readFileSync(p, 'utf-8');
2938
+ // Extract first paragraph or heading as description
2939
+ const firstLine = content.split('\n').find(l => l.trim() && !l.startsWith('#'));
2940
+ return firstLine?.trim() ?? featureCode;
2941
+ }
2942
+ }
2943
+ return featureCode;
2944
+ }
2945
+
2946
+ async function abortBuild(dataDir, featureCode) {
2947
+ const active = readActiveBuild(dataDir);
2948
+ if (!active) {
2949
+ console.log('No active build to abort.');
2950
+ return;
2951
+ }
2952
+
2953
+ if (featureCode && active.featureCode !== featureCode) {
2954
+ console.log(`Active build is for ${active.featureCode}, not ${featureCode}.`);
2955
+ return;
2956
+ }
2957
+
2958
+ console.log(`Aborting build for ${active.featureCode}...`);
2959
+
2960
+ // Try to kill via Stratum gate resolve if at a gate
2961
+ const stratum = new StratumMcpClient();
2962
+ try {
2963
+ await stratum.connect();
2964
+ const audit = await stratum.audit(active.flowId);
2965
+ if (isTerminalFlow(audit.status)) {
2966
+ console.log(`Flow already ${audit.status}.`);
2967
+ } else {
2968
+ // Try direct flow file deletion (known contract gap)
2969
+ const flowFile = join(homedir(), '.stratum', 'flows', `${active.flowId}.json`);
2970
+ if (existsSync(flowFile)) {
2971
+ unlinkSync(flowFile);
2972
+ console.log('Deleted Stratum flow state.');
2973
+ }
2974
+ }
2975
+ } catch {
2976
+ // Flow might not exist; try direct cleanup
2977
+ const flowFile = join(homedir(), '.stratum', 'flows', `${active.flowId}.json`);
2978
+ if (existsSync(flowFile)) {
2979
+ unlinkSync(flowFile);
2980
+ console.log('Deleted Stratum flow state.');
2981
+ }
2982
+ } finally {
2983
+ await stratum.close();
2984
+ }
2985
+
2986
+ // Update vision state
2987
+ const visionWriter = new VisionWriter(dataDir);
2988
+ const item = await visionWriter.findFeatureItem(active.featureCode);
2989
+ const itemId = item?.id;
2990
+ if (itemId) {
2991
+ await visionWriter.updateItemStatus(itemId, 'killed');
2992
+ }
2993
+
2994
+ // Write terminal state (file retained per STRAT-COMP-4 contract)
2995
+ writeActiveBuild(dataDir, { ...active, status: 'aborted', completedAt: new Date().toISOString() });
2996
+ console.log('Build aborted.');
2997
+ }