@smartmemory/compose 0.3.6-beta → 0.3.8

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 (269) hide show
  1. package/.claude/skills/compose/SKILL.md +42 -88
  2. package/.compose-deps.json +1 -13
  3. package/README.md +72 -5
  4. package/bin/compose.js +754 -347
  5. package/bin/git-hooks/pre-push.template +29 -0
  6. package/bin/judgment-import.js +7 -0
  7. package/bin/judgment-migrate.js +387 -0
  8. package/contracts/comp-obs-contract.schema.json +9 -3
  9. package/contracts/feature-json.schema.json +5 -0
  10. package/contracts/fluid-record.schema.json +209 -0
  11. package/contracts/judgment-record.schema.json +425 -4
  12. package/contracts/lifecycle-backfill.schema.json +322 -0
  13. package/dist/assets/App-Z4MU-H_F.js +916 -0
  14. package/dist/assets/_baseUniq-ClWoCPFl.js +1 -0
  15. package/dist/assets/arc-DY26UIVo.js +1 -0
  16. package/dist/assets/architectureDiagram-Q4EWVU46-6Ggq4DqJ.js +36 -0
  17. package/dist/assets/blockDiagram-DXYQGD6D-CH3Ked0l.js +132 -0
  18. package/dist/assets/{browser-BSM23If2.js → browser-BWkrenen.js} +6 -6
  19. package/dist/assets/{c4Diagram-LMCZKHZV-DZf45Fbz.js → c4Diagram-AHTNJAMY-Bk8dYilu.js} +1 -1
  20. package/dist/assets/channel-SnZzzh7k.js +1 -0
  21. package/dist/assets/{chunk-JWPE2WC7-_7ujgd_Q.js → chunk-4BX2VUAB-BMR0XaAQ.js} +1 -1
  22. package/dist/assets/chunk-4TB4RGXK-JytR14a9.js +206 -0
  23. package/dist/assets/{chunk-XXDRQBXY-DfdVhbmA.js → chunk-55IACEB6-B4Q97BCP.js} +1 -1
  24. package/dist/assets/{chunk-VR4S4FIN-Dt9NZ67m.js → chunk-EDXVE4YY-R_qarkSf.js} +1 -1
  25. package/dist/assets/{chunk-5VM5RSS4-BY4_PV5H.js → chunk-FMBD7UC4-C9s7KR9m.js} +1 -1
  26. package/dist/assets/chunk-OYMX7WX6-BySQzVxc.js +231 -0
  27. package/dist/assets/{chunk-2Q5K7J3B-Dn1spZYu.js → chunk-QZHKN3VN-DdpSYZsW.js} +1 -1
  28. package/dist/assets/{chunk-32BRIVSS-pURGrJDk.js → chunk-YZCP3GAM-iE_tzriw.js} +1 -1
  29. package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +1 -0
  30. package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +1 -0
  31. package/dist/assets/clone-DgklGjHm.js +1 -0
  32. package/dist/assets/{cose-bilkent-JH36ORCC-BieYif4o.js → cose-bilkent-S5V4N54A-BdlU6ZX_.js} +1 -1
  33. package/dist/assets/dagre-KV5264BT-Cp3F5KTn.js +4 -0
  34. package/dist/assets/diagram-5BDNPKRD-DiR6_2q_.js +10 -0
  35. package/dist/assets/diagram-G4DWMVQ6-w0i-p5HX.js +24 -0
  36. package/dist/assets/diagram-MMDJMWI5-tIHhwUv3.js +43 -0
  37. package/dist/assets/diagram-TYMM5635-BAeY3B19.js +24 -0
  38. package/dist/assets/erDiagram-SMLLAGMA-Ckx_Knko.js +85 -0
  39. package/dist/assets/flowDiagram-DWJPFMVM-DeoNka6J.js +162 -0
  40. package/dist/assets/ganttDiagram-T4ZO3ILL-BmGnFbEg.js +292 -0
  41. package/dist/assets/gitGraphDiagram-UUTBAWPF-Dk48IHsx.js +106 -0
  42. package/dist/assets/graph-BNzKGvoy.js +1 -0
  43. package/dist/assets/graph-CI_1htl0.js +331 -0
  44. package/dist/assets/index-BEfrNBp8.js +123 -0
  45. package/dist/assets/index-yyrA5OZd.css +1 -0
  46. package/dist/assets/infoDiagram-42DDH7IO-BRf827i0.js +2 -0
  47. package/dist/assets/{ishikawaDiagram-FXEZZL3T-CzEB9fQS.js → ishikawaDiagram-UXIWVN3A-0kCZaeCM.js} +5 -5
  48. package/dist/assets/{journeyDiagram-5HDEW3XC-Bz8TCdz2.js → journeyDiagram-VCZTEJTY-rvU7ayRt.js} +1 -1
  49. package/dist/assets/{kanban-definition-HUTT4EX6-tozrMoV_.js → kanban-definition-6JOO6SKY-DpQwX1C5.js} +7 -7
  50. package/dist/assets/katex-DkKDou_j.js +257 -0
  51. package/dist/assets/layout-BI8cXFPI.js +1 -0
  52. package/dist/assets/{linear-Ck7gpa5N.js → linear-a0glcDiw.js} +1 -1
  53. package/dist/assets/min-vPHfnXcC.js +1 -0
  54. package/dist/assets/{mindmap-definition-LN4V7U3C-DTcHO0DJ.js → mindmap-definition-QFDTVHPH-D14eF-7C.js} +7 -7
  55. package/dist/assets/mobile-B7m9EO9D.js +17 -0
  56. package/dist/assets/pieDiagram-DEJITSTG-Cno-gETh.js +30 -0
  57. package/dist/assets/quadrantDiagram-34T5L4WZ-BUQM1Hfm.js +7 -0
  58. package/dist/assets/{requirementDiagram-TGXJPOKE-bnI2zJeT.js → requirementDiagram-MS252O5E-pOXlN2-q.js} +3 -3
  59. package/dist/assets/sankeyDiagram-XADWPNL6-Crynd3_b.js +10 -0
  60. package/dist/assets/sequenceDiagram-FGHM5R23-D9fZdCM8.js +157 -0
  61. package/dist/assets/stateDiagram-FHFEXIEX-CW9qVec8.js +1 -0
  62. package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +1 -0
  63. package/dist/assets/{timeline-definition-FHXFAJF6-D267GQFF.js → timeline-definition-GMOUNBTQ-BcHzhm_8.js} +3 -3
  64. package/dist/assets/vennDiagram-DHZGUBPP-BfytJcWk.js +34 -0
  65. package/dist/assets/wardley-RL74JXVD-DLj-IjyB.js +162 -0
  66. package/dist/assets/wardleyDiagram-NUSXRM2D-Ds0Ue68c.js +20 -0
  67. package/dist/assets/xychartDiagram-5P7HB3ND-vjWDXFL6.js +7 -0
  68. package/dist/index.html +3 -3
  69. package/lib/agent-string.js +7 -5
  70. package/lib/append-integrity.js +81 -0
  71. package/lib/backfill-evidence.js +109 -0
  72. package/lib/bug-escalation.js +39 -4
  73. package/lib/build-stream-schema.js +3 -1
  74. package/lib/build-stream-writer.js +25 -0
  75. package/lib/build.js +1624 -195
  76. package/lib/canon-guard.js +245 -0
  77. package/lib/canon-override.js +196 -0
  78. package/lib/canon-registry.js +291 -0
  79. package/lib/cli-commands.js +144 -0
  80. package/lib/codex-preflight.js +50 -15
  81. package/lib/colleague/context.js +215 -0
  82. package/lib/colleague/writeback.js +95 -0
  83. package/lib/completion-gate.js +1421 -0
  84. package/lib/completion-writer.js +47 -47
  85. package/lib/consumer-fanout.js +105 -11
  86. package/lib/coverage-gate.js +200 -0
  87. package/lib/dir-lock.js +170 -0
  88. package/lib/dispatch-ledger.js +301 -0
  89. package/lib/dispatch-metrics.js +236 -0
  90. package/lib/experiment-judge.js +6 -1
  91. package/lib/feature-json.js +1 -1
  92. package/lib/feature-reconciler.js +8 -0
  93. package/lib/feature-validator.js +64 -1
  94. package/lib/feature-writer.js +66 -2
  95. package/lib/fluid/factory.js +167 -0
  96. package/lib/fluid/ideabox-dates.js +73 -0
  97. package/lib/fluid/ideabox-migrate.js +154 -0
  98. package/lib/fluid/ideabox-ops.js +585 -0
  99. package/lib/fluid/ideabox-view.js +146 -0
  100. package/lib/fluid/import-ideabox.js +186 -0
  101. package/lib/fluid/local-provider.js +606 -0
  102. package/lib/fluid/provider.js +684 -0
  103. package/lib/fluid/record-shape.js +214 -0
  104. package/lib/fluid/record-store.js +328 -0
  105. package/lib/fluid/render-ideabox.js +261 -0
  106. package/lib/fluid/schema.js +40 -0
  107. package/lib/fluid/smartmemory-provider.js +1695 -0
  108. package/lib/gsd.js +63 -14
  109. package/lib/guard-cli.js +175 -0
  110. package/lib/guard-custody.js +141 -0
  111. package/lib/guard-descriptors.js +530 -0
  112. package/lib/guard-enrol.js +254 -0
  113. package/lib/health-score.js +1 -1
  114. package/lib/hooks-status.js +32 -3
  115. package/lib/ideabox-cli.js +315 -0
  116. package/lib/ideabox.js +121 -21
  117. package/lib/judgment/store/index.js +166 -0
  118. package/lib/judgment/store/records.js +184 -25
  119. package/lib/judgment/trace.js +380 -0
  120. package/lib/judgment-attest.js +259 -0
  121. package/lib/judgment-decision-write.js +277 -0
  122. package/lib/judgment-decisions.js +466 -0
  123. package/lib/judgment-gen.js +375 -22
  124. package/lib/judgment-verify.js +153 -0
  125. package/lib/judgment-writer.js +2842 -262
  126. package/lib/lane-gate.js +2 -0
  127. package/lib/lifecycle-modes.js +4 -4
  128. package/lib/lineage.js +400 -0
  129. package/lib/local-claude-connector.js +250 -54
  130. package/lib/maya-client.js +302 -0
  131. package/lib/maya-config.js +53 -0
  132. package/lib/maya-identity.js +283 -0
  133. package/lib/mcp-enforcement.js +21 -35
  134. package/lib/migrate-anon.js +5 -0
  135. package/lib/migrate-roadmap.js +15 -0
  136. package/lib/new.js +13 -1
  137. package/lib/pipeline-compat.js +104 -0
  138. package/lib/policy-catalog.js +295 -0
  139. package/lib/policy-check.js +0 -0
  140. package/lib/process-termination.js +98 -0
  141. package/lib/resolve-workspace.js +5 -1
  142. package/lib/result-normalizer.js +428 -153
  143. package/lib/review-normalize.js +4 -0
  144. package/lib/roadmap-errors.js +65 -0
  145. package/lib/roadmap-preservers.js +24 -4
  146. package/lib/roadmap-residue.js +299 -0
  147. package/lib/smartmemory-client.js +614 -78
  148. package/lib/smartmemory-config.js +54 -0
  149. package/lib/smartmemory-ingest.js +19 -2
  150. package/lib/step-prompt.js +7 -6
  151. package/lib/stratum-engine.js +53 -4
  152. package/lib/stratum-mcp-client.js +391 -31
  153. package/lib/test-bootstrap.js +31 -0
  154. package/lib/tool-inventory.js +122 -0
  155. package/lib/version-check.js +91 -19
  156. package/lib/vision-writer.js +88 -1
  157. package/package.json +7 -6
  158. package/pipelines/bug-fix.stratum.yaml +205 -211
  159. package/pipelines/build-quick.profiles.json +12 -0
  160. package/pipelines/build-quick.stratum.yaml +263 -350
  161. package/pipelines/content.stratum.yaml +81 -77
  162. package/pipelines/coverage-sweep.stratum.yaml +49 -30
  163. package/pipelines/plan.stratum.yaml +76 -86
  164. package/pipelines/refactor.stratum.yaml +125 -125
  165. package/pipelines/research.stratum.yaml +56 -58
  166. package/pipelines/review-fix.profiles.json +6 -0
  167. package/pipelines/review-fix.stratum.yaml +110 -83
  168. package/presets/team-feature.profiles.json +6 -0
  169. package/presets/team-feature.stratum.yaml +93 -66
  170. package/presets/team-research.profiles.json +6 -0
  171. package/presets/team-research.stratum.yaml +89 -80
  172. package/presets/team-review.profiles.json +8 -0
  173. package/presets/team-review.stratum.yaml +98 -80
  174. package/scripts/cost-census.mjs +70 -0
  175. package/scripts/guard-sign/compose-guard-sign.sh +62 -0
  176. package/server/agent-health.js +22 -0
  177. package/server/agent-hooks.js +14 -1
  178. package/server/agent-server.js +5 -248
  179. package/server/agent-spawn.js +3 -4
  180. package/server/agent-workspace.js +294 -0
  181. package/server/build-routes.js +6 -5
  182. package/server/build-stream-bridge.js +53 -0
  183. package/server/cc-session-watcher.js +4 -1
  184. package/server/coalescing-buffer.js +7 -1
  185. package/server/completion-projection.js +228 -0
  186. package/server/compose-mcp-tools.js +124 -24
  187. package/server/compose-mcp.js +91 -790
  188. package/server/decision-event-emit.js +41 -2
  189. package/server/decision-event-id.js +17 -0
  190. package/server/decision-events-snapshot.js +3 -0
  191. package/server/design-routes.js +14 -8
  192. package/server/feature-scan.js +76 -2
  193. package/server/file-watcher.js +170 -21
  194. package/server/ideabox-routes.js +166 -224
  195. package/server/index.js +70 -100
  196. package/server/lifecycle-guard.js +240 -10
  197. package/server/lifecycle-phase-history.js +276 -0
  198. package/server/maya-routes.js +507 -0
  199. package/server/mcp-tool-defs.js +940 -0
  200. package/server/mcp-tool-policy.js +35 -3
  201. package/server/model-tiers.js +22 -5
  202. package/server/pipeline-routes.js +21 -11
  203. package/server/project-root.js +58 -19
  204. package/server/remote-utils.js +3 -1
  205. package/server/schema-validator.js +7 -1
  206. package/server/session-manager.js +5 -6
  207. package/server/session-routes.js +3 -1
  208. package/server/stratum-client.js +57 -10
  209. package/server/stratum-sync.js +6 -3
  210. package/server/summarizer.js +3 -4
  211. package/server/supervisor.js +0 -1
  212. package/server/vision-routes.js +208 -98
  213. package/server/vision-server.js +86 -23
  214. package/server/vision-store.js +60 -6
  215. package/server/vision-utils.js +3 -4
  216. package/server/workspace-activity.js +18 -0
  217. package/server/workspace-middleware.js +2 -2
  218. package/server/workspace-runtime.js +243 -0
  219. package/server/worktree-gc.js +1 -0
  220. package/dist/assets/App-BG3ngu8H.js +0 -896
  221. package/dist/assets/abnfDiagram-VRR7QNED-CjB_sD3D.js +0 -1
  222. package/dist/assets/arc-_v4hR_uD.js +0 -1
  223. package/dist/assets/architectureDiagram-ZJ3FMSHR-DreJmzXQ.js +0 -36
  224. package/dist/assets/blockDiagram-677ZJIJ3-BG9-c0O1.js +0 -132
  225. package/dist/assets/channel-B3U5wFAT.js +0 -1
  226. package/dist/assets/chunk-EX3LRPZG-DdELs1qP.js +0 -231
  227. package/dist/assets/chunk-MOJQB5TN-D-ky35G-.js +0 -88
  228. package/dist/assets/chunk-RYQCIY6F-Dag_kVlO.js +0 -1
  229. package/dist/assets/chunk-V7JOEXUC-BtewURat.js +0 -206
  230. package/dist/assets/classDiagram-OUVF2IWQ-B6fCN-ht.js +0 -1
  231. package/dist/assets/classDiagram-v2-EOCWNBFH-B6fCN-ht.js +0 -1
  232. package/dist/assets/cynefin-VYW2F7L2-CT2BA6KE.js +0 -178
  233. package/dist/assets/cynefinDiagram-TSTJHNR4-Bh6exbyg.js +0 -62
  234. package/dist/assets/dagre-VKFMJZFB-aXMLSmQL.js +0 -4
  235. package/dist/assets/diagram-FQU43EPY-Dr7JAOuQ.js +0 -3
  236. package/dist/assets/diagram-G47NLZAW-DUvA3FQK.js +0 -24
  237. package/dist/assets/diagram-NH7WQ7WH-BQUARqcu.js +0 -24
  238. package/dist/assets/diagram-OA4YK3LP-dDUc1zHi.js +0 -30
  239. package/dist/assets/diagram-WEI45ONY-B2h5Qlb1.js +0 -41
  240. package/dist/assets/ebnfDiagram-CCIWWBDH-DThRGupB.js +0 -1
  241. package/dist/assets/erDiagram-Q63AITRT-BUCsprO2.js +0 -85
  242. package/dist/assets/flowDiagram-23GEKE2U-DXtNNi6r.js +0 -156
  243. package/dist/assets/ganttDiagram-NO4QXBWP-D4zbBHh_.js +0 -292
  244. package/dist/assets/gitGraphDiagram-IHSO6WYX-DpoQws0W.js +0 -106
  245. package/dist/assets/graph-BXPQrYYB.js +0 -331
  246. package/dist/assets/graph-C9eacEi8.js +0 -1
  247. package/dist/assets/index-3ZH5eMcZ.js +0 -119
  248. package/dist/assets/index-LIwREYgH.css +0 -1
  249. package/dist/assets/infoDiagram-FWYZ7A6U-Bbas2GAo.js +0 -2
  250. package/dist/assets/katex-C5jXJg4s.js +0 -257
  251. package/dist/assets/layout-DEXfKzaS.js +0 -1
  252. package/dist/assets/map-Czzmt4hB.js +0 -1
  253. package/dist/assets/mobile-CaoXUwAr.js +0 -17
  254. package/dist/assets/pegDiagram-2B236MQR-CHiINrNy.js +0 -1
  255. package/dist/assets/pieDiagram-ENE6RG2P-CfS4YFlR.js +0 -39
  256. package/dist/assets/quadrantDiagram-ABIIQ3AL-CadesS9w.js +0 -7
  257. package/dist/assets/railroadDiagram-RFXS5EU6-CgWEspBN.js +0 -1
  258. package/dist/assets/sankeyDiagram-HTMAVEWB-YWKFgOGw.js +0 -40
  259. package/dist/assets/sequenceDiagram-DBY2YBRQ-BvkNOyF9.js +0 -162
  260. package/dist/assets/sizeCapture-X5ZJPWSS-DlFPA2yO.js +0 -1
  261. package/dist/assets/stateDiagram-2N3HPSRC-h8NIx0kQ.js +0 -1
  262. package/dist/assets/stateDiagram-v2-6OUMAXLB-DjPgZtJ9.js +0 -1
  263. package/dist/assets/swimlanes-5IMT3BWC-CT5n22kG.js +0 -2
  264. package/dist/assets/swimlanesDiagram-G3AALYLV-Dn318Bhq.js +0 -8
  265. package/dist/assets/vennDiagram-L72KCM5P-Dj-wWLYG.js +0 -34
  266. package/dist/assets/wardleyDiagram-EHGQE667-BxCeYxkG.js +0 -78
  267. package/dist/assets/xychartDiagram-FW5EYKEG-DMFqWn7z.js +0 -7
  268. package/lib/staleness.js +0 -87
  269. package/server/ideabox-cache.js +0 -77
package/lib/build.js CHANGED
@@ -16,8 +16,12 @@ import { createHash, randomUUID } from 'node:crypto';
16
16
 
17
17
  import { StratumMcpClient, StratumError, resolvePlanSpecValues, resolveStepProfile } from './stratum-mcp-client.js';
18
18
  import { resolveStratumMcpConnection } from './stratum-engine.js';
19
- import { runAndNormalize, AgentTimeoutError, AgentAbortedError, UserInterruptError, AgentError } from './result-normalizer.js';
19
+ import { runAndNormalize, mergeUsage, AgentTimeoutError, AgentAbortedError, UserInterruptError, AgentError } from './result-normalizer.js';
20
20
  import { checkCapabilityViolation } from './capability-checker.js';
21
+ import { getCatalog as getPolicyCatalog, getPolicyCheckConfig } from './policy-catalog.js';
22
+ import {
23
+ resolveBuildUserMode, scanResponse, toViolationStrings, buildRevisionNotice, attachPolicyCount,
24
+ } from './policy-check.js';
21
25
  import { preflightCodexWorktreeProbe, codexProbeAbortMessage } from './codex-preflight.js';
22
26
  import { buildStepPrompt, buildGateContext, clearAmbientContextCache } from './step-prompt.js';
23
27
  import { promptGate } from './gate-prompt.js';
@@ -33,11 +37,12 @@ import { resolveAgentConfig, parseAgentString } from './agent-string.js';
33
37
  import { emitSections as emitPlanSections, appendTrailers as appendSectionTrailers, analyzeRollup, writeRollup } from './sections.js';
34
38
  import { SECTIONS_DIR } from './constants.js';
35
39
  import { rtkPrefix } from './rtk.js';
40
+ import { tsCompatibilityOf, quarantineMessage, INIT_PROVISIONED_SPECS } from './pipeline-compat.js';
36
41
 
37
42
  import YAML from 'yaml';
38
43
  // feature-json direct imports removed — mutations now go through TrackerProvider (T9)
39
44
  import { loadFeaturesDir, resolveContextPath, resolveRoadmapPath, resolveFeaturesPath } from './project-paths.js';
40
- import { getMode } from './lifecycle-modes.js';
45
+ import { getMode, resolveMode } from './lifecycle-modes.js';
41
46
  import { vocabularyEnabled, tagVocabularyViolations, VOCABULARY_FILE } from './vocabulary-inject.js';
42
47
  import { vocabularyCompliance } from './vocabulary-compliance.js';
43
48
 
@@ -54,7 +59,7 @@ import { applyFrontTriage, maybeEscalateLane } from './lane-gate.js';
54
59
  import { LENS_DEFINITIONS } from './review-lenses.js';
55
60
  import { injectCertInstructions } from './cert-inject.js';
56
61
  import { buildReviewPrompt } from './review-prompt.js';
57
- import { detectTestFramework, scaffoldTestFramework, parseTestSummary, deriveTestsPass, isTestFile } from './test-bootstrap.js';
62
+ import { detectTestFramework, scaffoldTestFramework, parseTestSummary, deriveTestsPass, deriveTestsAttested, isTestFile } from './test-bootstrap.js';
58
63
  import { classifyStepAsTier, evaluateTiers } from './gate-tiers.js';
59
64
  import { mapFilesToRoutes, classifyRoutes, isDocsOnlyDiff } from './qa-scoping.js';
60
65
  import { computeCompositeScore } from './health-score.js';
@@ -75,6 +80,8 @@ import {
75
80
  recoverAdvancedConsumerArtifacts,
76
81
  verifyConsumerRunRevision,
77
82
  } from './consumer-fanout.js';
83
+ import { appendEvent as appendDispatchEvent, readEvents as readDispatchEvents } from './dispatch-ledger.js';
84
+ import { appendEvent as appendFeatureEvent } from './feature-events.js';
78
85
 
79
86
  // ---------------------------------------------------------------------------
80
87
  // COMP-ROADMAP-PLAN S8: gate the `ship` interception by mode.
@@ -96,6 +103,85 @@ export function shouldInterceptShip(stepId, mode) {
96
103
  return stepId === 'ship' && mode !== 'plan';
97
104
  }
98
105
 
106
+ // ---------------------------------------------------------------------------
107
+ // COMP-POLICY-CHECK: pre-response policy check (adherence enforcement).
108
+ // ---------------------------------------------------------------------------
109
+
110
+ /**
111
+ * Does this step declare a gate? A gate step is SKILL_GATED by construction —
112
+ * asking the user for a decision is the point of the step, so policy matches on
113
+ * its response are suppressed rather than flagged.
114
+ *
115
+ * @param {object} spec the local pipeline spec
116
+ * @param {string} flowName active flow
117
+ * @param {string} stepId ready-step id (scoped ids resolve to their bare tail)
118
+ * @returns {boolean}
119
+ */
120
+ export function isGateStep(spec, flowName, stepId) {
121
+ const bare = String(stepId ?? '').split('/').pop();
122
+ const steps = spec?.flows?.[flowName]?.steps;
123
+ if (!Array.isArray(steps)) return false;
124
+ return steps.some(st => st?.id === bare && !!st.gate);
125
+ }
126
+
127
+ /**
128
+ * COMP-POLICY-CHECK-2/3: scan one step response against the local catalog.
129
+ * Total — a broken catalog or scan degrades to "no findings" with a WARNING and
130
+ * never fails the step.
131
+ *
132
+ * The user mode is EXPLICIT here, never inferred: a build has no user turns to
133
+ * classify (see `resolveBuildUserMode`). Config override → gate step → default.
134
+ *
135
+ * @param {{cwd: string, text: string, skillGated: boolean}} args
136
+ * @returns {{records: object[], violations: string[], userMode: string}}
137
+ */
138
+ export function policyScanForStep({ cwd, text, skillGated }) {
139
+ const empty = { records: [], violations: [], userMode: 'AUTONOMOUS' };
140
+ try {
141
+ const config = getPolicyCheckConfig(cwd);
142
+ const catalog = getPolicyCatalog(cwd);
143
+ if (catalog.length === 0) return empty;
144
+ const userMode = resolveBuildUserMode(config.userMode, { skillGated });
145
+ const records = scanResponse(text ?? '', catalog, userMode);
146
+ return { records, violations: toViolationStrings(records), userMode };
147
+ } catch (err) {
148
+ // eslint-disable-next-line no-console
149
+ console.warn(`[policy-check] scan skipped: ${err.message}`);
150
+ return empty;
151
+ }
152
+ }
153
+
154
+ /**
155
+ * COMP-POLICY-CHECK-5: trace every match (flagged AND suppressed) to the
156
+ * append-only feature-events bus — which syncs into SmartMemory, closing the
157
+ * measurement loop — plus the build stream for live cockpit visibility.
158
+ *
159
+ * @param {object} args
160
+ * @param {string} args.pass 'initial' | 'policy_revision'
161
+ */
162
+ export function recordPolicyScan({ cwd, streamWriter, stepId, records, userMode, featureCode, buildId, pass = 'initial' }) {
163
+ for (const record of records ?? []) {
164
+ try {
165
+ appendFeatureEvent(cwd, {
166
+ tool: 'policy_check',
167
+ build_id: buildId ?? null,
168
+ step_id: stepId,
169
+ rule: record.rule,
170
+ matched: record.matched,
171
+ suppressed: record.suppressed,
172
+ user_mode: userMode,
173
+ pass,
174
+ });
175
+ } catch (err) {
176
+ // eslint-disable-next-line no-console
177
+ console.warn(`[policy-check] trace append failed: ${err.message}`);
178
+ }
179
+ try {
180
+ streamWriter?.writePolicyViolation(stepId, record, userMode, featureCode ?? null, buildId ?? null);
181
+ } catch { /* stream emit is best-effort */ }
182
+ }
183
+ }
184
+
99
185
  // ---------------------------------------------------------------------------
100
186
  // COMP-ROADMAP-PLAN S5: ratify a plan-authored design instead of clobbering it.
101
187
  // ---------------------------------------------------------------------------
@@ -444,6 +530,39 @@ export function deriveOrdinaryReviewScaffold({ contractName = null, stepId = '',
444
530
  return { isReviewMain, isReduceMain, isReviewScaffoldMain: isReviewMain && !isReduceMain };
445
531
  }
446
532
 
533
+ // COMP-AGENT-LANES: one lane per parallel worker slot. Identity is
534
+ // flowId:stepId:itemIndex (stepId/itemIndex RECUR across builds, so flowId is
535
+ // load-bearing); version is the ordered tuple (generation, attempt) — the UI
536
+ // resets a lane on a higher version and rejects lower (stale) events. The
537
+ // label is the human mandate: the review lens id when the item is a review,
538
+ // else the step intent truncated.
539
+ const LANE_LABEL_MAX = 80;
540
+
541
+ export function buildLaneEnvelope(descriptor, flowId, { lens = null } = {}) {
542
+ const rawLabel = (typeof lens === 'string' && lens)
543
+ || (typeof descriptor?.do === 'string' && descriptor.do)
544
+ || String(descriptor?.id ?? '');
545
+ const label = rawLabel.length > LANE_LABEL_MAX
546
+ ? `${rawLabel.slice(0, LANE_LABEL_MAX - 1)}…`
547
+ : rawLabel;
548
+ return {
549
+ flowId,
550
+ stepId: descriptor.id,
551
+ itemIndex: descriptor.itemIndex,
552
+ generation: descriptor.generation ?? 0,
553
+ attempt: descriptor.attempt ?? 1,
554
+ label,
555
+ agent: descriptor.agent ?? 'claude',
556
+ };
557
+ }
558
+
559
+ function deriveConsumerLane(descriptor, flowId) {
560
+ const reviewOpts = deriveConsumerReviewOptions(descriptor);
561
+ return buildLaneEnvelope(descriptor, flowId, {
562
+ lens: reviewOpts.reviewMode ? reviewOpts.lens : null,
563
+ });
564
+ }
565
+
447
566
  export function deriveConsumerReviewOptions(descriptor) {
448
567
  const reviewMode = descriptor?.contract?.root === 'ReviewResult';
449
568
  const item = (descriptor?.item && typeof descriptor.item === 'object') ? descriptor.item : {};
@@ -563,6 +682,11 @@ function isStaleOrDuplicateConsumerReportError(error) {
563
682
  || /\bstep is not awaiting a client result\b/i.test(message);
564
683
  }
565
684
 
685
+ function responseReissuesStep(response, stepId) {
686
+ if (response?.status !== 'ready' || !Array.isArray(response.ready)) return false;
687
+ return response.ready.some((ready) => ready?.id === stepId && ready?.previousFailure);
688
+ }
689
+
566
690
  async function reportConsumerStepDone({
567
691
  descriptor,
568
692
  flowId,
@@ -614,6 +738,7 @@ async function reportConsumerStepDone({
614
738
  itemIndex: descriptor.itemIndex,
615
739
  stage: descriptor.stage,
616
740
  generation: descriptor.generation,
741
+ lane: deriveConsumerLane(descriptor, flowId),
617
742
  });
618
743
  return { response, skipped: true };
619
744
  }
@@ -780,6 +905,12 @@ export async function runConsumerIssuance({
780
905
  // contract the python parallel-dispatch path emitted — otherwise the fanout runs
781
906
  // invisibly and the parallel progress bar never appears.
782
907
  const parallelStepNum = `∥${descriptor.itemIndex}`;
908
+ // COMP-AGENT-LANES: the same lane envelope rides every lifecycle write for
909
+ // this item AND (via runAndNormalize opts) every relayed output write, so the
910
+ // cockpit can attribute each event to its worker slot.
911
+ const lane = buildLaneEnvelope(descriptor, flowId, {
912
+ lens: reviewOpts.reviewMode ? reviewOpts.lens : null,
913
+ });
783
914
  progress.stepStart(parallelStepNum, '?', descriptor.id);
784
915
  streamWriter.write({
785
916
  type: 'build_step_start',
@@ -794,6 +925,7 @@ export async function runConsumerIssuance({
794
925
  itemIndex: descriptor.itemIndex,
795
926
  stage: descriptor.stage,
796
927
  generation: descriptor.generation,
928
+ lane,
797
929
  });
798
930
 
799
931
  let mainResult;
@@ -803,27 +935,40 @@ export async function runConsumerIssuance({
803
935
  streamWriter,
804
936
  maxDurationMs,
805
937
  stratum,
938
+ lane,
806
939
  cwd: recovery.worktree,
940
+ sandboxMode: descriptor.policy?.isolation === 'worktree' ? 'workspace-write' : 'read-only',
807
941
  onAgentEvent,
808
942
  profile,
809
943
  reviewMode: reviewOpts.reviewMode,
810
944
  confidenceGate: reviewOpts.confidenceGate,
811
945
  lens: reviewOpts.lens,
812
- // V2/V3: the isolation:none review fanout is the safety-critical controlled
813
- // execution a claude review item runs via the compose-local connector so
814
- // its read-only tool restrictions actually BIND (the engine's sync
815
- // agent_run can't carry claude allowlists and its sandboxMode binds only
816
- // codex) and its per-item timeout / stuck abort truly INTERRUPTS it. Write
817
- // (worktree) items keep the engine seam: their per-item timeout still fails
818
- // the item, but interrupting an in-flight workspace-write run needs a
819
- // stratum follow-up (background mode is codex+read-only-only). Codex review
820
- // items also fall back to the sync seam (compose has no codex SDK).
946
+ telemetry: {
947
+ site: context.gsd ? 'gsd' : 'consumer',
948
+ project_cwd: context.projectCwd ?? context.cwd,
949
+ build_id: context.build_id,
950
+ feature_code: context.featureCode,
951
+ step_id: descriptor.id,
952
+ ...(typeof descriptor.attempt === 'number' ? { attempt: descriptor.attempt } : {}),
953
+ },
954
+ // Local Claude owns its SDK process group for review fanout and drains
955
+ // graceful teardown before timeout/interrupt returns, as MCP does.
821
956
  localExecution: descriptor.policy?.isolation === 'none',
822
957
  });
823
958
  } catch (error) {
824
- // User interrupts and injected crashes are control-flow signals, not item
825
- // failures they must propagate and abort the pump.
826
- if (error instanceof UserInterruptError || error?.code === 'INJECTED_CONSUMER_CRASH') {
959
+ const failedUsage = failureUsageFields(error);
960
+ // Control failures do not settle the item, so record known dispatch usage
961
+ // before aborting the pump. Never retry work with uncertain termination.
962
+ if (error instanceof UserInterruptError || ['INJECTED_CONSUMER_CRASH', 'CANCELLATION_UNCONFIRMED', 'CANCELLATION_TEARDOWN_TIMEOUT'].includes(error?.code)) {
963
+ if (failedUsage.usage && typeof context?.onUsage === 'function') {
964
+ try {
965
+ await context.onUsage(usagePayload(failedUsage.usage, failedUsage.usages), {
966
+ dispatchId: error.dispatchId, stepId: descriptor.step ?? descriptor.id, source: 'consumer',
967
+ });
968
+ } catch (usageError) {
969
+ console.warn(`[consumer] Could not record cancelled usage: ${usageError?.message ?? usageError}`);
970
+ }
971
+ }
827
972
  throw error;
828
973
  }
829
974
  // D3: a stuck verdict halts the whole GSD run (not a per-item retry) — the
@@ -832,8 +977,12 @@ export async function runConsumerIssuance({
832
977
  // G3: no step_done envelope is sent on the stuck/abort path (the run halts),
833
978
  // so the billable usage the aborted run consumed would be lost. Record it
834
979
  // into compose's cumulative ledger before converting to the stuck signal.
835
- if (error.usage && typeof context?.onUsage === 'function') {
836
- context.onUsage(error.usage, descriptor);
980
+ if (failedUsage.usage && typeof context?.onUsage === 'function') {
981
+ await context.onUsage(usagePayload(failedUsage.usage, failedUsage.usages), {
982
+ dispatchId: error.dispatchId,
983
+ stepId: descriptor.step ?? descriptor.id,
984
+ source: 'consumer',
985
+ });
837
986
  }
838
987
  throw new ConsumerStuckError(stuckTaskId, error.reason);
839
988
  }
@@ -841,9 +990,11 @@ export async function runConsumerIssuance({
841
990
  mainResult = {
842
991
  result: { outcome: 'failed', summary: `Timed out after ${Math.round(error.durationMs / 1000)}s` },
843
992
  normalizationFailure: error.message,
993
+ dispatchIds: { primary: error.dispatchId ?? null, repair: null },
994
+ settlementFailureClass: 'agent',
844
995
  // G3: a timed-out run still consumed billable usage — forward it so the
845
996
  // failure envelope debits the engine ledger (same mechanism as F3).
846
- ...(error && typeof error === 'object' && error.usage ? { usage: error.usage } : {}),
997
+ ...failedUsage,
847
998
  };
848
999
  } else {
849
1000
  // A non-timeout agent/connector error must fail ONLY this item, not abort
@@ -855,11 +1006,13 @@ export async function runConsumerIssuance({
855
1006
  mainResult = {
856
1007
  result: { outcome: 'failed', summary: `Agent error: ${reason}` },
857
1008
  normalizationFailure: reason,
1009
+ dispatchIds: { primary: error?.dispatchId ?? null, repair: null },
1010
+ settlementFailureClass: 'agent',
858
1011
  // F3: a failed run still consumed billable usage — the connector attaches
859
1012
  // it to the error. Forward it so the failure envelope (and compose's
860
1013
  // cumulative ledger) debit the attempt instead of letting failures evade
861
1014
  // budget exhaustion.
862
- ...(error && typeof error === 'object' && error.usage ? { usage: error.usage } : {}),
1015
+ ...failedUsage,
863
1016
  };
864
1017
  }
865
1018
  }
@@ -868,7 +1021,11 @@ export async function runConsumerIssuance({
868
1021
  // D2(b): forward the item's agent usage so GSD can debit the cumulative
869
1022
  // budget ledger. Build mode passes no onUsage sink → byte-identical no-op.
870
1023
  if (typeof context?.onUsage === 'function' && mainResult?.usage) {
871
- context.onUsage(mainResult.usage, descriptor);
1024
+ await context.onUsage(usagePayload(mainResult.usage, mainResult.usages), {
1025
+ dispatchId: mainResult.dispatchIds?.primary,
1026
+ stepId: descriptor.step ?? descriptor.id,
1027
+ source: 'fanout',
1028
+ });
872
1029
  }
873
1030
  const finalStage = isFinalConsumerStage(localSpec, descriptor);
874
1031
  let localFailure = normalizationFailure
@@ -899,7 +1056,7 @@ export async function runConsumerIssuance({
899
1056
  // succeeded, so usage rides both the success and failure envelope. Compose's
900
1057
  // cumulative ledger (context.onUsage) is separate, compose-side accounting.
901
1058
  const engineUsage = toEngineUsage(mainResult?.usage);
902
- if (engineUsage) envelope.usage = engineUsage;
1059
+ if (engineUsage && !context?.receiptsMode) envelope.usage = engineUsage;
903
1060
 
904
1061
  if (typeof artifacts.hooks.afterAgentMutationBeforePrepared === 'function') {
905
1062
  await artifacts.hooks.afterAgentMutationBeforePrepared({
@@ -936,6 +1093,17 @@ export async function runConsumerIssuance({
936
1093
  });
937
1094
  if (report.skipped) return report.response;
938
1095
  const { response } = report;
1096
+ if (!context.gsd && typeof context?.settleDispatches === 'function') {
1097
+ const isEnsureRetry = responseReissuesStep(response, descriptor.id);
1098
+ context.settleDispatches({
1099
+ stepId: descriptor.id,
1100
+ dispatchIds: mainResult.dispatchIds,
1101
+ accepted: !localFailure && !isEnsureRetry,
1102
+ failureClass: mainResult.settlementFailureClass
1103
+ ?? (normalizationFailure ? 'normalization' : (localFailure ? 'agent' : null)),
1104
+ isEnsureRetry,
1105
+ });
1106
+ }
939
1107
  if (typeof artifacts.hooks.afterStepDone === 'function') {
940
1108
  await artifacts.hooks.afterStepDone({ descriptor, envelope, response });
941
1109
  }
@@ -952,6 +1120,14 @@ export async function runConsumerIssuance({
952
1120
  // H6: matches the item's start stepId so the UI decrements the same task
953
1121
  // (AgentStream keys the per-task done on parallel:true + a known stepId).
954
1122
  parallel: true,
1123
+ // COMP-AGENT-LANES (C4): terminal status is explicit at source — the UI
1124
+ // must not infer "complete" from the done event's existence.
1125
+ status: localFailure ? 'failed' : 'succeeded',
1126
+ outcome: result?.outcome ?? (localFailure ? 'failed' : 'succeeded'),
1127
+ itemIndex: descriptor.itemIndex,
1128
+ stage: descriptor.stage,
1129
+ generation: descriptor.generation,
1130
+ lane,
955
1131
  });
956
1132
  return response;
957
1133
  }
@@ -1037,6 +1213,91 @@ export function toEngineUsage(usage) {
1037
1213
  return Object.keys(out).length > 0 ? out : null;
1038
1214
  }
1039
1215
 
1216
+ // A repair failure can carry two dispatch records. Keep those records for
1217
+ // receipts and aggregate both for the legacy step_done budget envelope.
1218
+ function failureUsageFields(error) {
1219
+ const usages = Array.isArray(error?.usages) ? error.usages : null;
1220
+ if (!usages?.length) return error?.usage ? { usage: error.usage } : {};
1221
+ if (usages.length === 1 && error?.usage) return { usage: error.usage, usages };
1222
+ const usage = {
1223
+ input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0,
1224
+ cache_read_input_tokens: 0, cost_usd: 0, duration_ms: 0, model: null,
1225
+ };
1226
+ for (const entry of usages) {
1227
+ if (!entry || typeof entry !== 'object') continue;
1228
+ usage.input_tokens += entry.input_tokens ?? 0;
1229
+ usage.output_tokens += entry.output_tokens ?? 0;
1230
+ usage.cache_creation_input_tokens += entry.cache_creation ?? entry.cache_creation_input_tokens ?? 0;
1231
+ usage.cache_read_input_tokens += entry.cache_read ?? entry.cache_read_input_tokens ?? 0;
1232
+ usage.cost_usd += entry.cost_usd ?? 0;
1233
+ usage.duration_ms += entry.duration_ms ?? 0;
1234
+ usage.model = entry.model ?? usage.model;
1235
+ }
1236
+ return { usage, usages };
1237
+ }
1238
+
1239
+ function usagePayload(usage, usages) {
1240
+ if (!usage || typeof usage !== 'object') return usage;
1241
+ return Array.isArray(usages) ? { ...usage, usages } : usage;
1242
+ }
1243
+
1244
+ /** Send one surface-15 receipt per underlying model dispatch. */
1245
+ export async function reportUsageReceipts(context, usage, meta = {}) {
1246
+ if (!context?.receiptsMode || !context.flowId || typeof context.stratum?.usageReport !== 'function') {
1247
+ return [];
1248
+ }
1249
+ const entries = Array.isArray(usage)
1250
+ ? usage
1251
+ : (Array.isArray(usage?.usages) ? usage.usages : (usage ? [usage] : []));
1252
+ const responses = [];
1253
+ for (const entry of entries) {
1254
+ if (!entry || typeof entry !== 'object') continue;
1255
+ const engineUsage = toEngineUsage(entry);
1256
+ if (!engineUsage) continue;
1257
+ // Surface 15 requires explicit USD provenance. Normalized UsageRecords carry
1258
+ // `usd_source`; raw engine usage ({tokens, usd, ms}) does not. Preserve raw
1259
+ // token/time usage, but fail closed on an unlabelled dollar value instead of
1260
+ // manufacturing "reported" provenance.
1261
+ const usdSource = ['reported', 'estimated'].includes(entry.usd_source)
1262
+ ? entry.usd_source
1263
+ : null;
1264
+ if (Object.hasOwn(engineUsage, 'usd') && !usdSource) delete engineUsage.usd;
1265
+ if (Object.keys(engineUsage).length === 0) continue;
1266
+ const input = entry.input_tokens;
1267
+ const output = entry.output_tokens;
1268
+ const receipt = {
1269
+ dispatchId: entry.dispatch_id ?? meta.dispatchId ?? randomUUID(),
1270
+ ...(meta.stepId ? { stepId: meta.stepId } : {}),
1271
+ source: meta.source ?? 'main',
1272
+ usage: engineUsage,
1273
+ telemetry: {
1274
+ model: typeof entry.model === 'string' && entry.model.length > 0 ? entry.model : 'unknown',
1275
+ ...(typeof entry.effort === 'string' && entry.effort.length > 0 ? { effort: entry.effort } : {}),
1276
+ durationMs: entry.duration_ms ?? entry.ms ?? 0,
1277
+ },
1278
+ ...(typeof input === 'number' || typeof output === 'number'
1279
+ ? { split: {
1280
+ input: input ?? 0,
1281
+ output: output ?? 0,
1282
+ ...(typeof (entry.cache_read ?? entry.cache_read_input_tokens) === 'number'
1283
+ ? { cacheRead: entry.cache_read ?? entry.cache_read_input_tokens }
1284
+ : {}),
1285
+ ...(typeof (entry.cache_creation ?? entry.cache_creation_input_tokens) === 'number'
1286
+ ? { cacheCreation: entry.cache_creation ?? entry.cache_creation_input_tokens }
1287
+ : {}),
1288
+ } }
1289
+ : {}),
1290
+ ...(Object.hasOwn(engineUsage, 'usd') ? { usdSource } : {}),
1291
+ };
1292
+ try {
1293
+ responses.push(await context.stratum.usageReport(context.flowId, receipt));
1294
+ } catch (error) {
1295
+ console.warn(`[usage-receipt] failed for ${receipt.dispatchId}: ${error?.message ?? error}`);
1296
+ }
1297
+ }
1298
+ return responses;
1299
+ }
1300
+
1040
1301
  /**
1041
1302
  * F5: deterministic v1 vocabulary enforcement, evaluated compose-side at the
1042
1303
  * review_merge step (the step the now-dropped judged ensure was attached to). The
@@ -1215,6 +1476,323 @@ function writeActiveBuild(dataDir, state) {
1215
1476
  renameSync(tmp, target);
1216
1477
  }
1217
1478
 
1479
+ // COMP-COMPLETION-GATE slice 2: v2 adds the completion evidence the gate needs
1480
+ // at terminalization — `tests_attested` (tri-state) and `evidence_root`.
1481
+ //
1482
+ // `test_count`/`pass_rate` were already here but are metrics, not attestation:
1483
+ // they are only populated when the output PARSED, so their absence is ambiguous
1484
+ // between "no tests" and "could not read the output". The gate cannot act on an
1485
+ // ambiguous signal, hence an explicit tri-state.
1486
+ //
1487
+ // `evidence_root` is persisted because a cross-repo build runs git and tests in
1488
+ // the agent's tree while feature metadata lives in the project tree — and
1489
+ // `runBuild` reconstructs that root from the CURRENT invocation, so a resumed
1490
+ // cross-repo build would otherwise fall back to the project root and verify the
1491
+ // wrong repository's HEAD.
1492
+ const BUILD_ACCUMULATOR_VERSION = 2;
1493
+ const BUILD_ACCUMULATOR_FIELDS = new Set([
1494
+ 'v',
1495
+ 'build_id',
1496
+ 'feature_code',
1497
+ 'last_terminal',
1498
+ 'review_iterations',
1499
+ 'escalations',
1500
+ 'files_changed',
1501
+ 'ship_files_changed',
1502
+ 'test_count',
1503
+ 'pass_rate',
1504
+ 'tests_attested',
1505
+ 'evidence_root',
1506
+ 'tokens_total',
1507
+ 'usd',
1508
+ ]);
1509
+ /** The only values `tests_attested` may hold. See deriveTestsAttested. */
1510
+ const TESTS_ATTESTED_VALUES = new Set(['passed', 'failed', 'no-signal']);
1511
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
1512
+
1513
+ function assertFeatureCodeForAccumulator(featureCode) {
1514
+ if (typeof featureCode !== 'string' || featureCode.length === 0 || /[/\\]/.test(featureCode)) {
1515
+ throw new Error('Build accumulator feature code must be a non-empty path-safe string');
1516
+ }
1517
+ }
1518
+
1519
+ export function buildAccumulatorPath(projectCwd, featureCode) {
1520
+ assertFeatureCodeForAccumulator(featureCode);
1521
+ return join(projectCwd, '.compose', 'data', 'build-accumulator', `${featureCode}.json`);
1522
+ }
1523
+
1524
+ function validateBuildAccumulator(value, expectedFeatureCode = null) {
1525
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
1526
+ throw new Error('Build accumulator is corrupt: expected an object');
1527
+ }
1528
+ for (const key of Object.keys(value)) {
1529
+ if (!BUILD_ACCUMULATOR_FIELDS.has(key)) {
1530
+ throw new Error(`Build accumulator is corrupt: unknown field "${key}"`);
1531
+ }
1532
+ }
1533
+ for (const key of BUILD_ACCUMULATOR_FIELDS) {
1534
+ if (!Object.hasOwn(value, key)) {
1535
+ throw new Error(`Build accumulator is corrupt: missing field "${key}"`);
1536
+ }
1537
+ }
1538
+ if (value.v !== BUILD_ACCUMULATOR_VERSION) {
1539
+ throw new Error(`Build accumulator is corrupt: unsupported version ${value.v}`);
1540
+ }
1541
+ if (typeof value.build_id !== 'string' || !UUID_RE.test(value.build_id)) {
1542
+ throw new Error('Build accumulator is corrupt: build_id must be a UUID');
1543
+ }
1544
+ if (typeof value.feature_code !== 'string' || value.feature_code.length === 0) {
1545
+ throw new Error('Build accumulator is corrupt: feature_code must be a non-empty string');
1546
+ }
1547
+ if (expectedFeatureCode !== null && value.feature_code !== expectedFeatureCode) {
1548
+ throw new Error(
1549
+ `Build accumulator feature identity mismatch: expected ${expectedFeatureCode}, found ${value.feature_code}`,
1550
+ );
1551
+ }
1552
+ if (![null, 'failed', 'complete', 'aborted'].includes(value.last_terminal)) {
1553
+ throw new Error('Build accumulator is corrupt: invalid last_terminal');
1554
+ }
1555
+ for (const key of ['review_iterations', 'escalations']) {
1556
+ if (!Number.isInteger(value[key]) || value[key] < 0) {
1557
+ throw new Error(`Build accumulator is corrupt: ${key} must be a non-negative integer`);
1558
+ }
1559
+ }
1560
+ if (!Array.isArray(value.files_changed) || !value.files_changed.every((file) => typeof file === 'string')) {
1561
+ throw new Error('Build accumulator is corrupt: files_changed must be a string array');
1562
+ }
1563
+ if (value.ship_files_changed !== null
1564
+ && (!Array.isArray(value.ship_files_changed)
1565
+ || !value.ship_files_changed.every((file) => typeof file === 'string'))) {
1566
+ throw new Error('Build accumulator is corrupt: ship_files_changed must be null or a string array');
1567
+ }
1568
+ for (const key of ['test_count', 'pass_rate']) {
1569
+ if (value[key] !== null && (typeof value[key] !== 'number' || !Number.isFinite(value[key]))) {
1570
+ throw new Error(`Build accumulator is corrupt: ${key} must be null or a finite number`);
1571
+ }
1572
+ }
1573
+ for (const key of ['tokens_total', 'usd']) {
1574
+ if (typeof value[key] !== 'number' || !Number.isFinite(value[key]) || value[key] < 0) {
1575
+ throw new Error(`Build accumulator is corrupt: ${key} must be a non-negative finite number`);
1576
+ }
1577
+ }
1578
+ if (!TESTS_ATTESTED_VALUES.has(value.tests_attested)) {
1579
+ throw new Error(
1580
+ `Build accumulator is corrupt: tests_attested must be one of ${[...TESTS_ATTESTED_VALUES].join('|')}`,
1581
+ );
1582
+ }
1583
+ if (value.evidence_root !== null && typeof value.evidence_root !== 'string') {
1584
+ throw new Error('Build accumulator is corrupt: evidence_root must be null or a string');
1585
+ }
1586
+ return value;
1587
+ }
1588
+
1589
+ /**
1590
+ * Bring a v1 accumulator forward. A v1 record predates completion evidence, so
1591
+ * it cannot say anything about whether tests were attested — and the honest value
1592
+ * for "we do not know" is `no-signal`, which the completion gate REFUSES. A build
1593
+ * resumed across this upgrade therefore has to re-attest rather than inheriting a
1594
+ * pass it never recorded. That is the intended direction: absence of signal is
1595
+ * never attestation.
1596
+ */
1597
+ function migrateBuildAccumulator(value) {
1598
+ if (!value || typeof value !== 'object' || value.v !== 1) return value;
1599
+ return {
1600
+ ...value,
1601
+ v: BUILD_ACCUMULATOR_VERSION,
1602
+ tests_attested: 'no-signal',
1603
+ evidence_root: null,
1604
+ };
1605
+ }
1606
+
1607
+ export function readBuildAccumulator(projectCwd, featureCode) {
1608
+ const path = buildAccumulatorPath(projectCwd, featureCode);
1609
+ if (!existsSync(path)) return null;
1610
+ let parsed;
1611
+ try {
1612
+ parsed = JSON.parse(readFileSync(path, 'utf8'));
1613
+ } catch (error) {
1614
+ throw new Error(`Build accumulator is corrupt at ${path}: ${error.message}`);
1615
+ }
1616
+ return validateBuildAccumulator(migrateBuildAccumulator(parsed), featureCode);
1617
+ }
1618
+
1619
+ export function writeBuildAccumulator(projectCwd, accumulator) {
1620
+ validateBuildAccumulator(accumulator, accumulator?.feature_code ?? null);
1621
+ const target = buildAccumulatorPath(projectCwd, accumulator.feature_code);
1622
+ mkdirSync(dirname(target), { recursive: true });
1623
+ const tmp = `${target}.tmp`;
1624
+ writeFileSync(tmp, JSON.stringify(accumulator, null, 2));
1625
+ renameSync(tmp, target);
1626
+ return accumulator;
1627
+ }
1628
+
1629
+ export function clearBuildAccumulator(projectCwd, featureCode) {
1630
+ const path = buildAccumulatorPath(projectCwd, featureCode);
1631
+ if (existsSync(path)) unlinkSync(path);
1632
+ }
1633
+
1634
+ export function newBuildAccumulatorRecord(featureCode) {
1635
+ return {
1636
+ v: BUILD_ACCUMULATOR_VERSION,
1637
+ build_id: randomUUID(),
1638
+ feature_code: featureCode,
1639
+ last_terminal: null,
1640
+ review_iterations: 0,
1641
+ escalations: 0,
1642
+ files_changed: [],
1643
+ ship_files_changed: null,
1644
+ test_count: null,
1645
+ pass_rate: null,
1646
+ tests_attested: 'no-signal',
1647
+ evidence_root: null,
1648
+ tokens_total: 0,
1649
+ usd: 0,
1650
+ };
1651
+ }
1652
+
1653
+ export function createBuildAccumulator(projectCwd, featureCode) {
1654
+ return writeBuildAccumulator(projectCwd, newBuildAccumulatorRecord(featureCode));
1655
+ }
1656
+
1657
+ export function updateBuildAccumulator(projectCwd, featureCode, mutate) {
1658
+ const current = readBuildAccumulator(projectCwd, featureCode);
1659
+ if (!current) {
1660
+ throw new Error(`Build accumulator not found for ${featureCode}`);
1661
+ }
1662
+ const next = typeof mutate === 'function' ? mutate({ ...current }) : { ...current, ...mutate };
1663
+ validateBuildAccumulator(next, featureCode);
1664
+ return writeBuildAccumulator(projectCwd, next);
1665
+ }
1666
+
1667
+ export function selectBuildAccumulator(projectCwd, featureCode, { fresh = false } = {}) {
1668
+ const existing = readBuildAccumulator(projectCwd, featureCode);
1669
+ if (fresh || !existing || ['complete', 'aborted'].includes(existing.last_terminal)) {
1670
+ // Candidate only — NOTHING persists until the caller owns the attempt. A
1671
+ // concurrent live build may refuse this invocation, and persisting here
1672
+ // (e.g. on --fresh) would clobber the live build's sidecar mid-run.
1673
+ return { accumulator: newBuildAccumulatorRecord(featureCode), isNew: true };
1674
+ }
1675
+ return { accumulator: existing, isNew: false };
1676
+ }
1677
+
1678
+ function ledgerEstimateSource(source) {
1679
+ if (source === 'escalated') return 'escalated';
1680
+ if (source === 'cached') return 'cached';
1681
+ if (source === 'front' || source === 'refined' || source === 'fresh') return 'fresh';
1682
+ throw new Error(`Unsupported triage estimate source "${source}"`);
1683
+ }
1684
+
1685
+ export function emitTriageEstimate(projectCwd, estimate) {
1686
+ return appendDispatchEvent(projectCwd, {
1687
+ kind: 'triage-estimate',
1688
+ build_id: estimate.build_id,
1689
+ feature_code: estimate.feature_code,
1690
+ tier: estimate.triageTier ?? estimate.tier,
1691
+ lane: estimate.lane,
1692
+ profile: estimate.profile,
1693
+ estimate_source: ledgerEstimateSource(estimate.estimateSource ?? estimate.estimate_source),
1694
+ confidence: estimate.triageConfidence ?? estimate.confidence ?? null,
1695
+ });
1696
+ }
1697
+
1698
+ export function emitBuildActuals(projectCwd, accumulator, terminalStatus) {
1699
+ validateBuildAccumulator(accumulator, accumulator?.feature_code ?? null);
1700
+ const persisted = readBuildAccumulator(projectCwd, accumulator.feature_code);
1701
+ if (!persisted || persisted.build_id !== accumulator.build_id) {
1702
+ throw new Error(`Build accumulator identity mismatch while finalizing ${accumulator.feature_code}`);
1703
+ }
1704
+ // COMP-TRIAGE-6-4: the ledger row is the durable, ACRR-consumed artifact —
1705
+ // append it FIRST, then do the best-effort accumulator marker/clear. The row
1706
+ // fields are all read from `persisted` (none depends on last_terminal), so
1707
+ // this is byte-identical on the happy path. Row-first makes the two writes
1708
+ // effectively atomic for every terminalization path (fresh, --fresh, resume,
1709
+ // fresh-over-failed): a crash between them can leave a stale marker/sidecar
1710
+ // (harmless — rotated away on the next build) but can NEVER lose a row or
1711
+ // strand a `last_terminal='failed'` marker with no row behind it. Since
1712
+ // emitBuildActuals is the ONLY writer of `last_terminal='failed'`, no other
1713
+ // path can produce that orphan either.
1714
+ const authoritativeShip = Array.isArray(persisted.ship_files_changed);
1715
+ const files = authoritativeShip
1716
+ ? persisted.ship_files_changed
1717
+ : persisted.files_changed;
1718
+ const row = appendDispatchEvent(projectCwd, {
1719
+ kind: 'build-actuals',
1720
+ build_id: persisted.build_id,
1721
+ feature_code: persisted.feature_code,
1722
+ terminal_status: terminalStatus,
1723
+ files_changed_count: new Set(files).size,
1724
+ files_source: authoritativeShip ? 'ship' : 'accumulated',
1725
+ review_iterations: persisted.review_iterations,
1726
+ escalations: persisted.escalations,
1727
+ tokens_total: persisted.tokens_total,
1728
+ usd: persisted.usd,
1729
+ test_count: persisted.test_count,
1730
+ pass_rate: persisted.pass_rate,
1731
+ });
1732
+ if (terminalStatus === 'failed') {
1733
+ updateBuildAccumulator(projectCwd, persisted.feature_code, (current) => ({
1734
+ ...current,
1735
+ last_terminal: 'failed',
1736
+ }));
1737
+ } else if (terminalStatus === 'complete' || terminalStatus === 'aborted') {
1738
+ clearBuildAccumulator(projectCwd, persisted.feature_code);
1739
+ }
1740
+ return row;
1741
+ }
1742
+
1743
+ export function settleDispatches(projectCwd, buildId, stepId, {
1744
+ dispatchIds,
1745
+ accepted,
1746
+ failureClass = null,
1747
+ isEnsureRetry = false,
1748
+ gsd = false,
1749
+ } = {}) {
1750
+ if (gsd) return [];
1751
+ const primary = dispatchIds?.primary;
1752
+ const repair = dispatchIds?.repair;
1753
+ // COMP-POLICY-CHECK-4: a policy revision is a second dispatch whose output
1754
+ // replaced the primary's. It settles on the same verdict as the run it
1755
+ // replaced — same path, one more id, no parallel settlement loop.
1756
+ const revision = dispatchIds?.revision;
1757
+ if (!primary && !repair && !revision) return [];
1758
+ const rows = [];
1759
+ const appendSettlement = (dispatchId, isAccepted, rejectedClass) => {
1760
+ if (typeof dispatchId !== 'string' || dispatchId.length === 0) return;
1761
+ rows.push(appendDispatchEvent(projectCwd, {
1762
+ kind: 'settlement',
1763
+ dispatch_id: dispatchId,
1764
+ accepted: isAccepted,
1765
+ ...(typeof buildId === 'string' && buildId.length > 0 ? { build_id: buildId } : {}),
1766
+ ...(typeof stepId === 'string' && stepId.length > 0 ? { step_id: stepId } : {}),
1767
+ ...(!isAccepted ? { failure_class: rejectedClass ?? 'agent' } : {}),
1768
+ }));
1769
+ };
1770
+
1771
+ if (repair) {
1772
+ appendSettlement(primary, false, 'normalization');
1773
+ appendSettlement(
1774
+ repair,
1775
+ isEnsureRetry ? false : accepted === true,
1776
+ isEnsureRetry ? 'ensure-retry' : failureClass,
1777
+ );
1778
+ } else {
1779
+ appendSettlement(
1780
+ primary,
1781
+ isEnsureRetry ? false : accepted === true,
1782
+ isEnsureRetry ? 'ensure-retry' : failureClass,
1783
+ );
1784
+ }
1785
+
1786
+ if (revision) {
1787
+ appendSettlement(
1788
+ revision,
1789
+ isEnsureRetry ? false : accepted === true,
1790
+ isEnsureRetry ? 'ensure-retry' : failureClass,
1791
+ );
1792
+ }
1793
+ return rows;
1794
+ }
1795
+
1218
1796
  /**
1219
1797
  * Decide how a compose build invocation should start.
1220
1798
  *
@@ -1379,10 +1957,12 @@ function isProcessAlive(pid) {
1379
1957
  * @param {object} gateDispatch - Stratum gate dispatch (step_id, on_approve, on_revise, on_kill)
1380
1958
  * @param {object} [gateExtras] - Optional enrichment (fromPhase, toPhase, summary)
1381
1959
  */
1382
- function makeAskAgent(stratum, context, gateDispatch, gateExtras) {
1960
+ export function makeAskAgent(stratum, context, gateDispatch, gateExtras) {
1383
1961
  const preamble = buildGateContext(gateDispatch, context, gateExtras);
1962
+ let budgetExhausted = false;
1384
1963
 
1385
1964
  return async function askAgent(question, artifactPath) {
1965
+ if (budgetExhausted) return '(budget exhausted)';
1386
1966
  const fileRef = artifactPath && !artifactPath.endsWith('/')
1387
1967
  ? `Read the file "${artifactPath}" and answer`
1388
1968
  : `Look at the project files in the working directory and answer`;
@@ -1391,7 +1971,26 @@ function makeAskAgent(stratum, context, gateDispatch, gateExtras) {
1391
1971
  `${fileRef} this question concisely:\n\n` +
1392
1972
  `${question}\n\n` +
1393
1973
  `Keep your answer brief — 2-3 sentences max.`;
1394
- const text = await stratum.runAgentText('claude', qaPrompt, { cwd: context.cwd });
1974
+ const text = await stratum.runAgentText('claude', qaPrompt, {
1975
+ cwd: context.cwd,
1976
+ telemetry: {
1977
+ site: 'gate-qa',
1978
+ project_cwd: context.projectCwd ?? context.cwd,
1979
+ build_id: context.build_id,
1980
+ feature_code: context.featureCode,
1981
+ step_id: gateDispatch.step_id ?? gateDispatch.id,
1982
+ ...(typeof gateDispatch.attempt === 'number' ? { attempt: gateDispatch.attempt } : {}),
1983
+ },
1984
+ onUsage: async (usages) => {
1985
+ const results = await context.recordBuildUsage?.(usages, {
1986
+ stepId: gateDispatch.step_id ?? gateDispatch.id,
1987
+ source: 'gate_qa',
1988
+ });
1989
+ if (results?.some((result) => ['flow_exhausted', 'flow_exhausted_after_terminal'].includes(result?.budget))) {
1990
+ budgetExhausted = true;
1991
+ }
1992
+ },
1993
+ });
1395
1994
  return text || '(no answer)';
1396
1995
  };
1397
1996
  }
@@ -1441,6 +2040,22 @@ export function resolveTemplatePath(name, cwd) {
1441
2040
  const presetsPath = join(packageDir, '..', 'presets', `${templateName}.stratum.yaml`);
1442
2041
  if (existsSync(presetsPath)) return presetsPath;
1443
2042
 
2043
+ // COMP-PIPELINE-QUARANTINE follow-up: fall back to the BUNDLED pipelines too,
2044
+ // not just presets. `compose init` seeds a curated few specs, so every other
2045
+ // shipped pipeline (content, coverage-sweep, refactor, research, review-fix)
2046
+ // was unreachable from a workspace no matter how it was invoked — the resolver
2047
+ // simply had no path to them. Project-local still wins, so a workspace that
2048
+ // customizes a spec keeps its own copy.
2049
+ //
2050
+ // NOT for the init-provisioned specs: if `build` is missing, the workspace was
2051
+ // never initialized, and answering with our bundled copy would silently run
2052
+ // Compose's own pipeline against an uninitialized project instead of raising
2053
+ // "Lifecycle spec not found".
2054
+ if (!INIT_PROVISIONED_SPECS.includes(templateName)) {
2055
+ const bundledPath = join(packageDir, '..', 'pipelines', `${templateName}.stratum.yaml`);
2056
+ if (existsSync(bundledPath)) return bundledPath;
2057
+ }
2058
+
1444
2059
  return projectPath;
1445
2060
  }
1446
2061
 
@@ -1638,6 +2253,87 @@ export async function runBuild(featureCode, opts = {}) {
1638
2253
  return;
1639
2254
  }
1640
2255
 
2256
+ const {
2257
+ accumulator: selectedAccumulator,
2258
+ isNew: isNewAccumulator,
2259
+ } = selectBuildAccumulator(cwd, featureCode, { fresh: opts.fresh === true });
2260
+ let build_id = selectedAccumulator.build_id;
2261
+ // Tracks the record this attempt actually owns: the reused sidecar, the
2262
+ // in-memory candidate (persisted at the ownership point after the start
2263
+ // verdict), or the rotated record on a fresh-over-failed start.
2264
+ let activeAccumulator = selectedAccumulator;
2265
+ let accumulatorPersisted = !isNewAccumulator;
2266
+ const buildStartedAt = new Date().toISOString();
2267
+ let buildStatus = 'failed';
2268
+ let attemptStarted = true;
2269
+ let attemptFinalized = false;
2270
+ // COMP-TRIAGE-6-4: function-scoped so finalizeBuildAttempt's ownership guard
2271
+ // can read them (they are assigned/read from inside the main try below).
2272
+ // accumulatorRotated flips true once a fresh-over-failed retry rotates to a
2273
+ // new identity; isFreshStart defaults true until a resume verdict flips it.
2274
+ let accumulatorRotated = false;
2275
+ let isFreshStart = true;
2276
+ let progress = null;
2277
+ let stratum = null;
2278
+ let streamWriter = null;
2279
+ let signalHandler = null;
2280
+ let runtimeResourcesFinalized = false;
2281
+
2282
+ const _priorBuildIdEnv = process.env.COMPOSE_BUILD_ID;
2283
+ if (_priorBuildIdEnv !== undefined) {
2284
+ // eslint-disable-next-line no-console
2285
+ console.warn(
2286
+ `[build] COMPOSE_BUILD_ID was already set ("${_priorBuildIdEnv}") when runBuild started. ` +
2287
+ `Overriding for this build; concurrent in-process builds will mis-stamp events.`
2288
+ );
2289
+ }
2290
+ process.env.COMPOSE_BUILD_ID = build_id;
2291
+ const _restoreBuildIdEnv = () => {
2292
+ if (_priorBuildIdEnv === undefined) delete process.env.COMPOSE_BUILD_ID;
2293
+ else process.env.COMPOSE_BUILD_ID = _priorBuildIdEnv;
2294
+ };
2295
+ const finalizeBuildAttempt = () => {
2296
+ if (!attemptStarted || attemptFinalized) return;
2297
+ const terminalStatus = buildStatus === 'complete'
2298
+ ? 'complete'
2299
+ : (buildStatus === 'killed' || buildStatus === 'aborted')
2300
+ ? 'aborted'
2301
+ : 'failed';
2302
+ let accumulator = readBuildAccumulator(cwd, featureCode);
2303
+ // A pre-ownership failure (e.g. missing lifecycle spec) dies before the
2304
+ // candidate persists — write it now so the attempt still leaves a terminal
2305
+ // actuals row. The build_id guard keeps a stale on-disk record from another
2306
+ // identity from being finalized under this attempt's name.
2307
+ if ((!accumulator || accumulator.build_id !== build_id) && !accumulatorPersisted) {
2308
+ accumulator = writeBuildAccumulator(cwd, activeAccumulator);
2309
+ accumulatorPersisted = true;
2310
+ }
2311
+ // A terminal complete/abort may already have cleared the sidecar if a nested
2312
+ // terminalization path finalized first. The finalized flag is the primary
2313
+ // guard; the missing-file/identity guard keeps cleanup idempotent.
2314
+ //
2315
+ // COMP-TRIAGE-6-4 ownership guard: a fresh (non-resume), not-yet-rotated
2316
+ // attempt that reuses a prior record must not re-emit a terminal row that
2317
+ // ALREADY EXISTS for this build_id. Without it, an attempt that dies before
2318
+ // rotating to a fresh identity (rotateStaleAccumulatorForFreshStart) — e.g.
2319
+ // the flow-audit probe throws while resolving the fresh/resume verdict —
2320
+ // re-finalizes the reused failed record, double-counting in ACRR. The signal
2321
+ // is the LEDGER itself, not the reused record's last_terminal: emitBuildActuals
2322
+ // writes last_terminal and the row non-atomically, so a crash between them
2323
+ // must cause neither a wrongful suppression (marker set, no row) nor a
2324
+ // wrongful double-emit (row written, marker cleared). A genuine resume
2325
+ // (isFreshStart=false) owns and continues the build (its second row under the
2326
+ // same id is intended); a rotated/new identity owns a fresh, unemitted id.
2327
+ const mayReemitReused = !isNewAccumulator && !accumulatorRotated && isFreshStart;
2328
+ const alreadyEmitted = mayReemitReused
2329
+ && readDispatchEvents(cwd, { kind: 'build-actuals' }).some((r) => r.build_id === build_id);
2330
+ if (accumulator && accumulator.build_id === build_id && !alreadyEmitted) {
2331
+ emitBuildActuals(cwd, accumulator, terminalStatus);
2332
+ }
2333
+ attemptFinalized = true;
2334
+ };
2335
+
2336
+ try {
1641
2337
  // Single resolver — used at every site that previously hardcoded
1642
2338
  // `docs/features/<featureCode>/`. Callers must use this (not inline
1643
2339
  // string concatenation) so the bug-mode path stays in sync.
@@ -1660,29 +2356,6 @@ export async function runBuild(featureCode, opts = {}) {
1660
2356
  ? join(resolveFeaturesPath(cwd), code)
1661
2357
  : join(cwd, ...cfg.artifactRoot.split('/'), code);
1662
2358
 
1663
- // COMP-MCP-MIGRATION-1: per-build correlation ID stamped onto every audit
1664
- // row written during this run, so `executeShipStep`'s pre-stage scan can
1665
- // tell agent-emitted typed-tool events apart from anything else.
1666
- const build_id = randomUUID();
1667
- const buildStartedAt = new Date().toISOString();
1668
- const _priorBuildIdEnv = process.env.COMPOSE_BUILD_ID;
1669
- if (_priorBuildIdEnv !== undefined) {
1670
- // Concurrent in-process builds are not currently supported — env-based
1671
- // stamping cannot tell them apart. Warn loudly so the surrounding
1672
- // process tree picks it up; do not throw, since the prior env value
1673
- // may simply be inherited from the parent (CI, harness) and harmless.
1674
- // eslint-disable-next-line no-console
1675
- console.warn(
1676
- `[build] COMPOSE_BUILD_ID was already set ("${_priorBuildIdEnv}") when runBuild started. ` +
1677
- `Overriding for this build; concurrent in-process builds will mis-stamp events.`
1678
- );
1679
- }
1680
- process.env.COMPOSE_BUILD_ID = build_id;
1681
- const _restoreBuildIdEnv = () => {
1682
- if (_priorBuildIdEnv === undefined) delete process.env.COMPOSE_BUILD_ID;
1683
- else process.env.COMPOSE_BUILD_ID = _priorBuildIdEnv;
1684
- };
1685
-
1686
2359
  const featureDir = resolveItemDir(featureCode);
1687
2360
 
1688
2361
  // Debug discipline (COMP-DEBUG-1)
@@ -1732,6 +2405,7 @@ export async function runBuild(featureCode, opts = {}) {
1732
2405
  // ---------------------------------------------------------------------------
1733
2406
  let buildProfile = null;
1734
2407
  let _buildTierLabel = '?'; // for skip_reason label in spec YAML mutation below
2408
+ let triageEstimate = null;
1735
2409
  // Only modes that run feature triage do so — triage is feature-shaped (writes
1736
2410
  // feature.json, profile selection per complexity tiers). bug AND plan skip it.
1737
2411
  if (cfg.runsTriage && !opts.skipTriage && !opts.template) {
@@ -1746,6 +2420,15 @@ export async function runBuild(featureCode, opts = {}) {
1746
2420
  // Reuse cached profile
1747
2421
  buildProfile = cachedFeature.profile;
1748
2422
  _buildTierLabel = cachedFeature.complexity ?? '?';
2423
+ triageEstimate = {
2424
+ build_id,
2425
+ feature_code: featureCode,
2426
+ triageTier: cachedFeature.triageTier,
2427
+ lane: cachedFeature.lane,
2428
+ profile: cachedFeature.profile,
2429
+ estimateSource: _escalated ? 'escalated' : 'cached',
2430
+ triageConfidence: cachedFeature.triageConfidence ?? null,
2431
+ };
1749
2432
  console.log(`[triage] Using ${_escalated ? 'escalated' : 'cached'} profile (tier ${_buildTierLabel}, lane ${cachedFeature.lane ?? '?'}): ${JSON.stringify(buildProfile)}`);
1750
2433
  } else {
1751
2434
  // COMP-TRIAGE-5 (E3 Estimate): derive the lane from the RAW REQUEST before
@@ -1763,10 +2446,21 @@ export async function runBuild(featureCode, opts = {}) {
1763
2446
  buildProfile = front.buildProfile;
1764
2447
  _buildTierLabel = front.tierLabel;
1765
2448
  cachedFeature = front.cachedFeature;
2449
+ triageEstimate = {
2450
+ build_id,
2451
+ feature_code: featureCode,
2452
+ triageTier: front.tier,
2453
+ lane: front.lane,
2454
+ profile: front.buildProfile,
2455
+ estimateSource: 'fresh',
2456
+ triageConfidence: front.confidence ?? null,
2457
+ };
1766
2458
  console.log(`[triage] Front estimate lane=${front.lane} (tier ${front.tier}): ${front.rationale}`);
1767
2459
  console.log(`[triage] Profile: ${JSON.stringify(buildProfile)}`);
1768
2460
  }
1769
2461
  }
2462
+ // Estimate emission is DEFERRED to the post-verdict ownership point: a
2463
+ // refused/errored invocation must leave no orphan estimate (review r2).
1770
2464
 
1771
2465
  // Load lifecycle spec (template selection). The mode's defaultTemplate is the
1772
2466
  // fallback when no explicit --template is given (build → 'build', byte-identical
@@ -1782,6 +2476,15 @@ export async function runBuild(featureCode, opts = {}) {
1782
2476
  const stepProfiles = loadPipelineProfiles(specPath);
1783
2477
  let specYaml = readFileSync(specPath, 'utf-8');
1784
2478
 
2479
+ // COMP-PIPELINE-QUARANTINE: refuse a retired-dialect spec HERE, at the one
2480
+ // seam every template passes through (build, fix, plan, --quick, --template,
2481
+ // bundled presets), rather than letting the engine answer with a bare
2482
+ // `-32602: spec validation failed` that names neither the file nor the cause.
2483
+ const specCompat = tsCompatibilityOf(specYaml);
2484
+ if (!specCompat.compatible) {
2485
+ throw new Error(quarantineMessage(specPath, specCompat));
2486
+ }
2487
+
1785
2488
  // STRAT-IMMUTABLE: hash the on-disk spec BEFORE triage mutation for tamper detection.
1786
2489
  // verifyPipelineIntegrity() re-reads from disk, so we must compare against the original file content.
1787
2490
  const specFileHash = _sha256(specYaml);
@@ -1883,11 +2586,14 @@ export async function runBuild(featureCode, opts = {}) {
1883
2586
  }
1884
2587
 
1885
2588
  // CLI progress renderer
1886
- const progress = new CliProgress();
2589
+ progress = new CliProgress();
1887
2590
 
1888
2591
  // Stratum MCP client (test override permitted via opts.stratum)
1889
- const stratum = opts.stratum ?? new StratumMcpClient();
2592
+ stratum = opts.stratum ?? new StratumMcpClient();
1890
2593
  if (!opts.stratum) await stratum.connect(resolveStratumMcpConnection(cwd));
2594
+ const receiptsMode = typeof stratum.hasTool === 'function'
2595
+ ? await stratum.hasTool('stratum_usage_report')
2596
+ : false;
1891
2597
 
1892
2598
  // Update feature.json status to IN_PROGRESS (only modes that track
1893
2599
  // feature.json lifecycle status; bug AND plan do not).
@@ -1905,8 +2611,7 @@ export async function runBuild(featureCode, opts = {}) {
1905
2611
  }
1906
2612
 
1907
2613
  // Hoisted for finally-block visibility
1908
- let streamWriter = null;
1909
- let buildStatus = 'complete';
2614
+ buildStatus = 'complete';
1910
2615
  let killedByGate = false;
1911
2616
  let terminalFailureReason = null;
1912
2617
  // I4: the last step the engine issued as `ready` (i.e. dispatched). When a flow
@@ -1924,17 +2629,25 @@ export async function runBuild(featureCode, opts = {}) {
1924
2629
  // and resets lenses_run, so the TRUE dirty lens (e.g. security) can be erased —
1925
2630
  // the gate persists from this, and only falls back to post-normalization findings.
1926
2631
  let lastReviewMergeDirtyLenses = [];
1927
- let signalHandler = null;
1928
2632
  let response;
1929
2633
  let stepHistory = [];
1930
2634
  const terminalHistoryWritten = { value: false };
1931
2635
  // COMP-OBS-COST: Accumulate token/cost totals across all steps (hoisted for finally-block)
1932
2636
  // On resume, seed from active-build.json to preserve pre-resume cost totals
1933
- const buildCostTotals = { input_tokens: 0, output_tokens: 0, cost_usd: 0 };
2637
+ const buildCostTotals = {
2638
+ input_tokens: 0,
2639
+ output_tokens: selectedAccumulator.tokens_total,
2640
+ cost_usd: selectedAccumulator.usd,
2641
+ };
1934
2642
  // COMP-MODEL-AB: capture structured test counts from the ship step so they can
1935
2643
  // be persisted to build-history.jsonl for the metrics consumer (experiment-metrics.js).
1936
2644
  // Null when ship didn't run (failed/killed builds) or testSummary was unparsed.
1937
- let shipStepTestData = null;
2645
+ let shipStepTestData = selectedAccumulator.test_count === null
2646
+ ? null
2647
+ : {
2648
+ test_count: selectedAccumulator.test_count,
2649
+ pass_rate: selectedAccumulator.pass_rate ?? 0,
2650
+ };
1938
2651
 
1939
2652
  // COMP-OBS-GATES: accumulate tier pass/fail results for this build.
1940
2653
  // Keys are tier IDs (T0–T4), values are true (passed), false (failed), or null (not yet run).
@@ -1949,17 +2662,37 @@ export async function runBuild(featureCode, opts = {}) {
1949
2662
  // Accumulate contract compliance signal: array of { passed: bool } per ensure check
1950
2663
  const contractCompliance = [];
1951
2664
 
1952
- const priorActive = readActiveBuild(dataDir);
1953
- if (priorActive && priorActive.featureCode === featureCode && priorActive.status === 'running') {
1954
- if (typeof priorActive.total_input_tokens === 'number') buildCostTotals.input_tokens = priorActive.total_input_tokens;
1955
- if (typeof priorActive.total_output_tokens === 'number') buildCostTotals.output_tokens = priorActive.total_output_tokens;
1956
- if (typeof priorActive.cumulative_cost_usd === 'number') buildCostTotals.cost_usd = priorActive.cumulative_cost_usd;
1957
- }
2665
+ // COMP-TRIAGE-6-4: on a fresh-over-failed retry the reused accumulator still
2666
+ // carries the prior FAILED attempt's identity and counters. Rotate to a fresh
2667
+ // record BEFORE the fallible startFresh (its plan() call can throw): otherwise
2668
+ // a throw drops to finalizeBuildAttempt, which reads the OLD record still on
2669
+ // disk under the reused build_id and emits a DUPLICATE build-actuals failed
2670
+ // row with stale counters (double-count in ACRR). Rotating first means the
2671
+ // throw finalizes the NEW record (distinct build_id, zeroed counters) instead.
2672
+ // Idempotent: guarded so the post-startFresh ownership block cannot re-rotate.
2673
+ // accumulatorRotated / isFreshStart are declared at function scope above so
2674
+ // finalizeBuildAttempt's ownership guard can read them.
2675
+ const rotateStaleAccumulatorForFreshStart = () => {
2676
+ if (isNewAccumulator || accumulatorRotated) return;
2677
+ activeAccumulator = createBuildAccumulator(cwd, featureCode);
2678
+ accumulatorPersisted = true;
2679
+ build_id = activeAccumulator.build_id;
2680
+ process.env.COMPOSE_BUILD_ID = build_id;
2681
+ buildCostTotals.input_tokens = 0;
2682
+ buildCostTotals.output_tokens = 0;
2683
+ buildCostTotals.cost_usd = 0;
2684
+ shipStepTestData = null;
2685
+ if (triageEstimate) {
2686
+ triageEstimate.build_id = build_id;
2687
+ emitTriageEstimate(cwd, triageEstimate);
2688
+ }
2689
+ accumulatorRotated = true;
2690
+ };
1958
2691
 
1959
2692
  try {
1960
2693
  // Check for active build (resume)
1961
2694
  const active = readActiveBuild(dataDir);
1962
- let isFreshStart = true;
2695
+ isFreshStart = true;
1963
2696
 
1964
2697
  // COMP-CODEX-IMPL: implementer/reviewer roles. A FRESH start derives them from
1965
2698
  // the flag (--codex flips Claude-implements/Codex-reviews → Codex/Claude); the
@@ -1991,6 +2724,26 @@ export async function runBuild(featureCode, opts = {}) {
1991
2724
  }
1992
2725
  reviewerAgent = opts.reviewer;
1993
2726
  }
2727
+ // COMP-PIPELINE-QUARANTINE round 3: keep the two roles on DIFFERENT providers
2728
+ // unless the caller asked for both explicitly. `--implementer codex` alone
2729
+ // leaves the reviewer at its codex default, so cross-model review silently
2730
+ // becomes Codex reviewing its own work — which is exactly the defect the
2731
+ // review-fix pipeline was corrected for, reintroduced one layer down. When
2732
+ // only one role is overridden, the other flips to the opposite provider.
2733
+ // Setting both to the same provider stays possible, but only deliberately.
2734
+ if (parseAgentString(implementerAgent).provider === parseAgentString(reviewerAgent).provider) {
2735
+ const bothExplicit = opts.implementer != null && opts.reviewer != null;
2736
+ if (!bothExplicit) {
2737
+ const flipped = parseAgentString(implementerAgent).provider === 'codex' ? 'claude' : 'codex';
2738
+ if (opts.reviewer == null) reviewerAgent = flipped;
2739
+ else implementerAgent = flipped;
2740
+ } else {
2741
+ console.warn(
2742
+ `⚠ implementer and reviewer are both ${parseAgentString(implementerAgent).provider}; ` +
2743
+ 'cross-model review is disabled for this run.'
2744
+ );
2745
+ }
2746
+ }
1994
2747
  const roles = { implementerAgent, reviewerAgent };
1995
2748
  // Restore persisted roles when (and only when) a resume actually happens.
1996
2749
  const restoreRolesFromActive = (src) => {
@@ -2083,8 +2836,13 @@ export async function runBuild(featureCode, opts = {}) {
2083
2836
  if (explicitResume) {
2084
2837
  throw new Error(`Nothing to resume for ${featureCode} (no in-progress or failed build found)`);
2085
2838
  }
2086
- response = await startFresh(stratum, specYaml, featureCode, description, dataDir, templateName, mode, preMergeGate, roles, cwd);
2839
+ // COMP-TRIAGE-6-4: claim fresh-ownership intent BEFORE rotating, so if
2840
+ // rotation itself throws the finalize guard still treats this as a fresh
2841
+ // start (isFreshStart=true) and suppresses re-finalizing the reused
2842
+ // failed row. Rotation then swaps to a fresh identity before startFresh.
2087
2843
  isFreshStart = true;
2844
+ rotateStaleAccumulatorForFreshStart();
2845
+ response = await startFresh(stratum, specYaml, featureCode, description, dataDir, templateName, mode, preMergeGate, roles, cwd);
2088
2846
  }
2089
2847
  if (!isFreshStart) {
2090
2848
  const stepId = response.ready?.[0]?.id;
@@ -2116,14 +2874,34 @@ export async function runBuild(featureCode, opts = {}) {
2116
2874
  }
2117
2875
  } else if (verdict.action === 'fresh') {
2118
2876
  if (activeForDecision?.flowId) console.log(`${verdict.reason}. Starting fresh.`);
2877
+ // COMP-TRIAGE-6-4: rotate a reused failed identity before the fallible
2878
+ // startFresh so a plan() throw cannot re-finalize the prior attempt.
2879
+ rotateStaleAccumulatorForFreshStart();
2119
2880
  response = await startFresh(stratum, specYaml, featureCode, description, dataDir, templateName, mode, preMergeGate, roles, cwd);
2120
2881
  } else {
2882
+ // A refused invocation never owned the accumulator — the ALIVE build does.
2883
+ // Disown before throwing so the widened finally cannot finalize the other
2884
+ // build's attempt as failed (spurious terminal row).
2885
+ attemptStarted = false;
2121
2886
  const reason = verdict.reason.includes(featureCode)
2122
2887
  ? verdict.reason
2123
2888
  : verdict.reason.replace('Build already running', `Build already running for ${featureCode}`);
2124
2889
  throw new Error(reason);
2125
2890
  }
2126
2891
 
2892
+ // Ownership point — the verdict resolved to resume or fresh (refuse/error
2893
+ // threw above without persisting anything). The fresh-over-failed identity
2894
+ // rotation now happens BEFORE startFresh (COMP-TRIAGE-6-4,
2895
+ // rotateStaleAccumulatorForFreshStart), so only the brand-new candidate is
2896
+ // persisted here. A new candidate is unaffected by that bug: a startFresh
2897
+ // throw hits finalizeBuildAttempt's write-and-emit fallback under a distinct
2898
+ // candidate id, never a duplicate row.
2899
+ if (isNewAccumulator) {
2900
+ writeBuildAccumulator(cwd, activeAccumulator);
2901
+ accumulatorPersisted = true;
2902
+ if (triageEstimate) emitTriageEstimate(cwd, triageEstimate);
2903
+ }
2904
+
2127
2905
  // COMP-CODEX-IMPL: verify Codex can write inside a detached git worktree (the
2128
2906
  // execute step's isolation primitive) before any step dispatches. Runs on the
2129
2907
  // EFFECTIVE role — after resume restoration above — so a resumed Codex build is
@@ -2132,9 +2910,15 @@ export async function runBuild(featureCode, opts = {}) {
2132
2910
  // The plan/resume above only created the flow object; no agent/worktree work has
2133
2911
  // happened yet, so aborting here still means we never reach `execute` (Codex
2134
2912
  // review finding #2). Cached + skippable via COMPOSE_SKIP_CODEX_PROBE.
2135
- if (implementerAgent === 'codex') {
2913
+ // Compare the PROVIDER, not the raw string: `codex:orchestrator` and
2914
+ // `codex::critical` are Codex implementers too, and an exact-string check let
2915
+ // them skip the mandatory worktree probe entirely (round 4 review).
2916
+ if (parseAgentString(implementerAgent).provider === 'codex') {
2136
2917
  const probe = await preflightCodexWorktreeProbe({
2137
2918
  cwd: agentCwd,
2919
+ projectCwd: cwd,
2920
+ buildId: build_id,
2921
+ featureCode,
2138
2922
  stratum,
2139
2923
  dataDir,
2140
2924
  ts: new Date().toISOString().replace(/[:.]/g, '-'),
@@ -2234,7 +3018,11 @@ export async function runBuild(featureCode, opts = {}) {
2234
3018
  })();
2235
3019
 
2236
3020
  const context = {
3021
+ stratum,
3022
+ flowId: response.runId,
3023
+ receiptsMode,
2237
3024
  cwd: agentCwd,
3025
+ projectCwd: cwd,
2238
3026
  featureCode,
2239
3027
  featureDir: resolveItemDir(featureCode),
2240
3028
  contextDir: contextDirPath,
@@ -2254,8 +3042,96 @@ export async function runBuild(featureCode, opts = {}) {
2254
3042
  // self-review suppression. Restored from active-build state on resume (above).
2255
3043
  implementerAgent,
2256
3044
  reviewerAgent,
3045
+ filesChanged: [...activeAccumulator.files_changed],
2257
3046
  ...(isBugMode ? { bug_code: featureCode } : {}),
2258
3047
  };
3048
+ context.recordBuildUsage = async (usage, meta) => {
3049
+ if (!usage || typeof usage !== 'object') return;
3050
+ const accumulatorUsage = Array.isArray(usage)
3051
+ ? usage.reduce((sum, entry) => ({
3052
+ input_tokens: sum.input_tokens + (entry?.input_tokens ?? 0),
3053
+ output_tokens: sum.output_tokens + (entry?.output_tokens ?? entry?.tokens ?? 0),
3054
+ cost_usd: sum.cost_usd + (entry?.cost_usd ?? entry?.usd ?? 0),
3055
+ }), { input_tokens: 0, output_tokens: 0, cost_usd: 0 })
3056
+ : usage;
3057
+ const componentTokens = (typeof accumulatorUsage.input_tokens === 'number' ? accumulatorUsage.input_tokens : 0)
3058
+ + (typeof accumulatorUsage.output_tokens === 'number' ? accumulatorUsage.output_tokens : 0);
3059
+ const tokens = componentTokens > 0
3060
+ ? componentTokens
3061
+ : (typeof accumulatorUsage.tokens_total === 'number'
3062
+ ? accumulatorUsage.tokens_total
3063
+ : (typeof accumulatorUsage.tokens === 'number' ? accumulatorUsage.tokens : 0));
3064
+ const usd = typeof accumulatorUsage.cost_usd === 'number'
3065
+ ? accumulatorUsage.cost_usd
3066
+ : (typeof accumulatorUsage.usd === 'number' ? accumulatorUsage.usd : 0);
3067
+ if (tokens !== 0 || usd !== 0) {
3068
+ updateBuildAccumulator(cwd, featureCode, (accumulator) => ({
3069
+ ...accumulator,
3070
+ tokens_total: accumulator.tokens_total + tokens,
3071
+ usd: accumulator.usd + usd,
3072
+ }));
3073
+ }
3074
+ return reportUsageReceipts(context, usage, meta);
3075
+ };
3076
+ context.recordFilesChanged = (paths, { authoritativeShip = false } = {}) => {
3077
+ const normalized = Array.isArray(paths)
3078
+ ? [...new Set(paths.filter((file) => typeof file === 'string' && file.length > 0))]
3079
+ : [];
3080
+ updateBuildAccumulator(cwd, featureCode, (accumulator) => ({
3081
+ ...accumulator,
3082
+ ...(authoritativeShip
3083
+ ? { ship_files_changed: normalized }
3084
+ : { files_changed: [...new Set([...accumulator.files_changed, ...normalized])] }),
3085
+ }));
3086
+ };
3087
+ // COMP-COMPLETION-GATE slice 2: the ship step's completion evidence, carried
3088
+ // to terminalization (where completion now happens, after the health gate).
3089
+ //
3090
+ // `tests_attested` and `evidence_root` are PERSISTED because they cannot be
3091
+ // re-derived later: re-running the suite at terminalization would be a second
3092
+ // run with a different result, and `runBuild` rebuilds the agent cwd from the
3093
+ // current invocation — so a resumed cross-repo build would otherwise verify
3094
+ // the wrong repository. The commit SHA is deliberately NOT persisted; it is
3095
+ // resolved from HEAD at terminalization, where it is verifiable.
3096
+ context.completionEvidence = null;
3097
+ context.recordCompletionEvidence = (evidence = {}) => {
3098
+ context.completionEvidence = { ...(context.completionEvidence || {}), ...evidence };
3099
+ const attested = evidence.testsAttested;
3100
+ if (attested !== undefined) {
3101
+ updateBuildAccumulator(cwd, featureCode, (accumulator) => ({
3102
+ ...accumulator,
3103
+ tests_attested: attested,
3104
+ evidence_root: agentCwd,
3105
+ }));
3106
+ }
3107
+ };
3108
+ context.recordShipTestMetrics = (metrics) => {
3109
+ if (!metrics || typeof metrics.test_count !== 'number') return;
3110
+ updateBuildAccumulator(cwd, featureCode, (accumulator) => ({
3111
+ ...accumulator,
3112
+ test_count: metrics.test_count,
3113
+ pass_rate: typeof metrics.pass_rate === 'number' ? metrics.pass_rate : 0,
3114
+ }));
3115
+ };
3116
+ context.recordEscalation = () => {
3117
+ updateBuildAccumulator(cwd, featureCode, (accumulator) => ({
3118
+ ...accumulator,
3119
+ escalations: accumulator.escalations + 1,
3120
+ }));
3121
+ };
3122
+ context.recordReviewIteration = () => {
3123
+ updateBuildAccumulator(cwd, featureCode, (accumulator) => ({
3124
+ ...accumulator,
3125
+ review_iterations: accumulator.review_iterations + 1,
3126
+ }));
3127
+ };
3128
+ context.settleDispatches = ({ stepId, ...args } = {}) => settleDispatches(
3129
+ cwd,
3130
+ build_id,
3131
+ stepId,
3132
+ args,
3133
+ );
3134
+ context.onUsage = context.recordBuildUsage;
2259
3135
 
2260
3136
  let consumerArtifacts = null;
2261
3137
  const artifactsForRun = (runId, pins) => {
@@ -2287,6 +3163,11 @@ export async function runBuild(featureCode, opts = {}) {
2287
3163
  // is the backstop — if the round can't be threaded for any reason, it trips
2288
3164
  // instead of letting the gate spin unbounded (the 52-round loop).
2289
3165
  const gateReentries = new Map();
3166
+ // Last consumer-merge preparation/apply failure per merge gate. A failure
3167
+ // that repeats byte-identically means the fan-out re-produced the same
3168
+ // conflict; revising again only re-dispatches every lane for the same result
3169
+ // (observed 2026-08-30: 4 paid rounds on one MERGE_WITNESS_PRECOMPUTE_FAILED).
3170
+ const consumerMergeFailures = new Map();
2290
3171
 
2291
3172
  // The run's effective-spec digest, carried on plan/resume responses only
2292
3173
  // (step_done responses omit it). Captured so the merge-gate path can pin the
@@ -2507,7 +3388,13 @@ export async function runBuild(featureCode, opts = {}) {
2507
3388
  // capture, shipStepTestData stays null for all real builds and appendBuildHistory
2508
3389
  // never persists test_count/pass_rate. Must mirror the generic path exactly.
2509
3390
  const _interceptedTestMetrics = _extractShipTestMetrics(shipResult);
2510
- if (_interceptedTestMetrics !== null) shipStepTestData = _interceptedTestMetrics;
3391
+ if (Array.isArray(shipResult.filesChanged)) {
3392
+ context.recordFilesChanged(shipResult.filesChanged, { authoritativeShip: true });
3393
+ }
3394
+ if (_interceptedTestMetrics !== null) {
3395
+ shipStepTestData = _interceptedTestMetrics;
3396
+ context.recordShipTestMetrics(_interceptedTestMetrics);
3397
+ }
2511
3398
  stepHistory.push({
2512
3399
  stepId: 'ship',
2513
3400
  artifact: shipResult.artifact,
@@ -2582,14 +3469,12 @@ export async function runBuild(featureCode, opts = {}) {
2582
3469
  };
2583
3470
  }
2584
3471
  verifyPipelineIntegrity(specPath, specFileHash);
3472
+ // `plan_items` is declared by build.stratum.yaml's PhaseResult but NOT
3473
+ // by gsd's, so it stays at this call site rather than in the shared
3474
+ // narrowing helper.
2585
3475
  const tsShipOutput = {
2586
- phase: shipResult.phase,
2587
- artifact: shipResult.artifact,
2588
- outcome: shipResult.outcome,
2589
- summary: shipResult.summary,
3476
+ ...toPhaseResultOutput(shipResult),
2590
3477
  ...(Array.isArray(shipResult.plan_items) ? { plan_items: shipResult.plan_items } : {}),
2591
- ...(Array.isArray(shipResult.filesChanged) ? { files_changed: shipResult.filesChanged } : {}),
2592
- ...(typeof shipResult.commit === 'string' ? { commit_hash: shipResult.commit } : {}),
2593
3478
  };
2594
3479
  const shipStepResult = response.status === 'ready'
2595
3480
  ? { output: tsShipOutput }
@@ -2618,17 +3503,55 @@ export async function runBuild(featureCode, opts = {}) {
2618
3503
  `Fix step "${stepId}" — the previous attempt failed:\n` +
2619
3504
  `- ${failureReason}\n\nFix every issue, then return the step's expected result.`;
2620
3505
  try {
2621
- await runAndNormalize(undefined, fixPrompt, { ...stepDispatch, agent: fixAgent }, {
3506
+ // The fixer's work is adjudicated by the retried step's own stepDone —
3507
+ // no settlement here (its dispatch event still records the cost).
3508
+ const fixResult = await runAndNormalize(undefined, fixPrompt, { ...stepDispatch, agent: fixAgent }, {
2622
3509
  progress,
2623
3510
  streamWriter,
2624
3511
  maxDurationMs: STEP_TIMEOUT_MS[stepId] ?? DEFAULT_TIMEOUT_MS,
2625
3512
  stratum,
2626
3513
  cwd: agentCwd,
2627
- profile: resolveStepProfile(context.stepProfiles, stepId),
3514
+ // C9: the sidecar's `fix` profile carries the fixer's tool
3515
+ // restrictions and model tier. Passing the bare agent literal
3516
+ // here handed the fixer an unrestricted profile; the sibling
3517
+ // review-repair site keys off `fix` the same way. Identity still
3518
+ // comes from the dispatch (`agent: fixAgent`).
3519
+ profile: resolveStepProfile(context.stepProfiles, 'fix')
3520
+ ?? resolveStepProfile(context.stepProfiles, stepId),
3521
+ sandboxMode: 'workspace-write',
3522
+ telemetry: {
3523
+ site: 'review-repair',
3524
+ project_cwd: cwd,
3525
+ build_id,
3526
+ feature_code: featureCode,
3527
+ step_id: stepId,
3528
+ ...(typeof readyStep.attempt === 'number' ? { attempt: readyStep.attempt } : {}),
3529
+ },
2628
3530
  });
3531
+ if (fixResult?.usage && typeof context.recordBuildUsage === 'function') {
3532
+ try {
3533
+ await context.recordBuildUsage(usagePayload(fixResult.usage, fixResult.usages), {
3534
+ stepId,
3535
+ source: 'fixer',
3536
+ dispatchId: fixResult.dispatchIds?.primary,
3537
+ });
3538
+ } catch { /* fail-open */ }
3539
+ }
2629
3540
  } catch (err) {
2630
- if (!(err instanceof AgentTimeoutError)) throw err;
2631
- console.warn(`\n⚠ Fix agent timed out on "${stepId}"`);
3541
+ if (err?.usage && typeof context.recordBuildUsage === 'function') {
3542
+ try {
3543
+ await context.recordBuildUsage(usagePayload(err.usage, err.usages), {
3544
+ stepId,
3545
+ source: 'fixer',
3546
+ dispatchId: err.dispatchId,
3547
+ });
3548
+ } catch { /* fail-open */ }
3549
+ }
3550
+ if (err instanceof AgentTimeoutError) {
3551
+ console.warn(`\n⚠ Fix agent timed out on "${stepId}"`);
3552
+ } else {
3553
+ throw err;
3554
+ }
2632
3555
  }
2633
3556
  progress.retry('build', stepId, readyStep.agent ?? response.agent ?? 'claude');
2634
3557
  }
@@ -2637,7 +3560,9 @@ export async function runBuild(featureCode, opts = {}) {
2637
3560
  const stepStartMs = Date.now();
2638
3561
  const agentType = readyStep?.agent ?? response.agent ?? 'claude';
2639
3562
  const basePrompt = buildStepPrompt(stepDispatch, context);
2640
- const maxDurationMs = STEP_TIMEOUT_MS[stepId] ?? DEFAULT_TIMEOUT_MS;
3563
+ const maxDurationMs = process.env.NODE_ENV === 'test' && Number.isFinite(opts.stepTimeoutMs)
3564
+ ? opts.stepTimeoutMs
3565
+ : (STEP_TIMEOUT_MS[stepId] ?? DEFAULT_TIMEOUT_MS);
2641
3566
 
2642
3567
  // MF-1/SF-4: Prepend shared review scaffold when this is a review step.
2643
3568
  // Also covers a ReviewResult merge step so its output is normalized via
@@ -2678,29 +3603,161 @@ export async function runBuild(featureCode, opts = {}) {
2678
3603
  reviewMode: isReviewMain,
2679
3604
  confidenceGate: confGateMain,
2680
3605
  lens: response.inputs?.lens_name ?? response.lens_name ?? 'general',
3606
+ telemetry: {
3607
+ site: isReviewMain ? 'review' : 'build-step',
3608
+ project_cwd: cwd,
3609
+ build_id,
3610
+ feature_code: featureCode,
3611
+ step_id: stepId,
3612
+ ...(typeof readyStep?.attempt === 'number' ? { attempt: readyStep.attempt } : {}),
3613
+ },
2681
3614
  // D6/V4: apply this ordinary step's compose-side profile (e.g.
2682
3615
  // blueprint → claude::critical, review_merge → claude:orchestrator),
2683
3616
  // normalizing scoped subflow ready ids to the bare step id.
2684
3617
  profile: resolveStepProfile(effectiveProfiles, stepId),
2685
3618
  });
2686
3619
  } catch (err) {
3620
+ const failedUsage = failureUsageFields(err);
2687
3621
  if (err instanceof UserInterruptError) {
2688
3622
  if (err.action === 'skip') {
2689
3623
  if (progress) progress.info(` ⏭ Skipped step "${stepId}"`);
2690
- mainResult = { text: '', result: { outcome: 'skipped', summary: `Skipped by user` } };
3624
+ mainResult = {
3625
+ text: '',
3626
+ // `phase` is carried because every pipeline's result contract
3627
+ // declares it and engine contracts are strict — without it a
3628
+ // user-initiated skip fails the step it was meant to bypass.
3629
+ result: { phase: stepId, outcome: 'skipped', summary: 'Skipped by user' },
3630
+ dispatchIds: { primary: err.dispatchId ?? null, repair: null },
3631
+ settlementFailureClass: 'agent',
3632
+ ...failedUsage,
3633
+ };
2691
3634
  } else {
2692
3635
  if (progress) progress.info(` ↻ Retrying step "${stepId}"`);
2693
- mainResult = { text: '', result: { outcome: 'failed', summary: `Retry requested by user` } };
3636
+ mainResult = {
3637
+ text: '',
3638
+ result: { outcome: 'failed', summary: 'Retry requested by user' },
3639
+ dispatchIds: { primary: err.dispatchId ?? null, repair: null },
3640
+ settlementFailureClass: 'agent',
3641
+ ...failedUsage,
3642
+ };
2694
3643
  }
2695
3644
  } else if (err instanceof AgentTimeoutError) {
2696
3645
  console.warn(`\n⚠ Agent timed out on step "${stepId}" after ${Math.round(err.durationMs / 1000)}s`);
2697
3646
  streamWriter.write({ type: 'build_error', message: err.message, stepId });
2698
- mainResult = { text: '', result: { outcome: 'failed', summary: `Timed out after ${Math.round(err.durationMs / 1000)}s` } };
3647
+ mainResult = {
3648
+ text: '',
3649
+ result: { outcome: 'failed', summary: `Timed out after ${Math.round(err.durationMs / 1000)}s` },
3650
+ dispatchIds: { primary: err.dispatchId ?? null, repair: null },
3651
+ settlementFailureClass: 'agent',
3652
+ ...failedUsage,
3653
+ };
2699
3654
  } else {
3655
+ // Fatal rethrow bypasses the post-stepDone usage fold — bill the
3656
+ // attempt's real cost to the accumulator before crashing.
3657
+ if (failedUsage.usage && typeof context.recordBuildUsage === 'function') {
3658
+ try {
3659
+ await context.recordBuildUsage(usagePayload(failedUsage.usage, failedUsage.usages), {
3660
+ stepId,
3661
+ source: 'main',
3662
+ dispatchId: err.dispatchId,
3663
+ });
3664
+ } catch { /* fail-open */ }
3665
+ }
2700
3666
  streamWriter.write({ type: 'build_error', message: err.message, stepId });
2701
3667
  throw err;
2702
3668
  }
2703
3669
  }
3670
+ // COMP-POLICY-CHECK-3/4: scan the candidate response against the local
3671
+ // adherence catalog before it is accepted, then allow the agent exactly
3672
+ // one revision pass. Never hard-blocks (design: "surfaces violations for
3673
+ // revision; it does not refuse to emit") and never rewrites the draft.
3674
+ let policyViolationStrings = [];
3675
+ let policyUnsuppressedCount = 0;
3676
+ {
3677
+ const skillGated = isGateStep(localSpec, localFlowName, stepId);
3678
+ const traceArgs = { cwd, streamWriter, stepId, featureCode, buildId: build_id };
3679
+
3680
+ let scan = policyScanForStep({ cwd, text: mainResult?.text, skillGated });
3681
+ recordPolicyScan({ ...traceArgs, records: scan.records, userMode: scan.userMode, pass: 'initial' });
3682
+
3683
+ if (scan.violations.length > 0) {
3684
+ if (progress) progress.warn(`Policy check: ${scan.violations.length} unsuppressed violation(s) — requesting one revision`);
3685
+ try {
3686
+ const revised = await runAndNormalize(
3687
+ null,
3688
+ `${prompt}\n\n${buildRevisionNotice(scan.records)}`,
3689
+ stepDispatch,
3690
+ {
3691
+ progress, streamWriter, maxDurationMs, stratum, cwd: agentCwd,
3692
+ reviewMode: isReviewMain,
3693
+ confidenceGate: confGateMain,
3694
+ profile: resolveStepProfile(effectiveProfiles, stepId),
3695
+ telemetry: {
3696
+ site: 'policy-revision',
3697
+ project_cwd: cwd,
3698
+ build_id,
3699
+ feature_code: featureCode,
3700
+ step_id: stepId,
3701
+ ...(typeof readyStep?.attempt === 'number' ? { attempt: readyStep.attempt } : {}),
3702
+ },
3703
+ },
3704
+ );
3705
+ const replaces = revised && !revised.normalizationFailure && revised.result?.outcome !== 'failed';
3706
+ if (!replaces && revised?.usage && typeof context.recordBuildUsage === 'function') {
3707
+ // Rejected revision: its cost still happened, and no merged
3708
+ // usage will carry it, so bill it like the review fixer's.
3709
+ try {
3710
+ await context.recordBuildUsage(usagePayload(revised.usage, revised.usages), {
3711
+ stepId,
3712
+ source: 'policy_revision',
3713
+ dispatchId: revised.dispatchIds?.primary,
3714
+ });
3715
+ } catch { /* fail-open */ }
3716
+ }
3717
+ if (replaces) {
3718
+ await reportUsageReceipts(
3719
+ context,
3720
+ usagePayload(revised.usage, revised.usages),
3721
+ { stepId, source: 'policy_revision', dispatchId: revised.dispatchIds?.primary },
3722
+ );
3723
+ // The replacement carries BOTH dispatch ids (so settlement
3724
+ // settles both) and the summed usage (so step_usage, build
3725
+ // totals, and build-history include the revision's cost — the
3726
+ // step_usage block below is the single accumulator call).
3727
+ mainResult = {
3728
+ ...mainResult,
3729
+ text: revised.text ?? mainResult.text,
3730
+ result: revised.result ?? mainResult.result,
3731
+ usage: mergeUsage(mainResult.usage, revised.usage),
3732
+ dispatchIds: {
3733
+ ...(mainResult.dispatchIds ?? {}),
3734
+ revision: revised.dispatchIds?.primary ?? null,
3735
+ },
3736
+ };
3737
+ scan = policyScanForStep({ cwd, text: mainResult.text, skillGated });
3738
+ recordPolicyScan({ ...traceArgs, records: scan.records, userMode: scan.userMode, pass: 'policy_revision' });
3739
+ }
3740
+ // The second result stands either way — no further passes.
3741
+ } catch (err) {
3742
+ if (err?.usage && typeof context.recordBuildUsage === 'function') {
3743
+ try {
3744
+ await context.recordBuildUsage(usagePayload(err.usage, err.usages), {
3745
+ stepId,
3746
+ source: 'policy_revision',
3747
+ dispatchId: err.dispatchId,
3748
+ });
3749
+ } catch { /* fail-open */ }
3750
+ }
3751
+ if (policyRevisionMustStop(err)) throw err;
3752
+ // eslint-disable-next-line no-console
3753
+ console.warn(`[policy-check] revision pass failed on "${stepId}" — keeping the original draft: ${err.message}`);
3754
+ }
3755
+ }
3756
+
3757
+ policyViolationStrings = scan.violations;
3758
+ policyUnsuppressedCount = scan.violations.length;
3759
+ }
3760
+
2704
3761
  const { result, text: stepText, usage: stepUsage, normalizationFailure } = mainResult;
2705
3762
 
2706
3763
  // Scan agent output for "we should X" / "we could X" patterns that don't map
@@ -2791,6 +3848,7 @@ export async function runBuild(featureCode, opts = {}) {
2791
3848
  const existing = new Set(context.filesChanged ?? []);
2792
3849
  for (const f of files) existing.add(f);
2793
3850
  context.filesChanged = [...existing];
3851
+ context.recordFilesChanged(context.filesChanged);
2794
3852
  }
2795
3853
  } catch { /* git not available or no repo — skip */ }
2796
3854
  }
@@ -2804,7 +3862,14 @@ export async function runBuild(featureCode, opts = {}) {
2804
3862
  // so both paths produce identical shipStepTestData shapes.
2805
3863
  if (stepId === 'ship') {
2806
3864
  const _genericTestMetrics = _extractShipTestMetrics(result);
2807
- if (_genericTestMetrics !== null) shipStepTestData = _genericTestMetrics;
3865
+ const genericShipFiles = result?.filesChanged ?? result?.files_changed;
3866
+ if (Array.isArray(genericShipFiles)) {
3867
+ context.recordFilesChanged(genericShipFiles, { authoritativeShip: true });
3868
+ }
3869
+ if (_genericTestMetrics !== null) {
3870
+ shipStepTestData = _genericTestMetrics;
3871
+ context.recordShipTestMetrics(_genericTestMetrics);
3872
+ }
2808
3873
  }
2809
3874
 
2810
3875
  // Note: scope-step BuildProfile persistence has been replaced by pre-build triage.
@@ -2847,20 +3912,59 @@ export async function runBuild(featureCode, opts = {}) {
2847
3912
  // lens rather than a normalization-stamped 'general'.
2848
3913
  lastReviewMergeDirtyLenses = extractDirtyLenses(stepText ?? result);
2849
3914
  }
3915
+ // COMP-POLICY-CHECK-6: expose the unsuppressed count on the step result
3916
+ // so a spec can declare `ensure: ['result.unsuppressed_violations == 0']`.
3917
+ // Engine contracts are strict, so the field is attached only where it is
3918
+ // declared (or where the step has no out contract).
3919
+ const policyResult = attachPolicyCount(result, policyUnsuppressedCount, stepDispatch);
2850
3920
  const stepDoneResult = readyStep
2851
3921
  ? blockingFailure
2852
3922
  ? { failure: blockingFailure }
2853
3923
  : normalizationFailure || result?.outcome === 'failed'
2854
3924
  ? { failure: String(normalizationFailure ?? result?.summary ?? `Step "${stepId}" did not produce structured output`) }
2855
3925
  : stepDispatch.has_out_contract
2856
- ? result != null
2857
- ? { output: result }
3926
+ ? policyResult != null
3927
+ ? { output: policyResult }
2858
3928
  : { failure: `Step "${stepId}" did not produce structured output` }
2859
3929
  : {}
2860
- : result ?? { summary: 'Step complete' };
3930
+ : policyResult ?? { summary: 'Step complete' };
3931
+ // Report each model dispatch before the outcome it funded. The merged
3932
+ // step usage remains the accumulator/build-stream shape used by existing
3933
+ // callers; receipts use mainResult.usages to preserve per-dispatch data.
3934
+ if (toEngineUsage(stepUsage)) {
3935
+ buildCostTotals.input_tokens += stepUsage.input_tokens ?? 0;
3936
+ buildCostTotals.output_tokens += stepUsage.output_tokens ?? 0;
3937
+ buildCostTotals.cost_usd += stepUsage.cost_usd ?? 0;
3938
+ await context.recordBuildUsage(usagePayload(stepUsage, mainResult.usages), {
3939
+ stepId,
3940
+ source: 'main',
3941
+ dispatchId: mainResult.dispatchIds?.primary,
3942
+ });
3943
+ streamWriter.writeUsage(stepId, stepUsage);
3944
+ }
2861
3945
  response = await stratum.stepDone(
2862
3946
  flowId, stepId, stepDoneResult, readyStep?.dispatchToken,
2863
3947
  );
3948
+ {
3949
+ const isEnsureRetry = responseReissuesStep(response, stepId);
3950
+ const failureClass = ownershipFailure
3951
+ ? 'ownership'
3952
+ : vocabularyFailure
3953
+ ? 'vocabulary'
3954
+ : normalizationFailure || (stepDispatch.has_out_contract && result == null)
3955
+ ? 'normalization'
3956
+ : mainResult.settlementFailureClass
3957
+ ?? (result?.outcome === 'failed' ? 'agent' : null);
3958
+ context.settleDispatches({
3959
+ stepId,
3960
+ dispatchIds: mainResult.dispatchIds,
3961
+ accepted: !isEnsureRetry && !blockingFailure && !normalizationFailure
3962
+ && result?.outcome !== 'failed'
3963
+ && (!stepDispatch.has_out_contract || result != null),
3964
+ failureClass,
3965
+ isEnsureRetry,
3966
+ });
3967
+ }
2864
3968
  syncStepHistory(dataDir, stepHistory);
2865
3969
 
2866
3970
  // COMP-FIX-HARD T6: record accepted hypothesis on diagnose success (bug mode only).
@@ -2934,7 +4038,19 @@ export async function runBuild(featureCode, opts = {}) {
2934
4038
 
2935
4039
  // COMP-FIX-HARD T10: post-retro_check escalation gate (bug mode only).
2936
4040
  if (stepId === 'retro_check' && context.mode === 'bug' && context.bug_code) {
2937
- await maybeRunEscalation(stratum, context, progress, streamWriter, attemptCounter, dataDir);
4041
+ await maybeRunEscalation(
4042
+ stratum,
4043
+ {
4044
+ ...context,
4045
+ projectCwd: cwd,
4046
+ step_id: stepId,
4047
+ ...(typeof readyStep?.attempt === 'number' ? { attempt: readyStep.attempt } : {}),
4048
+ },
4049
+ progress,
4050
+ streamWriter,
4051
+ attemptCounter,
4052
+ dataDir,
4053
+ );
2938
4054
  }
2939
4055
 
2940
4056
  // Stream: step done — read retries/violations from active-build state
@@ -2942,16 +4058,10 @@ export async function runBuild(featureCode, opts = {}) {
2942
4058
  {
2943
4059
  const buildState = readActiveBuild(dataDir);
2944
4060
  const stepState = buildState?.steps?.find(s => s.id === stepId) ?? {};
2945
- // COMP-OBS-COST: accumulate step usage and emit step_usage event
2946
- if (stepUsage && (stepUsage.input_tokens > 0 || stepUsage.output_tokens > 0 || stepUsage.cost_usd > 0)) {
2947
- buildCostTotals.input_tokens += stepUsage.input_tokens ?? 0;
2948
- buildCostTotals.output_tokens += stepUsage.output_tokens ?? 0;
2949
- buildCostTotals.cost_usd += stepUsage.cost_usd ?? 0;
2950
- streamWriter.writeUsage(stepId, stepUsage);
2951
- }
2952
-
2953
- // COMP-HEALTH: collect runtime violations for health score signal
2954
- const stepViolations = stepState.violations ?? [];
4061
+ // COMP-HEALTH: collect runtime violations for health score signal.
4062
+ // COMP-POLICY-CHECK-3: unsuppressed policy violations join the same
4063
+ // stream ViolationDetail renders them with zero UI changes.
4064
+ const stepViolations = [...(stepState.violations ?? []), ...policyViolationStrings];
2955
4065
  if (stepViolations.length > 0) {
2956
4066
  allViolations.push(...stepViolations);
2957
4067
  }
@@ -3097,10 +4207,18 @@ export async function runBuild(featureCode, opts = {}) {
3097
4207
  ) ?? null;
3098
4208
  }
3099
4209
  }
4210
+ const repairFor = (error) => {
4211
+ const failure = `${error.code}: ${error.message}`;
4212
+ const decision = decideMergeRepairOutcome(
4213
+ consumerMergeFailures.get(stepId), failure, repairOutcome,
4214
+ );
4215
+ consumerMergeFailures.set(stepId, failure);
4216
+ outcome = decision.outcome;
4217
+ rationale = decision.rationale;
4218
+ };
3100
4219
  if (consumerMergeArtifacts && outcome === 'approve') {
3101
4220
  if (consumerMergePreparationError) {
3102
- outcome = repairOutcome;
3103
- rationale = `${consumerMergePreparationError.code}: ${consumerMergePreparationError.message}`;
4221
+ repairFor(consumerMergePreparationError);
3104
4222
  } else {
3105
4223
  try {
3106
4224
  await consumerMergeArtifacts.applyMerge(consumerMergeTransaction);
@@ -3113,12 +4231,12 @@ export async function runBuild(featureCode, opts = {}) {
3113
4231
  const files = new Set(context.filesChanged ?? []);
3114
4232
  for (const file of changed.split('\n').filter(Boolean)) files.add(file);
3115
4233
  context.filesChanged = [...files];
4234
+ context.recordFilesChanged(context.filesChanged);
3116
4235
  }
3117
4236
  } catch { /* best-effort build context projection */ }
3118
4237
  } catch (error) {
3119
4238
  if (!(error instanceof ConsumerMergeDecisionError)) throw error;
3120
- outcome = repairOutcome;
3121
- rationale = `${error.code}: ${error.message}`;
4239
+ repairFor(error);
3122
4240
  }
3123
4241
  }
3124
4242
  }
@@ -3204,8 +4322,18 @@ export async function runBuild(featureCode, opts = {}) {
3204
4322
  let reviewResult = lastReviewMergeResult;
3205
4323
  let rawDirtyLenses = lastReviewMergeDirtyLenses;
3206
4324
  if (!reviewResult) {
3207
- const auditedOutput = gateAudit?.steps?.review_merge?.output ?? null;
3208
- if (auditedOutput && typeof auditedOutput === 'object') {
4325
+ // COMP-PIPELINE-QUARANTINE follow-up: the re-derive used to read
4326
+ // `steps.review_merge.output` by that literal name, so any pipeline
4327
+ // whose reducer is called something else (team-review's `merge`,
4328
+ // review-fix's `review`) silently got a null stash on resume and had
4329
+ // its clean review treated as dirty. The stash above is already keyed
4330
+ // on the sidecar's _reduceSteps; this now matches it, with the
4331
+ // canonical id kept as the fallback.
4332
+ const reducerIds = [...reduceSteps, 'review_merge'];
4333
+ const auditedOutput = reducerIds
4334
+ .map((id) => gateAudit?.steps?.[id]?.output ?? null)
4335
+ .find((out) => out && typeof out === 'object') ?? null;
4336
+ if (auditedOutput) {
3209
4337
  reviewResult = auditedOutput;
3210
4338
  rawDirtyLenses = extractDirtyLenses(auditedOutput);
3211
4339
  }
@@ -3219,6 +4347,7 @@ export async function runBuild(featureCode, opts = {}) {
3219
4347
  stepHistory.push({ stepId: gateStepId, artifact: null, summary: 'Review gate: clean', outcome: resolved.outcome });
3220
4348
  syncStepHistory(dataDir, stepHistory);
3221
4349
  } else {
4350
+ context.recordReviewIteration();
3222
4351
  // Persist the dirty lens ids so the revised triage re-runs only those.
3223
4352
  // J2: prefer the PRE-normalization dirty lenses (normalization resets
3224
4353
  // lenses_run and stamps a missing finding lens as 'general', which would
@@ -3242,11 +4371,37 @@ export async function runBuild(featureCode, opts = {}) {
3242
4371
  + (reviewResult.summary ? `\nSummary: ${reviewResult.summary}\n` : '')
3243
4372
  + findings.map((f) => `- ${f.file ?? '?'}:${f.line ?? '?'} [${f.lens ?? f.severity ?? ''}] ${f.finding ?? f.summary ?? ''}`).join('\n');
3244
4373
  try {
3245
- await runAndNormalize(undefined, fixPrompt, { step_id: 'review_fix', agent: fixAgent, flow_id: flowId }, {
4374
+ const gateFixResult = await runAndNormalize(undefined, fixPrompt, { step_id: 'review_fix', agent: fixAgent, flow_id: flowId }, {
3246
4375
  progress, streamWriter, maxDurationMs: STEP_TIMEOUT_MS.review_merge ?? DEFAULT_TIMEOUT_MS,
3247
- stratum, cwd: agentCwd, profile: resolveStepProfile(effectiveProfiles, 'fix'),
4376
+ stratum, cwd: agentCwd, sandboxMode: 'workspace-write', profile: resolveStepProfile(effectiveProfiles, 'fix'),
4377
+ telemetry: {
4378
+ site: 'review-repair',
4379
+ project_cwd: cwd,
4380
+ build_id,
4381
+ feature_code: featureCode,
4382
+ step_id: 'review_fix',
4383
+ ...(typeof gateDispatch.attempt === 'number' ? { attempt: gateDispatch.attempt } : {}),
4384
+ },
3248
4385
  });
4386
+ if (gateFixResult?.usage && typeof context.recordBuildUsage === 'function') {
4387
+ try {
4388
+ await context.recordBuildUsage(usagePayload(gateFixResult.usage, gateFixResult.usages), {
4389
+ stepId: gateStepId,
4390
+ source: 'gate_fixer',
4391
+ dispatchId: gateFixResult.dispatchIds?.primary,
4392
+ });
4393
+ } catch { /* fail-open */ }
4394
+ }
3249
4395
  } catch (err) {
4396
+ if (err?.usage && typeof context.recordBuildUsage === 'function') {
4397
+ try {
4398
+ await context.recordBuildUsage(usagePayload(err.usage, err.usages), {
4399
+ stepId: gateStepId,
4400
+ source: 'gate_fixer',
4401
+ dispatchId: err.dispatchId,
4402
+ });
4403
+ } catch { /* fail-open */ }
4404
+ }
3250
4405
  if (!(err instanceof AgentTimeoutError)) throw err;
3251
4406
  console.warn('\n⚠ Review fixer timed out');
3252
4407
  }
@@ -3412,6 +4567,9 @@ export async function runBuild(featureCode, opts = {}) {
3412
4567
  }
3413
4568
 
3414
4569
  // Flow complete — write terminal state (file retained per STRAT-COMP-4 contract).
4570
+ // COMP-COMPLETION-GATE slice 2: set when the flow completed and the feature is
4571
+ // eligible to be completed — the actual completion runs after the health gate.
4572
+ let pendingCompletion = null;
3415
4573
  if (response.status === 'completed') buildStatus = 'complete';
3416
4574
  if ((response.status === 'failed' || response.status === 'budget_exhausted') && !killedByGate) {
3417
4575
  buildStatus = 'failed';
@@ -3419,20 +4577,19 @@ export async function runBuild(featureCode, opts = {}) {
3419
4577
  }
3420
4578
  if (response.status === 'completed' && buildStatus === 'complete') {
3421
4579
  console.log('\nBuild complete.');
3422
- await visionWriter.updateItemStatus(itemId, 'complete');
3423
- // COMP-QA: persist filesChanged so `compose qa-scope` can read them post-build.
3424
- // Bug mode skips feature-json bugs don't have feature.json (COMP-FIX-HARD T4).
3425
- if (cfg.tracksFeatureJson) {
3426
- const _bp = await getBuildProvider(cwd);
3427
- // Guard: feature.json may not exist when triage was skipped (test harnesses).
3428
- // Original updateFeature silently no-oped when feature was missing.
3429
- // Single atomic raw write (status + filesChanged together) — restores original
3430
- // updateFeature atomicity. persistFeatureRaw: no policy, no events, no roadmap.
3431
- const _feat = await _bp.getFeature(featureCode);
3432
- if (_feat) {
3433
- await _bp.persistFeatureRaw(featureCode, { ..._feat, status: 'COMPLETE', filesChanged: context.filesChanged ?? [] });
3434
- }
3435
- }
4580
+ // COMP-COMPLETION-GATE slice 2: the COMPLETION does not happen here.
4581
+ //
4582
+ // This block used to flip the vision item and feature.json to COMPLETE
4583
+ // immediately — but the health gate below can still downgrade the build to
4584
+ // `failed`, and the guard ledger is append-only. Completing here meant a
4585
+ // health-rejected build was left marked COMPLETE, and (once gated) the very
4586
+ // first thing the ledger would ever durably attest would be a build the
4587
+ // system itself then judged a failure.
4588
+ //
4589
+ // The health verdict is a PRECONDITION of completion, not its successor, so
4590
+ // the completion is deferred to the gated block below, which runs after the
4591
+ // health gate. See COMP-COMPLETION-GATE design §2.3c.
4592
+ pendingCompletion = { itemId, featureCode };
3436
4593
  const termState = readActiveBuild(dataDir);
3437
4594
  if (termState) {
3438
4595
  writeActiveBuild(dataDir, { ...termState, status: 'complete', completedAt: new Date().toISOString() });
@@ -3492,16 +4649,29 @@ export async function runBuild(featureCode, opts = {}) {
3492
4649
  buildSignals.runtime_errors = [];
3493
4650
  }
3494
4651
 
3495
- // Doc freshness — check staleness of feature artifacts
4652
+ // Doc freshness — derivation-based staleness (COMP-PROV-LINEAGE). An
4653
+ // artifact is stale when an upstream it wasDerivedFrom is newer than it.
4654
+ // This replaced the old phase-marker staleness reader (now removed),
4655
+ // which read a `<!-- phase: -->` marker that no production writer ever
4656
+ // emitted (a dead signal). findStaleArtifacts works off the canonical
4657
+ // chain + mtimes, so it needs no marker to be written first.
3496
4658
  try {
3497
- const { checkStaleness } = await import('./staleness.js');
3498
- const currentPhase = stepHistory.length > 0
3499
- ? stepHistory[stepHistory.length - 1].stepId
3500
- : 'build';
3501
- const stalenessResults = checkStaleness(resolveItemDir(featureCode), currentPhase);
3502
- buildSignals.doc_freshness = stalenessResults;
4659
+ const { findStaleArtifacts } = await import('./lineage.js');
4660
+ buildSignals.doc_freshness = findStaleArtifacts(resolveItemDir(featureCode));
3503
4661
  } catch { /* staleness check is optional — skip on error */ }
3504
4662
 
4663
+ // COMP-PROV-LINEAGE — populate PROV-O lineage markers on the canonical
4664
+ // artifacts that now exist. This runs once per build, in the finalization
4665
+ // pass after the dispatch loop, when the artifact set is complete. Build
4666
+ // is the single writer here, so the read/write/utimes in stampFeatureLineage
4667
+ // is uncontended. Idempotent and mtime-preserving, so it never resets the
4668
+ // derivation clock that staleness reachability depends on. This is the
4669
+ // lifecycle-writer surface that materialises wasGeneratedBy/wasDerivedFrom.
4670
+ try {
4671
+ const { stampFeatureLineage } = await import('./lineage.js');
4672
+ stampFeatureLineage(resolveItemDir(featureCode));
4673
+ } catch { /* lineage stamping is optional — skip on error */ }
4674
+
3505
4675
  const healthSettings = (() => {
3506
4676
  try {
3507
4677
  if (existsSync(settingsPath)) {
@@ -3564,6 +4734,97 @@ export async function runBuild(featureCode, opts = {}) {
3564
4734
  }
3565
4735
  }
3566
4736
 
4737
+ // ---------------------------------------------------------------------
4738
+ // COMP-COMPLETION-GATE slice 2 — THE completion, and the only one.
4739
+ //
4740
+ // Runs here, after the health gate above may have downgraded buildStatus, so
4741
+ // a health-rejected build completes nothing: no completion record, no
4742
+ // COMPLETE status, no vision completion, no guard transition.
4743
+ // ---------------------------------------------------------------------
4744
+ if (pendingCompletion && buildStatus === 'complete') {
4745
+ const ev = context.completionEvidence || {};
4746
+ const acc = readBuildAccumulator(cwd, featureCode);
4747
+ // Persisted, because it survives a resume; the in-memory value wins when
4748
+ // this process ran the ship step itself.
4749
+ const testsAttested = ev.testsAttested ?? acc?.tests_attested ?? 'no-signal';
4750
+ const evidenceRoot = acc?.evidence_root || agentCwd;
4751
+
4752
+ // The SHA is resolved here rather than carried: at terminalization HEAD is
4753
+ // the commit the build produced (or, on the already-committed path, found),
4754
+ // and resolving it at the point of use keeps it verifiable instead of a
4755
+ // stale claim threaded across a resume boundary.
4756
+ let commitSha = ev.commitSha ?? null;
4757
+ if (!commitSha) {
4758
+ try {
4759
+ commitSha = execSync('git rev-parse HEAD', {
4760
+ cwd: evidenceRoot, encoding: 'utf-8', timeout: 5000, stdio: ['ignore', 'pipe', 'pipe'],
4761
+ }).trim() || null;
4762
+ } catch { /* no repo — the no-repo exemption applies below */ }
4763
+ }
4764
+
4765
+ if (cfg.tracksFeatureJson) {
4766
+ const { completionGate, guardEnabled } = await import('./completion-gate.js');
4767
+ // `capabilities.guard: false` is a deliberate opt-OUT, and the gate itself
4768
+ // honors it (completion-gate.js §"Evidence, BEFORE the lock" / AC-5). This
4769
+ // refusal has to sit INSIDE the same regime: enforcing attestation on an
4770
+ // opted-out project would break every one of them — including non-git
4771
+ // workspaces, where the evidence can never pass at all — which is exactly
4772
+ // the reversal already made once during slice 1. An opted-out project keeps
4773
+ // `deriveTestsPass`'s degrade contract: 'no-signal' reads as true there.
4774
+ const guarded = guardEnabled(cwd);
4775
+
4776
+ // `no-signal` is not an attestation. Refusing here is the whole point of
4777
+ // the tri-state: an unreadable test run must not become a passing claim
4778
+ // on a permanent record.
4779
+ if (guarded && testsAttested === 'no-signal') {
4780
+ console.warn(
4781
+ `[completion-gate] ${featureCode}: tests could not be attested (test output was ` +
4782
+ `unreadable). Configure guard.testCommand in .compose/compose.json so the test run ` +
4783
+ `itself attests, or record the completion explicitly. Build is complete; the feature ` +
4784
+ `is NOT marked COMPLETE.`,
4785
+ );
4786
+ } else {
4787
+ const gated = await completionGate({
4788
+ featureCode,
4789
+ commitSha,
4790
+ testsPass: guarded ? testsAttested === 'passed' : testsAttested !== 'failed',
4791
+ filesChanged: ev.filesChanged ?? context.filesChanged ?? [],
4792
+ notes: ev.notes,
4793
+ builtVia: ev.builtVia,
4794
+ workspaceRoot: cwd,
4795
+ evidenceRoot,
4796
+ mode: resolveMode(mode),
4797
+ // Slice 3: the gate owns the vision projection (§2.3a step 4) through
4798
+ // the self-verifying seam; `updateItemStatus(…, 'complete')` refuses
4799
+ // managed build items now (AC-16).
4800
+ visionItemId: pendingCompletion.itemId,
4801
+ visionProjector: ({ featureCode: fc, commitSha: sha, ledgerRef }) =>
4802
+ visionWriter.completeItem(pendingCompletion.itemId, { featureCode: fc, cwd, commitSha: sha, ledgerRef }),
4803
+ });
4804
+ if (gated.ok) {
4805
+ if (gated.partial) {
4806
+ console.warn(
4807
+ `[completion-gate] ${featureCode}: completed, but a projection failed — ` +
4808
+ gated.failures.map(f => `${f.step}: ${f.message} (recover: ${f.recover})`).join('; '),
4809
+ );
4810
+ }
4811
+ } else {
4812
+ // Do NOT fail the build: the work is committed and the flow finished.
4813
+ // But do not claim completion either — say plainly what was refused.
4814
+ console.warn(
4815
+ `[completion-gate] ${featureCode}: completion refused at ${gated.refusedAt} — ` +
4816
+ `${(gated.reasons || []).join('; ')}. The build finished and the commit stands; ` +
4817
+ `the feature is NOT marked COMPLETE.`,
4818
+ );
4819
+ }
4820
+ }
4821
+ } else {
4822
+ // Bug/plan modes have no feature.json to gate on (COMP-FIX-HARD T4), and
4823
+ // slice 1/2 are scoped to build mode — their completion path is unchanged.
4824
+ await visionWriter.updateItemStatus(pendingCompletion.itemId, 'complete');
4825
+ }
4826
+ }
4827
+
3567
4828
  // COMP-COCKPIT-3: archive the run to build-history.jsonl ONCE, here — after
3568
4829
  // the COMP-HEALTH gate above may have downgraded buildStatus to 'failed'.
3569
4830
  // Assembled from the in-memory build context for THIS run (never re-read
@@ -3691,6 +4952,13 @@ export async function runBuild(featureCode, opts = {}) {
3691
4952
  }
3692
4953
  throw err;
3693
4954
  } finally {
4955
+ let actualsError = null;
4956
+ try {
4957
+ finalizeBuildAttempt();
4958
+ } catch (error) {
4959
+ actualsError = error;
4960
+ attemptFinalized = true;
4961
+ }
3694
4962
  // Close stream writer with appropriate status (idempotent — signal handler may have already closed)
3695
4963
  if (streamWriter) {
3696
4964
  streamWriter.close(buildStatus, buildCostTotals);
@@ -3701,9 +4969,31 @@ export async function runBuild(featureCode, opts = {}) {
3701
4969
  }
3702
4970
  progress.finish();
3703
4971
  await stratum.close();
3704
- // COMP-MCP-MIGRATION-1: restore COMPOSE_BUILD_ID env to its prior value
3705
- // (or unset) so subsequent processes / tests don't inherit a stale UUID.
3706
- _restoreBuildIdEnv();
4972
+ runtimeResourcesFinalized = true;
4973
+ if (actualsError) throw actualsError;
4974
+ }
4975
+ } finally {
4976
+ try {
4977
+ finalizeBuildAttempt();
4978
+ } finally {
4979
+ try {
4980
+ if (!runtimeResourcesFinalized) {
4981
+ if (streamWriter) streamWriter.close(buildStatus);
4982
+ if (signalHandler) {
4983
+ process.removeListener('SIGINT', signalHandler);
4984
+ process.removeListener('SIGTERM', signalHandler);
4985
+ }
4986
+ progress?.finish();
4987
+ if (stratum) await stratum.close();
4988
+ runtimeResourcesFinalized = true;
4989
+ }
4990
+ } finally {
4991
+ // COMP-MCP-MIGRATION-1: restore COMPOSE_BUILD_ID env to its prior value
4992
+ // (or unset) so subsequent processes / tests don't inherit a stale UUID.
4993
+ _restoreBuildIdEnv();
4994
+ attemptStarted = false;
4995
+ }
4996
+ }
3707
4997
  }
3708
4998
  }
3709
4999
 
@@ -3812,6 +5102,78 @@ export function _extractShipTestMetrics(shipResult) {
3812
5102
  return { test_count: shipResult.test_count, pass_rate: shipResult.pass_rate ?? 0 };
3813
5103
  }
3814
5104
 
5105
+ /** The guarded namespace — must equal the verifier's and the hook's (R2). */
5106
+ const JUDGMENT_TREE = 'docs/judgment';
5107
+
5108
+ /**
5109
+ * Run a git command and return its non-empty output lines.
5110
+ *
5111
+ * Deliberately does NOT swallow failures: this feeds the pre-commit judgment
5112
+ * gate, where an unreadable git state must fail CLOSED. The caller's catch turns
5113
+ * a throw into "verification errored; refusing to commit".
5114
+ */
5115
+ function gitLines(args, cwd) {
5116
+ return execFileSync('git', args, { cwd, encoding: 'utf-8', timeout: 5000 })
5117
+ .split('\n')
5118
+ .map((line) => line.trim())
5119
+ .filter(Boolean);
5120
+ }
5121
+
5122
+ function judgmentCanonDriftError({ treeDrift, projectionDrift, recordDrift }) {
5123
+ const tiers = [
5124
+ ['Tree drift', treeDrift],
5125
+ ['Projection drift', projectionDrift],
5126
+ // R1: records are covered by drift detection, not claimed as enforced.
5127
+ ['Record drift detection', recordDrift],
5128
+ ];
5129
+ const sections = tiers
5130
+ .filter(([, drift]) => drift.length > 0)
5131
+ .map(([label, drift]) => {
5132
+ const paths = drift.map((item) => {
5133
+ if (typeof item === 'string') return ` - ${item}`;
5134
+ return ` - ${item.path} [${item.kind}]`;
5135
+ });
5136
+ return `${label}:\n${paths.join('\n')}`;
5137
+ });
5138
+ const err = new Error(
5139
+ `Judgment canon drift detected; refusing to commit.\n${sections.join('\n')}`,
5140
+ );
5141
+ err.code = 'JUDGMENT_CANON_DRIFT';
5142
+ err.treeDrift = treeDrift;
5143
+ err.projectionDrift = projectionDrift;
5144
+ err.recordDrift = recordDrift;
5145
+ return err;
5146
+ }
5147
+
5148
+ /**
5149
+ * Narrow an executeShipStep return value to the fields the TS engine's
5150
+ * PhaseResult contract declares.
5151
+ *
5152
+ * COMP-SHIP-CONTRACT: engine contracts are STRICT Zod objects — any key the
5153
+ * contract does not declare fails the step. executeShipStep's return carries
5154
+ * caller-facing extras (`commit`, `filesChanged`, `testsAttested`,
5155
+ * `test_count`/`pass_rate`, `error_code`) that Compose's own code consumes but
5156
+ * PhaseResult never declared, so the raw object must NEVER be handed to
5157
+ * stepDone as an `output`. Both sites that do so (runBuild, runGsd) go through
5158
+ * here. Note runBuild's non-`ready` branch passes shipResult as the whole
5159
+ * envelope rather than as `{output}` — a legacy shape that never reaches
5160
+ * contract validation, deliberately left untouched.
5161
+ *
5162
+ * @param {object} shipResult Return value from executeShipStep
5163
+ * @returns {object} `{phase, artifact, outcome, summary}` plus
5164
+ * `files_changed`/`commit_hash` when present
5165
+ */
5166
+ export function toPhaseResultOutput(shipResult) {
5167
+ return {
5168
+ phase: shipResult.phase,
5169
+ artifact: shipResult.artifact,
5170
+ outcome: shipResult.outcome,
5171
+ summary: shipResult.summary,
5172
+ ...(Array.isArray(shipResult.filesChanged) ? { files_changed: shipResult.filesChanged } : {}),
5173
+ ...(typeof shipResult.commit === 'string' ? { commit_hash: shipResult.commit } : {}),
5174
+ };
5175
+ }
5176
+
3815
5177
  /**
3816
5178
  * Execute the ship step: run tests, stage feature files, commit.
3817
5179
  * Returns a PhaseResult-shaped object.
@@ -3834,7 +5196,35 @@ export async function executeShipStep(featureCode, agentCwd, cwd, context, descr
3834
5196
  const builtVia = context?.templateName === 'build-quick' ? 'build-quick' : null;
3835
5197
 
3836
5198
  try {
3837
- // 0. Check if we're in a git repository if not, skip git operations
5199
+ // 0. Run tests FIRSTbefore the git-availability branch.
5200
+ //
5201
+ // COMP-COMPLETION-GATE slice 2: this used to live below, after the non-git
5202
+ // branch had already returned. That meant a non-git build never ran tests at
5203
+ // all and hard-coded `tests_pass: true` on its completion record. With the
5204
+ // gate refusing an unattested completion, every non-git build would have been
5205
+ // refused — and "no repo" is a reason to skip the COMMIT, never a reason to
5206
+ // skip the tests. Both paths now attest identically.
5207
+ if (progress) progress.toolUse('ship', 'Running tests...');
5208
+ let testSummary = { test_count: 0, pass_rate: 0, parsed: false };
5209
+ try {
5210
+ // COMP-TEST-BOOTSTRAP item 128: use the detected test command, not a hard-coded `npm test`.
5211
+ const testFramework = detectTestFramework(agentCwd);
5212
+ const testCommand = testFramework?.command ?? 'npm test';
5213
+ const testOutput = execSync(`${testCommand} 2>&1 || true`, { cwd: agentCwd, encoding: 'utf-8', timeout: 120_000 });
5214
+ testSummary = parseTestSummary(testFramework?.framework, testOutput);
5215
+ } catch { /* test runner unavailable or timed out — testSummary stays unparsed */ }
5216
+ const testsPass = deriveTestsPass(testSummary);
5217
+ // The gate's input: 'passed' | 'failed' | 'no-signal'. Unlike testsPass, an
5218
+ // unreadable run does NOT become an attestation here.
5219
+ const testsAttested = deriveTestsAttested(testSummary);
5220
+ if (progress && testSummary.parsed) {
5221
+ progress.toolUse('ship', `Tests: ${testSummary.test_count} run, ${testSummary.pass_rate}% passing`);
5222
+ }
5223
+ // Hand the evidence to terminalization, which is where completion now happens
5224
+ // (after the health gate). The ship step no longer completes anything itself.
5225
+ context.recordCompletionEvidence?.({ testsAttested, testSummary });
5226
+
5227
+ // 1. Check if we're in a git repository — if not, skip git operations
3838
5228
  let isGitRepo = false;
3839
5229
  try {
3840
5230
  execSync('git rev-parse --is-inside-work-tree', { cwd: agentCwd, encoding: 'utf-8', timeout: 5000, stdio: 'pipe' });
@@ -3843,56 +5233,20 @@ export async function executeShipStep(featureCode, agentCwd, cwd, context, descr
3843
5233
 
3844
5234
  if (!isGitRepo) {
3845
5235
  // COMP-PATHS-EXTERNAL D6b: there is no repo to commit into (e.g. a
3846
- // forge-top-shaped workspace), but the lifecycle must still advance
3847
- // record a commit-less completion (null-SHA) so status flips to COMPLETE.
3848
- // Best-effort: a completion failure must not fail ship.
3849
- let completionWarning = null;
3850
- if (featureCode) {
3851
- try {
3852
- const { recordCompletion } = await import('./completion-writer.js');
3853
- await recordCompletion(cwd, {
3854
- feature_code: featureCode,
3855
- // commit_sha omitted — non-git workspace, stamped with the null-SHA
3856
- // COMP-TEST-BOOTSTRAP-4: this path returns before the test run, so
3857
- // there is no parsed signal — degrade to true (no block).
3858
- tests_pass: true,
3859
- files_changed: [],
3860
- notes: description.split('\n')[0].slice(0, 72),
3861
- ...(builtVia ? { built_via: builtVia } : {}),
3862
- });
3863
- } catch (err) {
3864
- completionWarning = `completion record failed (${err.code || 'UNKNOWN'}): ${err.message}`;
3865
- // eslint-disable-next-line no-console
3866
- console.warn(`[build/ship] ${featureCode}: ${completionWarning}`);
3867
- }
3868
- }
5236
+ // forge-top-shaped workspace). The lifecycle still advances, but the
5237
+ // completion is now written at terminalization by the completion gate,
5238
+ // AFTER the health verdict not here. See COMP-COMPLETION-GATE §2.3c.
3869
5239
  return {
3870
5240
  phase: 'ship',
3871
5241
  artifact: 'no-git',
3872
5242
  outcome: 'complete',
3873
- summary: 'No git repository — wrote artifacts, recorded completion (commit skipped)',
5243
+ summary: 'No git repository — wrote artifacts (commit skipped)',
3874
5244
  commit: null,
3875
- ...(completionWarning ? { completionWarning } : {}),
5245
+ noRepo: true,
5246
+ testsAttested,
3876
5247
  };
3877
5248
  }
3878
5249
 
3879
- // 1. Run feature-relevant tests (best-effort — don't block ship on test infra issues)
3880
- if (progress) progress.toolUse('ship', 'Running tests...');
3881
- // COMP-TEST-BOOTSTRAP-4: parse the run output into a structured signal and
3882
- // derive a real tests_pass for the completion attestation. Degrades to
3883
- // `true` (no block) whenever the output can't be parsed — see deriveTestsPass.
3884
- let testSummary = { test_count: 0, pass_rate: 0, parsed: false };
3885
- try {
3886
- // COMP-TEST-BOOTSTRAP item 128: use detected test command instead of hard-coded npm test
3887
- const testFramework = detectTestFramework(agentCwd);
3888
- const testCommand = testFramework?.command ?? 'npm test';
3889
- const testOutput = execSync(`${testCommand} 2>&1 || true`, { cwd: agentCwd, encoding: 'utf-8', timeout: 120_000 });
3890
- testSummary = parseTestSummary(testFramework?.framework, testOutput);
3891
- } catch { /* test runner not available or timed out — proceed (testSummary stays unparsed) */ }
3892
- const testsPass = deriveTestsPass(testSummary);
3893
- if (progress && testSummary.parsed) {
3894
- progress.toolUse('ship', `Tests: ${testSummary.test_count} run, ${testSummary.pass_rate}% passing`);
3895
- }
3896
5250
 
3897
5251
  // COMP-TRIAGE-5 (E3 Expand): if a lane-triaged feature fails its ship-time
3898
5252
  // test gate, escalate the lane so the NEXT build runs wider. Best-effort —
@@ -3906,6 +5260,7 @@ export async function executeShipStep(featureCode, agentCwd, cwd, context, descr
3906
5260
  // the real feature dir even when opts.cwd differs from process.cwd().
3907
5261
  const _esc = await maybeEscalateLane({ featureCode, provider: _escProvider, featureDir: resolve(cwd, featureDir) });
3908
5262
  if (_esc.action === 'escalate') {
5263
+ context.recordEscalation?.();
3909
5264
  console.warn(`[triage] Test gate failed on lane '${_esc.from}' — escalated to '${_esc.to}'. Re-run to execute the heavier phases.`);
3910
5265
  } else if (_esc.action === 'stop') {
3911
5266
  console.warn(`[triage] Test gate failed and escalation bound reached (lane '${_esc.lane}') — see escalation-checkpoint.md; human review needed.`);
@@ -4032,6 +5387,49 @@ export async function executeShipStep(featureCode, agentCwd, cwd, context, descr
4032
5387
  } catch { /* file might not exist or already staged */ }
4033
5388
  }
4034
5389
 
5390
+ // COMP-CANON-GUARD S5 T5: this verifier is deliberately build-independent
5391
+ // and runs after staging so pre-staged judgment edits cannot slip through.
5392
+ // This is a hard pre-commit gate: unlike best-effort metadata collection,
5393
+ // unexpected verifier failures are rethrown and block the commit because
5394
+ // silently passing would allow an unverified canon to ship.
5395
+ //
5396
+ // Scope boundary (whole-branch review): the canon verified is the WORKSPACE's
5397
+ // (`cwd`). For a cross-repo build (`agentCwd !== cwd`) the commit lands in
5398
+ // another repo, and that repo's own judgment canon — if it has one — is not
5399
+ // verified here. The staged-divergence check below does run in `agentCwd`,
5400
+ // against the tree actually being committed.
5401
+ try {
5402
+ // The verifier reads the WORKING TREE; `git commit` ships the INDEX. A canon
5403
+ // file staged and then restored in the worktree therefore verifies GREEN while
5404
+ // forged bytes go into the commit — a false GREEN, which is the one failure
5405
+ // mode this whole slice exists to prevent. So before trusting the verdict,
5406
+ // require that every STAGED guarded path matches the bytes about to be
5407
+ // verified. A guarded path modified but NOT staged is not this case: it is not
5408
+ // being committed, and worktree drift is what the verifier already covers.
5409
+ const stagedGuarded = new Set(gitLines(['diff', '--cached', '--name-only', '--', JUDGMENT_TREE], agentCwd));
5410
+ const worktreeGuarded = gitLines(['diff', '--name-only', '--', JUDGMENT_TREE], agentCwd);
5411
+ const divergent = worktreeGuarded.filter((p) => stagedGuarded.has(p));
5412
+ if (divergent.length > 0) {
5413
+ throw judgmentCanonDriftError({
5414
+ treeDrift: divergent.map((path) => ({ path, kind: 'staged-differs-from-worktree' })),
5415
+ projectionDrift: [],
5416
+ recordDrift: [],
5417
+ });
5418
+ }
5419
+
5420
+ const { verifyJudgmentCanon } = await import('./judgment-verify.js');
5421
+ const verification = await verifyJudgmentCanon(cwd);
5422
+ if (!verification.ok) throw judgmentCanonDriftError(verification);
5423
+ } catch (err) {
5424
+ // eslint-disable-next-line no-console
5425
+ console.error(
5426
+ err?.code === 'JUDGMENT_CANON_DRIFT'
5427
+ ? `[ship] ${err.message}`
5428
+ : `[ship] Judgment canon verification errored; refusing to commit: ${err.message}`,
5429
+ );
5430
+ throw err;
5431
+ }
5432
+
4035
5433
  // 4. Check if there's anything to commit
4036
5434
  const staged = execSync('git diff --cached --name-only', {
4037
5435
  cwd: agentCwd, encoding: 'utf-8', timeout: 5000,
@@ -4085,34 +5483,25 @@ export async function executeShipStep(featureCode, agentCwd, cwd, context, descr
4085
5483
  } catch { /* metadata best-effort — leave [] */ }
4086
5484
  // If we got nothing from show, fall back to the staged list (still best-effort).
4087
5485
  if (filesChanged.length === 0 && sha) filesChanged = stagedFiles;
5486
+ context.recordFilesChanged?.(filesChanged, { authoritativeShip: true });
4088
5487
 
4089
- // COMP-MCP-MIGRATION: write a commit-bound completion record via the
4090
- // typed writer. The writer flips feature.status to COMPLETE atomically
4091
- // and regenerates ROADMAP.md. Best-effort: completion failures must not
4092
- // downgrade the ship outcome since the commit itself succeeded.
4093
- let completionWarning = null;
4094
- if (sha && featureCode) {
4095
- try {
4096
- const { recordCompletion } = await import('./completion-writer.js');
4097
- await recordCompletion(cwd, {
4098
- feature_code: featureCode,
4099
- commit_sha: sha,
4100
- // COMP-TEST-BOOTSTRAP-4: derived from the parsed test run above
4101
- // (true when unparseable — never a false block).
4102
- tests_pass: testsPass,
4103
- files_changed: filesChanged,
4104
- notes: shortDesc,
4105
- ...(builtVia ? { built_via: builtVia } : {}),
4106
- });
4107
- if (progress) progress.toolUse('ship', `Recorded completion for ${featureCode}`);
4108
- } catch (err) {
4109
- completionWarning = err.code === 'STATUS_FLIP_AFTER_COMPLETION_RECORDED'
4110
- ? `completion recorded but status flip failed: ${err.message}`
4111
- : `completion record failed (${err.code || 'UNKNOWN'}): ${err.message}`;
4112
- // eslint-disable-next-line no-console
4113
- console.warn(`[build/ship] ${featureCode}: ${completionWarning}`);
4114
- }
4115
- }
5488
+ // COMP-COMPLETION-GATE slice 2: the ship step no longer completes the feature.
5489
+ //
5490
+ // It used to call recordCompletion here, catch ANY failure, and still return
5491
+ // a successful ship outcome so a completion could fail silently and the
5492
+ // build marched on regardless. Worse, the terminal block then wrote COMPLETE
5493
+ // again independently, and the health gate that can fail the build runs AFTER
5494
+ // both. A health-rejected build was left marked COMPLETE.
5495
+ //
5496
+ // Ship now collects evidence and stops. Exactly one completion happens, at
5497
+ // terminalization, through the gate, after health. See §2.3, §2.3c.
5498
+ context.recordCompletionEvidence?.({
5499
+ commitSha: sha,
5500
+ filesChanged,
5501
+ notes: shortDesc,
5502
+ builtVia,
5503
+ testsAttested,
5504
+ });
4116
5505
 
4117
5506
  // COMP-PATHS-EXTERNAL D6a: if ROADMAP / the feature folder resolved into a
4118
5507
  // DIFFERENT git repo, they were written but not committed here — tell the
@@ -4128,11 +5517,11 @@ export async function executeShipStep(featureCode, agentCwd, cwd, context, descr
4128
5517
  : `Committed: ${commitMsg} (${stagedFiles.length} files)`,
4129
5518
  commit: sha,
4130
5519
  filesChanged,
5520
+ testsAttested,
4131
5521
  // COMP-MODEL-AB: thread structured test counts into the step result so the
4132
5522
  // main loop can persist them to build-history.jsonl for metrics consumers.
4133
5523
  // Only present when testSummary.parsed=true (framework detected + output parsed).
4134
5524
  ...(testSummary.parsed ? { test_count: testSummary.test_count, pass_rate: testSummary.pass_rate } : {}),
4135
- ...(completionWarning ? { completionWarning } : {}),
4136
5525
  };
4137
5526
 
4138
5527
  } catch (err) {
@@ -4141,6 +5530,7 @@ export async function executeShipStep(featureCode, agentCwd, cwd, context, descr
4141
5530
  artifact: '',
4142
5531
  outcome: 'failed',
4143
5532
  summary: `Ship failed: ${err.message}`,
5533
+ ...(typeof err?.code === 'string' ? { error_code: err.code } : {}),
4144
5534
  };
4145
5535
  }
4146
5536
  }
@@ -4304,6 +5694,35 @@ async function pollGateResolution(visionWriter, gateId, intervalMs = 2000) {
4304
5694
  */
4305
5695
  export const MAX_GATE_REENTRIES = 20;
4306
5696
 
5697
+ /**
5698
+ * Decide how a merge gate answers a consumer-merge failure.
5699
+ *
5700
+ * The first failure routes to the gate's repair path (`on_revise`, else kill).
5701
+ * A failure that repeats BYTE-IDENTICALLY for the same gate is not going to be
5702
+ * fixed by re-running the fan-out — the lanes reproduced the same conflict —
5703
+ * so the gate kills instead of paying for another round. Anything different
5704
+ * (a new code, a different file) is genuine progress and revises as before.
5705
+ *
5706
+ * @param {string|undefined} previousFailure - `${code}: ${message}` of the last failure at this gate
5707
+ * @param {string} failure - this round's `${code}: ${message}`
5708
+ * @param {'revise'|'kill'} repairOutcome - the gate's configured repair route
5709
+ * @returns {{ outcome: 'revise'|'kill', rationale: string, repeated: boolean }}
5710
+ */
5711
+ export function decideMergeRepairOutcome(previousFailure, failure, repairOutcome) {
5712
+ const repeated = previousFailure !== undefined && previousFailure === failure;
5713
+ if (repairOutcome === 'revise' && repeated) {
5714
+ return {
5715
+ outcome: 'kill',
5716
+ repeated,
5717
+ rationale: `${failure} — identical to the previous round's failure at this gate; `
5718
+ + 'the fan-out reproduces the same conflict, so revising would only re-dispatch every '
5719
+ + 'lane for the same result. Killed to stop spending. A killed build is not resumable: '
5720
+ + 'fix the conflict (usually lanes editing the same file), then re-run with --fresh.',
5721
+ };
5722
+ }
5723
+ return { outcome: repairOutcome, repeated, rationale: failure };
5724
+ }
5725
+
4307
5726
  export function assertGateReentryWithinCap(count, stepId, cap = MAX_GATE_REENTRIES) {
4308
5727
  if (count > cap) {
4309
5728
  throw new Error(
@@ -4417,5 +5836,15 @@ export async function abortBuild(dataDir, featureCode, cwd, opts = {}) {
4417
5836
 
4418
5837
  // Write terminal state (file retained per STRAT-COMP-4 contract)
4419
5838
  writeActiveBuild(dataDir, { ...active, status: 'aborted', completedAt: new Date().toISOString() });
5839
+ const accumulator = readBuildAccumulator(cwd, active.featureCode);
5840
+ if (accumulator) {
5841
+ emitBuildActuals(cwd, accumulator, 'aborted');
5842
+ }
4420
5843
  console.log('Build aborted.');
4421
5844
  }
5845
+
5846
+ /** Optional revision failures keep the draft; only user control or uncertain teardown stops the build. */
5847
+ export function policyRevisionMustStop(error) {
5848
+ return error instanceof UserInterruptError
5849
+ || ['CANCELLATION_UNCONFIRMED', 'CANCELLATION_TEARDOWN_TIMEOUT'].includes(error?.code);
5850
+ }