@smartmemory/compose 0.3.5-beta → 0.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/.claude/skills/compose/SKILL.md +42 -88
  2. package/bin/compose.js +288 -0
  3. package/bin/git-hooks/pre-push.template +29 -0
  4. package/bin/judgment-import.js +572 -0
  5. package/contracts/feature-json.schema.json +5 -0
  6. package/contracts/judgment-record.schema.json +821 -0
  7. package/dist/assets/App-PkZzHeMj.js +894 -0
  8. package/dist/assets/_baseUniq-Bo837sRJ.js +1 -0
  9. package/dist/assets/arc-BafGpyqE.js +1 -0
  10. package/dist/assets/architectureDiagram-Q4EWVU46-BOBfUsqL.js +36 -0
  11. package/dist/assets/blockDiagram-DXYQGD6D-Dwodev1a.js +132 -0
  12. package/dist/assets/{browser-BSM23If2.js → browser-1ntj1-x_.js} +6 -6
  13. package/dist/assets/{c4Diagram-LMCZKHZV-CUmTCrMW.js → c4Diagram-AHTNJAMY-CU_bhYag.js} +1 -1
  14. package/dist/assets/channel-qVK_qn4E.js +1 -0
  15. package/dist/assets/{chunk-JWPE2WC7-DQFs7gjy.js → chunk-4BX2VUAB-p8WsDwnO.js} +1 -1
  16. package/dist/assets/chunk-4TB4RGXK-B8h7-eR0.js +206 -0
  17. package/dist/assets/{chunk-XXDRQBXY-CSdt06Hp.js → chunk-55IACEB6-DxeEr98s.js} +1 -1
  18. package/dist/assets/{chunk-VR4S4FIN-Dvhgnm7E.js → chunk-EDXVE4YY-BYt8F151.js} +1 -1
  19. package/dist/assets/{chunk-5VM5RSS4-Qw-L7OoJ.js → chunk-FMBD7UC4-DGSOVeie.js} +1 -1
  20. package/dist/assets/chunk-OYMX7WX6-B-QdgYR2.js +231 -0
  21. package/dist/assets/{chunk-2Q5K7J3B-Cyvi1AFH.js → chunk-QZHKN3VN-Du5UAZLs.js} +1 -1
  22. package/dist/assets/{chunk-32BRIVSS-B1y5FpOd.js → chunk-YZCP3GAM-C8JbNBSk.js} +1 -1
  23. package/dist/assets/classDiagram-6PBFFD2Q-B8UcfC1q.js +1 -0
  24. package/dist/assets/classDiagram-v2-HSJHXN6E-B8UcfC1q.js +1 -0
  25. package/dist/assets/clone-Pu3RyLUh.js +1 -0
  26. package/dist/assets/{cose-bilkent-JH36ORCC-G8l05tXy.js → cose-bilkent-S5V4N54A-O1ESaqge.js} +1 -1
  27. package/dist/assets/dagre-KV5264BT-CPTmFPHw.js +4 -0
  28. package/dist/assets/diagram-5BDNPKRD-B3PNrWs5.js +10 -0
  29. package/dist/assets/diagram-G4DWMVQ6-Cscfr6vc.js +24 -0
  30. package/dist/assets/diagram-MMDJMWI5-CSfqZ-TM.js +43 -0
  31. package/dist/assets/diagram-TYMM5635-Cg4aYS7W.js +24 -0
  32. package/dist/assets/erDiagram-SMLLAGMA-_ZqwG5pl.js +85 -0
  33. package/dist/assets/flowDiagram-DWJPFMVM-C83boxFT.js +162 -0
  34. package/dist/assets/ganttDiagram-T4ZO3ILL-CWnIjuEi.js +292 -0
  35. package/dist/assets/gitGraphDiagram-UUTBAWPF-DrMdxZfH.js +106 -0
  36. package/dist/assets/graph-Bi99_6Yf.js +331 -0
  37. package/dist/assets/graph-RE4I7Ty7.js +1 -0
  38. package/dist/assets/index-Rm2RE-c0.js +123 -0
  39. package/dist/assets/infoDiagram-42DDH7IO-BLmP4Epr.js +2 -0
  40. package/dist/assets/{ishikawaDiagram-FXEZZL3T-Hm4zSDlM.js → ishikawaDiagram-UXIWVN3A-yuWWshKN.js} +5 -5
  41. package/dist/assets/{journeyDiagram-5HDEW3XC-CIqcTFuO.js → journeyDiagram-VCZTEJTY-BOfhaJov.js} +1 -1
  42. package/dist/assets/{kanban-definition-HUTT4EX6-CB9jCXj_.js → kanban-definition-6JOO6SKY-Bbolde15.js} +7 -7
  43. package/dist/assets/katex-DkKDou_j.js +257 -0
  44. package/dist/assets/layout-BSf33zm8.js +1 -0
  45. package/dist/assets/{linear-CsXqv70E.js → linear-AvSTWMqx.js} +1 -1
  46. package/dist/assets/min-QBM8H4xN.js +1 -0
  47. package/dist/assets/{mindmap-definition-LN4V7U3C-CpqH5F_P.js → mindmap-definition-QFDTVHPH-BuvgtqIc.js} +7 -7
  48. package/dist/assets/{mobile-CaoXUwAr.js → mobile-BnXEOE3U.js} +2 -2
  49. package/dist/assets/pieDiagram-DEJITSTG-DIzF16vh.js +30 -0
  50. package/dist/assets/quadrantDiagram-34T5L4WZ-D-mbUIjS.js +7 -0
  51. package/dist/assets/{requirementDiagram-TGXJPOKE-D1lG1P7h.js → requirementDiagram-MS252O5E-CEs4kCLd.js} +3 -3
  52. package/dist/assets/sankeyDiagram-XADWPNL6-DFsnCr9n.js +10 -0
  53. package/dist/assets/sequenceDiagram-FGHM5R23-BEJYdTjQ.js +157 -0
  54. package/dist/assets/stateDiagram-FHFEXIEX-BBXs57uY.js +1 -0
  55. package/dist/assets/stateDiagram-v2-QKLJ7IA2-BqKuX4rj.js +1 -0
  56. package/dist/assets/{timeline-definition-FHXFAJF6-CzqqUDvt.js → timeline-definition-GMOUNBTQ-BGvLoVAY.js} +3 -3
  57. package/dist/assets/vennDiagram-DHZGUBPP-9LaBTMe0.js +34 -0
  58. package/dist/assets/wardley-RL74JXVD-P4MEqMTP.js +162 -0
  59. package/dist/assets/wardleyDiagram-NUSXRM2D-o-tmxnlC.js +20 -0
  60. package/dist/assets/xychartDiagram-5P7HB3ND-Dpn7V6qk.js +7 -0
  61. package/dist/index.html +2 -2
  62. package/lib/bug-escalation.js +30 -4
  63. package/lib/build.js +777 -52
  64. package/lib/canon-guard.js +223 -0
  65. package/lib/canon-registry.js +187 -0
  66. package/lib/codex-preflight.js +26 -4
  67. package/lib/dispatch-ledger.js +301 -0
  68. package/lib/dispatch-metrics.js +236 -0
  69. package/lib/experiment-judge.js +6 -1
  70. package/lib/feature-writer.js +9 -0
  71. package/lib/gsd.js +11 -2
  72. package/lib/hooks-status.js +32 -3
  73. package/lib/idempotency.js +33 -8
  74. package/lib/judgment/schema.js +23 -0
  75. package/lib/judgment/store/index.js +207 -0
  76. package/lib/judgment/store/records.js +405 -0
  77. package/lib/judgment-attest.js +259 -0
  78. package/lib/judgment-gen.js +691 -0
  79. package/lib/judgment-verify.js +153 -0
  80. package/lib/judgment-write-guard.js +230 -0
  81. package/lib/judgment-writer.js +3403 -0
  82. package/lib/lane-gate.js +2 -0
  83. package/lib/lifecycle-modes.js +42 -0
  84. package/lib/local-claude-connector.js +199 -54
  85. package/lib/mcp-enforcement.js +21 -35
  86. package/lib/result-normalizer.js +93 -15
  87. package/lib/review-normalize.js +4 -0
  88. package/lib/stratum-mcp-client.js +131 -6
  89. package/package.json +2 -2
  90. package/server/compose-mcp-tools.js +49 -1
  91. package/server/compose-mcp.js +220 -0
  92. package/server/mcp-tool-policy.js +1 -0
  93. package/dist/assets/App-BG92y_CY.js +0 -896
  94. package/dist/assets/abnfDiagram-VRR7QNED-Bip6cMjP.js +0 -1
  95. package/dist/assets/arc-CyMTM07c.js +0 -1
  96. package/dist/assets/architectureDiagram-ZJ3FMSHR-BukV0CJw.js +0 -36
  97. package/dist/assets/blockDiagram-677ZJIJ3-Bjb_MNB4.js +0 -132
  98. package/dist/assets/channel-BzkmtaLy.js +0 -1
  99. package/dist/assets/chunk-EX3LRPZG-G_MZEji_.js +0 -231
  100. package/dist/assets/chunk-MOJQB5TN-CQZQYMcz.js +0 -88
  101. package/dist/assets/chunk-RYQCIY6F-CmAda4sZ.js +0 -1
  102. package/dist/assets/chunk-V7JOEXUC-CbjBz-Ek.js +0 -206
  103. package/dist/assets/classDiagram-OUVF2IWQ-BIvLcbex.js +0 -1
  104. package/dist/assets/classDiagram-v2-EOCWNBFH-BIvLcbex.js +0 -1
  105. package/dist/assets/cynefin-VYW2F7L2-C3n1PNJC.js +0 -178
  106. package/dist/assets/cynefinDiagram-TSTJHNR4-C8N_CvKJ.js +0 -62
  107. package/dist/assets/dagre-VKFMJZFB-B2pkwDhN.js +0 -4
  108. package/dist/assets/diagram-FQU43EPY-DPHD_t-1.js +0 -3
  109. package/dist/assets/diagram-G47NLZAW-DGmHIZRr.js +0 -24
  110. package/dist/assets/diagram-NH7WQ7WH-BlLWwKVt.js +0 -24
  111. package/dist/assets/diagram-OA4YK3LP-DEv704Re.js +0 -30
  112. package/dist/assets/diagram-WEI45ONY-Bj6PGUCe.js +0 -41
  113. package/dist/assets/ebnfDiagram-CCIWWBDH-Ch-icksT.js +0 -1
  114. package/dist/assets/erDiagram-Q63AITRT-CI3lwEFA.js +0 -85
  115. package/dist/assets/flowDiagram-23GEKE2U-DcUANpAz.js +0 -156
  116. package/dist/assets/ganttDiagram-NO4QXBWP-Cy0Ie8KS.js +0 -292
  117. package/dist/assets/gitGraphDiagram-IHSO6WYX-BswI52az.js +0 -106
  118. package/dist/assets/graph-BXPQrYYB.js +0 -331
  119. package/dist/assets/graph-C9eacEi8.js +0 -1
  120. package/dist/assets/index-B1H7-G2y.js +0 -119
  121. package/dist/assets/infoDiagram-FWYZ7A6U-BBunqvr-.js +0 -2
  122. package/dist/assets/katex-C5jXJg4s.js +0 -257
  123. package/dist/assets/layout-DEXfKzaS.js +0 -1
  124. package/dist/assets/map-Czzmt4hB.js +0 -1
  125. package/dist/assets/pegDiagram-2B236MQR-HpQyrDcm.js +0 -1
  126. package/dist/assets/pieDiagram-ENE6RG2P-xkcpHwnY.js +0 -39
  127. package/dist/assets/quadrantDiagram-ABIIQ3AL-kebZIOmu.js +0 -7
  128. package/dist/assets/railroadDiagram-RFXS5EU6-DNvGwWm9.js +0 -1
  129. package/dist/assets/sankeyDiagram-HTMAVEWB-DOEEcn52.js +0 -40
  130. package/dist/assets/sequenceDiagram-DBY2YBRQ-DynIVCk_.js +0 -162
  131. package/dist/assets/sizeCapture-X5ZJPWSS-DJXFjUUL.js +0 -1
  132. package/dist/assets/stateDiagram-2N3HPSRC-OZ7trFh5.js +0 -1
  133. package/dist/assets/stateDiagram-v2-6OUMAXLB-GcPRHAJT.js +0 -1
  134. package/dist/assets/swimlanes-5IMT3BWC-DULYAFmB.js +0 -2
  135. package/dist/assets/swimlanesDiagram-G3AALYLV-BB0gCy01.js +0 -8
  136. package/dist/assets/vennDiagram-L72KCM5P-CdKmtcFL.js +0 -34
  137. package/dist/assets/wardleyDiagram-EHGQE667-DknGwjCA.js +0 -78
  138. package/dist/assets/xychartDiagram-FW5EYKEG-DdYyKfQ3.js +0 -7
package/lib/build.js CHANGED
@@ -75,6 +75,7 @@ import {
75
75
  recoverAdvancedConsumerArtifacts,
76
76
  verifyConsumerRunRevision,
77
77
  } from './consumer-fanout.js';
78
+ import { appendEvent as appendDispatchEvent, readEvents as readDispatchEvents } from './dispatch-ledger.js';
78
79
 
79
80
  // ---------------------------------------------------------------------------
80
81
  // COMP-ROADMAP-PLAN S8: gate the `ship` interception by mode.
@@ -563,6 +564,11 @@ function isStaleOrDuplicateConsumerReportError(error) {
563
564
  || /\bstep is not awaiting a client result\b/i.test(message);
564
565
  }
565
566
 
567
+ function responseReissuesStep(response, stepId) {
568
+ if (response?.status !== 'ready' || !Array.isArray(response.ready)) return false;
569
+ return response.ready.some((ready) => ready?.id === stepId && ready?.previousFailure);
570
+ }
571
+
566
572
  async function reportConsumerStepDone({
567
573
  descriptor,
568
574
  flowId,
@@ -809,6 +815,14 @@ export async function runConsumerIssuance({
809
815
  reviewMode: reviewOpts.reviewMode,
810
816
  confidenceGate: reviewOpts.confidenceGate,
811
817
  lens: reviewOpts.lens,
818
+ telemetry: {
819
+ site: context.gsd ? 'gsd' : 'consumer',
820
+ project_cwd: context.projectCwd ?? context.cwd,
821
+ build_id: context.build_id,
822
+ feature_code: context.featureCode,
823
+ step_id: descriptor.id,
824
+ ...(typeof descriptor.attempt === 'number' ? { attempt: descriptor.attempt } : {}),
825
+ },
812
826
  // V2/V3: the isolation:none review fanout is the safety-critical controlled
813
827
  // execution — a claude review item runs via the compose-local connector so
814
828
  // its read-only tool restrictions actually BIND (the engine's sync
@@ -841,6 +855,8 @@ export async function runConsumerIssuance({
841
855
  mainResult = {
842
856
  result: { outcome: 'failed', summary: `Timed out after ${Math.round(error.durationMs / 1000)}s` },
843
857
  normalizationFailure: error.message,
858
+ dispatchIds: { primary: error.dispatchId ?? null, repair: null },
859
+ settlementFailureClass: 'agent',
844
860
  // G3: a timed-out run still consumed billable usage — forward it so the
845
861
  // failure envelope debits the engine ledger (same mechanism as F3).
846
862
  ...(error && typeof error === 'object' && error.usage ? { usage: error.usage } : {}),
@@ -855,6 +871,8 @@ export async function runConsumerIssuance({
855
871
  mainResult = {
856
872
  result: { outcome: 'failed', summary: `Agent error: ${reason}` },
857
873
  normalizationFailure: reason,
874
+ dispatchIds: { primary: error?.dispatchId ?? null, repair: null },
875
+ settlementFailureClass: 'agent',
858
876
  // F3: a failed run still consumed billable usage — the connector attaches
859
877
  // it to the error. Forward it so the failure envelope (and compose's
860
878
  // cumulative ledger) debit the attempt instead of letting failures evade
@@ -936,6 +954,17 @@ export async function runConsumerIssuance({
936
954
  });
937
955
  if (report.skipped) return report.response;
938
956
  const { response } = report;
957
+ if (!context.gsd && typeof context?.settleDispatches === 'function') {
958
+ const isEnsureRetry = responseReissuesStep(response, descriptor.id);
959
+ context.settleDispatches({
960
+ stepId: descriptor.id,
961
+ dispatchIds: mainResult.dispatchIds,
962
+ accepted: !localFailure && !isEnsureRetry,
963
+ failureClass: mainResult.settlementFailureClass
964
+ ?? (normalizationFailure ? 'normalization' : (localFailure ? 'agent' : null)),
965
+ isEnsureRetry,
966
+ });
967
+ }
939
968
  if (typeof artifacts.hooks.afterStepDone === 'function') {
940
969
  await artifacts.hooks.afterStepDone({ descriptor, envelope, response });
941
970
  }
@@ -1215,6 +1244,266 @@ function writeActiveBuild(dataDir, state) {
1215
1244
  renameSync(tmp, target);
1216
1245
  }
1217
1246
 
1247
+ const BUILD_ACCUMULATOR_VERSION = 1;
1248
+ const BUILD_ACCUMULATOR_FIELDS = new Set([
1249
+ 'v',
1250
+ 'build_id',
1251
+ 'feature_code',
1252
+ 'last_terminal',
1253
+ 'review_iterations',
1254
+ 'escalations',
1255
+ 'files_changed',
1256
+ 'ship_files_changed',
1257
+ 'test_count',
1258
+ 'pass_rate',
1259
+ 'tokens_total',
1260
+ 'usd',
1261
+ ]);
1262
+ 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;
1263
+
1264
+ function assertFeatureCodeForAccumulator(featureCode) {
1265
+ if (typeof featureCode !== 'string' || featureCode.length === 0 || /[/\\]/.test(featureCode)) {
1266
+ throw new Error('Build accumulator feature code must be a non-empty path-safe string');
1267
+ }
1268
+ }
1269
+
1270
+ export function buildAccumulatorPath(projectCwd, featureCode) {
1271
+ assertFeatureCodeForAccumulator(featureCode);
1272
+ return join(projectCwd, '.compose', 'data', 'build-accumulator', `${featureCode}.json`);
1273
+ }
1274
+
1275
+ function validateBuildAccumulator(value, expectedFeatureCode = null) {
1276
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
1277
+ throw new Error('Build accumulator is corrupt: expected an object');
1278
+ }
1279
+ for (const key of Object.keys(value)) {
1280
+ if (!BUILD_ACCUMULATOR_FIELDS.has(key)) {
1281
+ throw new Error(`Build accumulator is corrupt: unknown field "${key}"`);
1282
+ }
1283
+ }
1284
+ for (const key of BUILD_ACCUMULATOR_FIELDS) {
1285
+ if (!Object.hasOwn(value, key)) {
1286
+ throw new Error(`Build accumulator is corrupt: missing field "${key}"`);
1287
+ }
1288
+ }
1289
+ if (value.v !== BUILD_ACCUMULATOR_VERSION) {
1290
+ throw new Error(`Build accumulator is corrupt: unsupported version ${value.v}`);
1291
+ }
1292
+ if (typeof value.build_id !== 'string' || !UUID_RE.test(value.build_id)) {
1293
+ throw new Error('Build accumulator is corrupt: build_id must be a UUID');
1294
+ }
1295
+ if (typeof value.feature_code !== 'string' || value.feature_code.length === 0) {
1296
+ throw new Error('Build accumulator is corrupt: feature_code must be a non-empty string');
1297
+ }
1298
+ if (expectedFeatureCode !== null && value.feature_code !== expectedFeatureCode) {
1299
+ throw new Error(
1300
+ `Build accumulator feature identity mismatch: expected ${expectedFeatureCode}, found ${value.feature_code}`,
1301
+ );
1302
+ }
1303
+ if (![null, 'failed', 'complete', 'aborted'].includes(value.last_terminal)) {
1304
+ throw new Error('Build accumulator is corrupt: invalid last_terminal');
1305
+ }
1306
+ for (const key of ['review_iterations', 'escalations']) {
1307
+ if (!Number.isInteger(value[key]) || value[key] < 0) {
1308
+ throw new Error(`Build accumulator is corrupt: ${key} must be a non-negative integer`);
1309
+ }
1310
+ }
1311
+ if (!Array.isArray(value.files_changed) || !value.files_changed.every((file) => typeof file === 'string')) {
1312
+ throw new Error('Build accumulator is corrupt: files_changed must be a string array');
1313
+ }
1314
+ if (value.ship_files_changed !== null
1315
+ && (!Array.isArray(value.ship_files_changed)
1316
+ || !value.ship_files_changed.every((file) => typeof file === 'string'))) {
1317
+ throw new Error('Build accumulator is corrupt: ship_files_changed must be null or a string array');
1318
+ }
1319
+ for (const key of ['test_count', 'pass_rate']) {
1320
+ if (value[key] !== null && (typeof value[key] !== 'number' || !Number.isFinite(value[key]))) {
1321
+ throw new Error(`Build accumulator is corrupt: ${key} must be null or a finite number`);
1322
+ }
1323
+ }
1324
+ for (const key of ['tokens_total', 'usd']) {
1325
+ if (typeof value[key] !== 'number' || !Number.isFinite(value[key]) || value[key] < 0) {
1326
+ throw new Error(`Build accumulator is corrupt: ${key} must be a non-negative finite number`);
1327
+ }
1328
+ }
1329
+ return value;
1330
+ }
1331
+
1332
+ export function readBuildAccumulator(projectCwd, featureCode) {
1333
+ const path = buildAccumulatorPath(projectCwd, featureCode);
1334
+ if (!existsSync(path)) return null;
1335
+ let parsed;
1336
+ try {
1337
+ parsed = JSON.parse(readFileSync(path, 'utf8'));
1338
+ } catch (error) {
1339
+ throw new Error(`Build accumulator is corrupt at ${path}: ${error.message}`);
1340
+ }
1341
+ return validateBuildAccumulator(parsed, featureCode);
1342
+ }
1343
+
1344
+ export function writeBuildAccumulator(projectCwd, accumulator) {
1345
+ validateBuildAccumulator(accumulator, accumulator?.feature_code ?? null);
1346
+ const target = buildAccumulatorPath(projectCwd, accumulator.feature_code);
1347
+ mkdirSync(dirname(target), { recursive: true });
1348
+ const tmp = `${target}.tmp`;
1349
+ writeFileSync(tmp, JSON.stringify(accumulator, null, 2));
1350
+ renameSync(tmp, target);
1351
+ return accumulator;
1352
+ }
1353
+
1354
+ export function clearBuildAccumulator(projectCwd, featureCode) {
1355
+ const path = buildAccumulatorPath(projectCwd, featureCode);
1356
+ if (existsSync(path)) unlinkSync(path);
1357
+ }
1358
+
1359
+ export function newBuildAccumulatorRecord(featureCode) {
1360
+ return {
1361
+ v: BUILD_ACCUMULATOR_VERSION,
1362
+ build_id: randomUUID(),
1363
+ feature_code: featureCode,
1364
+ last_terminal: null,
1365
+ review_iterations: 0,
1366
+ escalations: 0,
1367
+ files_changed: [],
1368
+ ship_files_changed: null,
1369
+ test_count: null,
1370
+ pass_rate: null,
1371
+ tokens_total: 0,
1372
+ usd: 0,
1373
+ };
1374
+ }
1375
+
1376
+ export function createBuildAccumulator(projectCwd, featureCode) {
1377
+ return writeBuildAccumulator(projectCwd, newBuildAccumulatorRecord(featureCode));
1378
+ }
1379
+
1380
+ export function updateBuildAccumulator(projectCwd, featureCode, mutate) {
1381
+ const current = readBuildAccumulator(projectCwd, featureCode);
1382
+ if (!current) {
1383
+ throw new Error(`Build accumulator not found for ${featureCode}`);
1384
+ }
1385
+ const next = typeof mutate === 'function' ? mutate({ ...current }) : { ...current, ...mutate };
1386
+ validateBuildAccumulator(next, featureCode);
1387
+ return writeBuildAccumulator(projectCwd, next);
1388
+ }
1389
+
1390
+ export function selectBuildAccumulator(projectCwd, featureCode, { fresh = false } = {}) {
1391
+ const existing = readBuildAccumulator(projectCwd, featureCode);
1392
+ if (fresh || !existing || ['complete', 'aborted'].includes(existing.last_terminal)) {
1393
+ // Candidate only — NOTHING persists until the caller owns the attempt. A
1394
+ // concurrent live build may refuse this invocation, and persisting here
1395
+ // (e.g. on --fresh) would clobber the live build's sidecar mid-run.
1396
+ return { accumulator: newBuildAccumulatorRecord(featureCode), isNew: true };
1397
+ }
1398
+ return { accumulator: existing, isNew: false };
1399
+ }
1400
+
1401
+ function ledgerEstimateSource(source) {
1402
+ if (source === 'escalated') return 'escalated';
1403
+ if (source === 'cached') return 'cached';
1404
+ if (source === 'front' || source === 'refined' || source === 'fresh') return 'fresh';
1405
+ throw new Error(`Unsupported triage estimate source "${source}"`);
1406
+ }
1407
+
1408
+ export function emitTriageEstimate(projectCwd, estimate) {
1409
+ return appendDispatchEvent(projectCwd, {
1410
+ kind: 'triage-estimate',
1411
+ build_id: estimate.build_id,
1412
+ feature_code: estimate.feature_code,
1413
+ tier: estimate.triageTier ?? estimate.tier,
1414
+ lane: estimate.lane,
1415
+ profile: estimate.profile,
1416
+ estimate_source: ledgerEstimateSource(estimate.estimateSource ?? estimate.estimate_source),
1417
+ confidence: estimate.triageConfidence ?? estimate.confidence ?? null,
1418
+ });
1419
+ }
1420
+
1421
+ export function emitBuildActuals(projectCwd, accumulator, terminalStatus) {
1422
+ validateBuildAccumulator(accumulator, accumulator?.feature_code ?? null);
1423
+ const persisted = readBuildAccumulator(projectCwd, accumulator.feature_code);
1424
+ if (!persisted || persisted.build_id !== accumulator.build_id) {
1425
+ throw new Error(`Build accumulator identity mismatch while finalizing ${accumulator.feature_code}`);
1426
+ }
1427
+ // COMP-TRIAGE-6-4: the ledger row is the durable, ACRR-consumed artifact —
1428
+ // append it FIRST, then do the best-effort accumulator marker/clear. The row
1429
+ // fields are all read from `persisted` (none depends on last_terminal), so
1430
+ // this is byte-identical on the happy path. Row-first makes the two writes
1431
+ // effectively atomic for every terminalization path (fresh, --fresh, resume,
1432
+ // fresh-over-failed): a crash between them can leave a stale marker/sidecar
1433
+ // (harmless — rotated away on the next build) but can NEVER lose a row or
1434
+ // strand a `last_terminal='failed'` marker with no row behind it. Since
1435
+ // emitBuildActuals is the ONLY writer of `last_terminal='failed'`, no other
1436
+ // path can produce that orphan either.
1437
+ const authoritativeShip = Array.isArray(persisted.ship_files_changed);
1438
+ const files = authoritativeShip
1439
+ ? persisted.ship_files_changed
1440
+ : persisted.files_changed;
1441
+ const row = appendDispatchEvent(projectCwd, {
1442
+ kind: 'build-actuals',
1443
+ build_id: persisted.build_id,
1444
+ feature_code: persisted.feature_code,
1445
+ terminal_status: terminalStatus,
1446
+ files_changed_count: new Set(files).size,
1447
+ files_source: authoritativeShip ? 'ship' : 'accumulated',
1448
+ review_iterations: persisted.review_iterations,
1449
+ escalations: persisted.escalations,
1450
+ tokens_total: persisted.tokens_total,
1451
+ usd: persisted.usd,
1452
+ test_count: persisted.test_count,
1453
+ pass_rate: persisted.pass_rate,
1454
+ });
1455
+ if (terminalStatus === 'failed') {
1456
+ updateBuildAccumulator(projectCwd, persisted.feature_code, (current) => ({
1457
+ ...current,
1458
+ last_terminal: 'failed',
1459
+ }));
1460
+ } else if (terminalStatus === 'complete' || terminalStatus === 'aborted') {
1461
+ clearBuildAccumulator(projectCwd, persisted.feature_code);
1462
+ }
1463
+ return row;
1464
+ }
1465
+
1466
+ export function settleDispatches(projectCwd, buildId, stepId, {
1467
+ dispatchIds,
1468
+ accepted,
1469
+ failureClass = null,
1470
+ isEnsureRetry = false,
1471
+ gsd = false,
1472
+ } = {}) {
1473
+ if (gsd) return [];
1474
+ const primary = dispatchIds?.primary;
1475
+ const repair = dispatchIds?.repair;
1476
+ if (!primary && !repair) return [];
1477
+ const rows = [];
1478
+ const appendSettlement = (dispatchId, isAccepted, rejectedClass) => {
1479
+ if (typeof dispatchId !== 'string' || dispatchId.length === 0) return;
1480
+ rows.push(appendDispatchEvent(projectCwd, {
1481
+ kind: 'settlement',
1482
+ dispatch_id: dispatchId,
1483
+ accepted: isAccepted,
1484
+ ...(typeof buildId === 'string' && buildId.length > 0 ? { build_id: buildId } : {}),
1485
+ ...(typeof stepId === 'string' && stepId.length > 0 ? { step_id: stepId } : {}),
1486
+ ...(!isAccepted ? { failure_class: rejectedClass ?? 'agent' } : {}),
1487
+ }));
1488
+ };
1489
+
1490
+ if (repair) {
1491
+ appendSettlement(primary, false, 'normalization');
1492
+ appendSettlement(
1493
+ repair,
1494
+ isEnsureRetry ? false : accepted === true,
1495
+ isEnsureRetry ? 'ensure-retry' : failureClass,
1496
+ );
1497
+ } else {
1498
+ appendSettlement(
1499
+ primary,
1500
+ isEnsureRetry ? false : accepted === true,
1501
+ isEnsureRetry ? 'ensure-retry' : failureClass,
1502
+ );
1503
+ }
1504
+ return rows;
1505
+ }
1506
+
1218
1507
  /**
1219
1508
  * Decide how a compose build invocation should start.
1220
1509
  *
@@ -1391,7 +1680,17 @@ function makeAskAgent(stratum, context, gateDispatch, gateExtras) {
1391
1680
  `${fileRef} this question concisely:\n\n` +
1392
1681
  `${question}\n\n` +
1393
1682
  `Keep your answer brief — 2-3 sentences max.`;
1394
- const text = await stratum.runAgentText('claude', qaPrompt, { cwd: context.cwd });
1683
+ const text = await stratum.runAgentText('claude', qaPrompt, {
1684
+ cwd: context.cwd,
1685
+ telemetry: {
1686
+ site: 'gate-qa',
1687
+ project_cwd: context.projectCwd ?? context.cwd,
1688
+ build_id: context.build_id,
1689
+ feature_code: context.featureCode,
1690
+ step_id: gateDispatch.step_id ?? gateDispatch.id,
1691
+ ...(typeof gateDispatch.attempt === 'number' ? { attempt: gateDispatch.attempt } : {}),
1692
+ },
1693
+ });
1395
1694
  return text || '(no answer)';
1396
1695
  };
1397
1696
  }
@@ -1638,6 +1937,87 @@ export async function runBuild(featureCode, opts = {}) {
1638
1937
  return;
1639
1938
  }
1640
1939
 
1940
+ const {
1941
+ accumulator: selectedAccumulator,
1942
+ isNew: isNewAccumulator,
1943
+ } = selectBuildAccumulator(cwd, featureCode, { fresh: opts.fresh === true });
1944
+ let build_id = selectedAccumulator.build_id;
1945
+ // Tracks the record this attempt actually owns: the reused sidecar, the
1946
+ // in-memory candidate (persisted at the ownership point after the start
1947
+ // verdict), or the rotated record on a fresh-over-failed start.
1948
+ let activeAccumulator = selectedAccumulator;
1949
+ let accumulatorPersisted = !isNewAccumulator;
1950
+ const buildStartedAt = new Date().toISOString();
1951
+ let buildStatus = 'failed';
1952
+ let attemptStarted = true;
1953
+ let attemptFinalized = false;
1954
+ // COMP-TRIAGE-6-4: function-scoped so finalizeBuildAttempt's ownership guard
1955
+ // can read them (they are assigned/read from inside the main try below).
1956
+ // accumulatorRotated flips true once a fresh-over-failed retry rotates to a
1957
+ // new identity; isFreshStart defaults true until a resume verdict flips it.
1958
+ let accumulatorRotated = false;
1959
+ let isFreshStart = true;
1960
+ let progress = null;
1961
+ let stratum = null;
1962
+ let streamWriter = null;
1963
+ let signalHandler = null;
1964
+ let runtimeResourcesFinalized = false;
1965
+
1966
+ const _priorBuildIdEnv = process.env.COMPOSE_BUILD_ID;
1967
+ if (_priorBuildIdEnv !== undefined) {
1968
+ // eslint-disable-next-line no-console
1969
+ console.warn(
1970
+ `[build] COMPOSE_BUILD_ID was already set ("${_priorBuildIdEnv}") when runBuild started. ` +
1971
+ `Overriding for this build; concurrent in-process builds will mis-stamp events.`
1972
+ );
1973
+ }
1974
+ process.env.COMPOSE_BUILD_ID = build_id;
1975
+ const _restoreBuildIdEnv = () => {
1976
+ if (_priorBuildIdEnv === undefined) delete process.env.COMPOSE_BUILD_ID;
1977
+ else process.env.COMPOSE_BUILD_ID = _priorBuildIdEnv;
1978
+ };
1979
+ const finalizeBuildAttempt = () => {
1980
+ if (!attemptStarted || attemptFinalized) return;
1981
+ const terminalStatus = buildStatus === 'complete'
1982
+ ? 'complete'
1983
+ : (buildStatus === 'killed' || buildStatus === 'aborted')
1984
+ ? 'aborted'
1985
+ : 'failed';
1986
+ let accumulator = readBuildAccumulator(cwd, featureCode);
1987
+ // A pre-ownership failure (e.g. missing lifecycle spec) dies before the
1988
+ // candidate persists — write it now so the attempt still leaves a terminal
1989
+ // actuals row. The build_id guard keeps a stale on-disk record from another
1990
+ // identity from being finalized under this attempt's name.
1991
+ if ((!accumulator || accumulator.build_id !== build_id) && !accumulatorPersisted) {
1992
+ accumulator = writeBuildAccumulator(cwd, activeAccumulator);
1993
+ accumulatorPersisted = true;
1994
+ }
1995
+ // A terminal complete/abort may already have cleared the sidecar if a nested
1996
+ // terminalization path finalized first. The finalized flag is the primary
1997
+ // guard; the missing-file/identity guard keeps cleanup idempotent.
1998
+ //
1999
+ // COMP-TRIAGE-6-4 ownership guard: a fresh (non-resume), not-yet-rotated
2000
+ // attempt that reuses a prior record must not re-emit a terminal row that
2001
+ // ALREADY EXISTS for this build_id. Without it, an attempt that dies before
2002
+ // rotating to a fresh identity (rotateStaleAccumulatorForFreshStart) — e.g.
2003
+ // the flow-audit probe throws while resolving the fresh/resume verdict —
2004
+ // re-finalizes the reused failed record, double-counting in ACRR. The signal
2005
+ // is the LEDGER itself, not the reused record's last_terminal: emitBuildActuals
2006
+ // writes last_terminal and the row non-atomically, so a crash between them
2007
+ // must cause neither a wrongful suppression (marker set, no row) nor a
2008
+ // wrongful double-emit (row written, marker cleared). A genuine resume
2009
+ // (isFreshStart=false) owns and continues the build (its second row under the
2010
+ // same id is intended); a rotated/new identity owns a fresh, unemitted id.
2011
+ const mayReemitReused = !isNewAccumulator && !accumulatorRotated && isFreshStart;
2012
+ const alreadyEmitted = mayReemitReused
2013
+ && readDispatchEvents(cwd, { kind: 'build-actuals' }).some((r) => r.build_id === build_id);
2014
+ if (accumulator && accumulator.build_id === build_id && !alreadyEmitted) {
2015
+ emitBuildActuals(cwd, accumulator, terminalStatus);
2016
+ }
2017
+ attemptFinalized = true;
2018
+ };
2019
+
2020
+ try {
1641
2021
  // Single resolver — used at every site that previously hardcoded
1642
2022
  // `docs/features/<featureCode>/`. Callers must use this (not inline
1643
2023
  // string concatenation) so the bug-mode path stays in sync.
@@ -1660,29 +2040,6 @@ export async function runBuild(featureCode, opts = {}) {
1660
2040
  ? join(resolveFeaturesPath(cwd), code)
1661
2041
  : join(cwd, ...cfg.artifactRoot.split('/'), code);
1662
2042
 
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
2043
  const featureDir = resolveItemDir(featureCode);
1687
2044
 
1688
2045
  // Debug discipline (COMP-DEBUG-1)
@@ -1732,6 +2089,7 @@ export async function runBuild(featureCode, opts = {}) {
1732
2089
  // ---------------------------------------------------------------------------
1733
2090
  let buildProfile = null;
1734
2091
  let _buildTierLabel = '?'; // for skip_reason label in spec YAML mutation below
2092
+ let triageEstimate = null;
1735
2093
  // Only modes that run feature triage do so — triage is feature-shaped (writes
1736
2094
  // feature.json, profile selection per complexity tiers). bug AND plan skip it.
1737
2095
  if (cfg.runsTriage && !opts.skipTriage && !opts.template) {
@@ -1746,6 +2104,15 @@ export async function runBuild(featureCode, opts = {}) {
1746
2104
  // Reuse cached profile
1747
2105
  buildProfile = cachedFeature.profile;
1748
2106
  _buildTierLabel = cachedFeature.complexity ?? '?';
2107
+ triageEstimate = {
2108
+ build_id,
2109
+ feature_code: featureCode,
2110
+ triageTier: cachedFeature.triageTier,
2111
+ lane: cachedFeature.lane,
2112
+ profile: cachedFeature.profile,
2113
+ estimateSource: _escalated ? 'escalated' : 'cached',
2114
+ triageConfidence: cachedFeature.triageConfidence ?? null,
2115
+ };
1749
2116
  console.log(`[triage] Using ${_escalated ? 'escalated' : 'cached'} profile (tier ${_buildTierLabel}, lane ${cachedFeature.lane ?? '?'}): ${JSON.stringify(buildProfile)}`);
1750
2117
  } else {
1751
2118
  // COMP-TRIAGE-5 (E3 Estimate): derive the lane from the RAW REQUEST before
@@ -1763,10 +2130,21 @@ export async function runBuild(featureCode, opts = {}) {
1763
2130
  buildProfile = front.buildProfile;
1764
2131
  _buildTierLabel = front.tierLabel;
1765
2132
  cachedFeature = front.cachedFeature;
2133
+ triageEstimate = {
2134
+ build_id,
2135
+ feature_code: featureCode,
2136
+ triageTier: front.tier,
2137
+ lane: front.lane,
2138
+ profile: front.buildProfile,
2139
+ estimateSource: 'fresh',
2140
+ triageConfidence: front.confidence ?? null,
2141
+ };
1766
2142
  console.log(`[triage] Front estimate lane=${front.lane} (tier ${front.tier}): ${front.rationale}`);
1767
2143
  console.log(`[triage] Profile: ${JSON.stringify(buildProfile)}`);
1768
2144
  }
1769
2145
  }
2146
+ // Estimate emission is DEFERRED to the post-verdict ownership point: a
2147
+ // refused/errored invocation must leave no orphan estimate (review r2).
1770
2148
 
1771
2149
  // Load lifecycle spec (template selection). The mode's defaultTemplate is the
1772
2150
  // fallback when no explicit --template is given (build → 'build', byte-identical
@@ -1883,10 +2261,10 @@ export async function runBuild(featureCode, opts = {}) {
1883
2261
  }
1884
2262
 
1885
2263
  // CLI progress renderer
1886
- const progress = new CliProgress();
2264
+ progress = new CliProgress();
1887
2265
 
1888
2266
  // Stratum MCP client (test override permitted via opts.stratum)
1889
- const stratum = opts.stratum ?? new StratumMcpClient();
2267
+ stratum = opts.stratum ?? new StratumMcpClient();
1890
2268
  if (!opts.stratum) await stratum.connect(resolveStratumMcpConnection(cwd));
1891
2269
 
1892
2270
  // Update feature.json status to IN_PROGRESS (only modes that track
@@ -1905,8 +2283,7 @@ export async function runBuild(featureCode, opts = {}) {
1905
2283
  }
1906
2284
 
1907
2285
  // Hoisted for finally-block visibility
1908
- let streamWriter = null;
1909
- let buildStatus = 'complete';
2286
+ buildStatus = 'complete';
1910
2287
  let killedByGate = false;
1911
2288
  let terminalFailureReason = null;
1912
2289
  // I4: the last step the engine issued as `ready` (i.e. dispatched). When a flow
@@ -1924,17 +2301,25 @@ export async function runBuild(featureCode, opts = {}) {
1924
2301
  // and resets lenses_run, so the TRUE dirty lens (e.g. security) can be erased —
1925
2302
  // the gate persists from this, and only falls back to post-normalization findings.
1926
2303
  let lastReviewMergeDirtyLenses = [];
1927
- let signalHandler = null;
1928
2304
  let response;
1929
2305
  let stepHistory = [];
1930
2306
  const terminalHistoryWritten = { value: false };
1931
2307
  // COMP-OBS-COST: Accumulate token/cost totals across all steps (hoisted for finally-block)
1932
2308
  // 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 };
2309
+ const buildCostTotals = {
2310
+ input_tokens: 0,
2311
+ output_tokens: selectedAccumulator.tokens_total,
2312
+ cost_usd: selectedAccumulator.usd,
2313
+ };
1934
2314
  // COMP-MODEL-AB: capture structured test counts from the ship step so they can
1935
2315
  // be persisted to build-history.jsonl for the metrics consumer (experiment-metrics.js).
1936
2316
  // Null when ship didn't run (failed/killed builds) or testSummary was unparsed.
1937
- let shipStepTestData = null;
2317
+ let shipStepTestData = selectedAccumulator.test_count === null
2318
+ ? null
2319
+ : {
2320
+ test_count: selectedAccumulator.test_count,
2321
+ pass_rate: selectedAccumulator.pass_rate ?? 0,
2322
+ };
1938
2323
 
1939
2324
  // COMP-OBS-GATES: accumulate tier pass/fail results for this build.
1940
2325
  // Keys are tier IDs (T0–T4), values are true (passed), false (failed), or null (not yet run).
@@ -1949,17 +2334,37 @@ export async function runBuild(featureCode, opts = {}) {
1949
2334
  // Accumulate contract compliance signal: array of { passed: bool } per ensure check
1950
2335
  const contractCompliance = [];
1951
2336
 
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
- }
2337
+ // COMP-TRIAGE-6-4: on a fresh-over-failed retry the reused accumulator still
2338
+ // carries the prior FAILED attempt's identity and counters. Rotate to a fresh
2339
+ // record BEFORE the fallible startFresh (its plan() call can throw): otherwise
2340
+ // a throw drops to finalizeBuildAttempt, which reads the OLD record still on
2341
+ // disk under the reused build_id and emits a DUPLICATE build-actuals failed
2342
+ // row with stale counters (double-count in ACRR). Rotating first means the
2343
+ // throw finalizes the NEW record (distinct build_id, zeroed counters) instead.
2344
+ // Idempotent: guarded so the post-startFresh ownership block cannot re-rotate.
2345
+ // accumulatorRotated / isFreshStart are declared at function scope above so
2346
+ // finalizeBuildAttempt's ownership guard can read them.
2347
+ const rotateStaleAccumulatorForFreshStart = () => {
2348
+ if (isNewAccumulator || accumulatorRotated) return;
2349
+ activeAccumulator = createBuildAccumulator(cwd, featureCode);
2350
+ accumulatorPersisted = true;
2351
+ build_id = activeAccumulator.build_id;
2352
+ process.env.COMPOSE_BUILD_ID = build_id;
2353
+ buildCostTotals.input_tokens = 0;
2354
+ buildCostTotals.output_tokens = 0;
2355
+ buildCostTotals.cost_usd = 0;
2356
+ shipStepTestData = null;
2357
+ if (triageEstimate) {
2358
+ triageEstimate.build_id = build_id;
2359
+ emitTriageEstimate(cwd, triageEstimate);
2360
+ }
2361
+ accumulatorRotated = true;
2362
+ };
1958
2363
 
1959
2364
  try {
1960
2365
  // Check for active build (resume)
1961
2366
  const active = readActiveBuild(dataDir);
1962
- let isFreshStart = true;
2367
+ isFreshStart = true;
1963
2368
 
1964
2369
  // COMP-CODEX-IMPL: implementer/reviewer roles. A FRESH start derives them from
1965
2370
  // the flag (--codex flips Claude-implements/Codex-reviews → Codex/Claude); the
@@ -2083,8 +2488,13 @@ export async function runBuild(featureCode, opts = {}) {
2083
2488
  if (explicitResume) {
2084
2489
  throw new Error(`Nothing to resume for ${featureCode} (no in-progress or failed build found)`);
2085
2490
  }
2086
- response = await startFresh(stratum, specYaml, featureCode, description, dataDir, templateName, mode, preMergeGate, roles, cwd);
2491
+ // COMP-TRIAGE-6-4: claim fresh-ownership intent BEFORE rotating, so if
2492
+ // rotation itself throws the finalize guard still treats this as a fresh
2493
+ // start (isFreshStart=true) and suppresses re-finalizing the reused
2494
+ // failed row. Rotation then swaps to a fresh identity before startFresh.
2087
2495
  isFreshStart = true;
2496
+ rotateStaleAccumulatorForFreshStart();
2497
+ response = await startFresh(stratum, specYaml, featureCode, description, dataDir, templateName, mode, preMergeGate, roles, cwd);
2088
2498
  }
2089
2499
  if (!isFreshStart) {
2090
2500
  const stepId = response.ready?.[0]?.id;
@@ -2116,14 +2526,34 @@ export async function runBuild(featureCode, opts = {}) {
2116
2526
  }
2117
2527
  } else if (verdict.action === 'fresh') {
2118
2528
  if (activeForDecision?.flowId) console.log(`${verdict.reason}. Starting fresh.`);
2529
+ // COMP-TRIAGE-6-4: rotate a reused failed identity before the fallible
2530
+ // startFresh so a plan() throw cannot re-finalize the prior attempt.
2531
+ rotateStaleAccumulatorForFreshStart();
2119
2532
  response = await startFresh(stratum, specYaml, featureCode, description, dataDir, templateName, mode, preMergeGate, roles, cwd);
2120
2533
  } else {
2534
+ // A refused invocation never owned the accumulator — the ALIVE build does.
2535
+ // Disown before throwing so the widened finally cannot finalize the other
2536
+ // build's attempt as failed (spurious terminal row).
2537
+ attemptStarted = false;
2121
2538
  const reason = verdict.reason.includes(featureCode)
2122
2539
  ? verdict.reason
2123
2540
  : verdict.reason.replace('Build already running', `Build already running for ${featureCode}`);
2124
2541
  throw new Error(reason);
2125
2542
  }
2126
2543
 
2544
+ // Ownership point — the verdict resolved to resume or fresh (refuse/error
2545
+ // threw above without persisting anything). The fresh-over-failed identity
2546
+ // rotation now happens BEFORE startFresh (COMP-TRIAGE-6-4,
2547
+ // rotateStaleAccumulatorForFreshStart), so only the brand-new candidate is
2548
+ // persisted here. A new candidate is unaffected by that bug: a startFresh
2549
+ // throw hits finalizeBuildAttempt's write-and-emit fallback under a distinct
2550
+ // candidate id, never a duplicate row.
2551
+ if (isNewAccumulator) {
2552
+ writeBuildAccumulator(cwd, activeAccumulator);
2553
+ accumulatorPersisted = true;
2554
+ if (triageEstimate) emitTriageEstimate(cwd, triageEstimate);
2555
+ }
2556
+
2127
2557
  // COMP-CODEX-IMPL: verify Codex can write inside a detached git worktree (the
2128
2558
  // execute step's isolation primitive) before any step dispatches. Runs on the
2129
2559
  // EFFECTIVE role — after resume restoration above — so a resumed Codex build is
@@ -2135,6 +2565,9 @@ export async function runBuild(featureCode, opts = {}) {
2135
2565
  if (implementerAgent === 'codex') {
2136
2566
  const probe = await preflightCodexWorktreeProbe({
2137
2567
  cwd: agentCwd,
2568
+ projectCwd: cwd,
2569
+ buildId: build_id,
2570
+ featureCode,
2138
2571
  stratum,
2139
2572
  dataDir,
2140
2573
  ts: new Date().toISOString().replace(/[:.]/g, '-'),
@@ -2235,6 +2668,7 @@ export async function runBuild(featureCode, opts = {}) {
2235
2668
 
2236
2669
  const context = {
2237
2670
  cwd: agentCwd,
2671
+ projectCwd: cwd,
2238
2672
  featureCode,
2239
2673
  featureDir: resolveItemDir(featureCode),
2240
2674
  contextDir: contextDirPath,
@@ -2254,8 +2688,66 @@ export async function runBuild(featureCode, opts = {}) {
2254
2688
  // self-review suppression. Restored from active-build state on resume (above).
2255
2689
  implementerAgent,
2256
2690
  reviewerAgent,
2691
+ filesChanged: [...activeAccumulator.files_changed],
2257
2692
  ...(isBugMode ? { bug_code: featureCode } : {}),
2258
2693
  };
2694
+ context.recordBuildUsage = (usage) => {
2695
+ if (!usage || typeof usage !== 'object') return;
2696
+ const componentTokens = (typeof usage.input_tokens === 'number' ? usage.input_tokens : 0)
2697
+ + (typeof usage.output_tokens === 'number' ? usage.output_tokens : 0);
2698
+ const tokens = componentTokens > 0
2699
+ ? componentTokens
2700
+ : (typeof usage.tokens_total === 'number'
2701
+ ? usage.tokens_total
2702
+ : (typeof usage.tokens === 'number' ? usage.tokens : 0));
2703
+ const usd = typeof usage.cost_usd === 'number'
2704
+ ? usage.cost_usd
2705
+ : (typeof usage.usd === 'number' ? usage.usd : 0);
2706
+ if (tokens === 0 && usd === 0) return;
2707
+ updateBuildAccumulator(cwd, featureCode, (accumulator) => ({
2708
+ ...accumulator,
2709
+ tokens_total: accumulator.tokens_total + tokens,
2710
+ usd: accumulator.usd + usd,
2711
+ }));
2712
+ };
2713
+ context.recordFilesChanged = (paths, { authoritativeShip = false } = {}) => {
2714
+ const normalized = Array.isArray(paths)
2715
+ ? [...new Set(paths.filter((file) => typeof file === 'string' && file.length > 0))]
2716
+ : [];
2717
+ updateBuildAccumulator(cwd, featureCode, (accumulator) => ({
2718
+ ...accumulator,
2719
+ ...(authoritativeShip
2720
+ ? { ship_files_changed: normalized }
2721
+ : { files_changed: [...new Set([...accumulator.files_changed, ...normalized])] }),
2722
+ }));
2723
+ };
2724
+ context.recordShipTestMetrics = (metrics) => {
2725
+ if (!metrics || typeof metrics.test_count !== 'number') return;
2726
+ updateBuildAccumulator(cwd, featureCode, (accumulator) => ({
2727
+ ...accumulator,
2728
+ test_count: metrics.test_count,
2729
+ pass_rate: typeof metrics.pass_rate === 'number' ? metrics.pass_rate : 0,
2730
+ }));
2731
+ };
2732
+ context.recordEscalation = () => {
2733
+ updateBuildAccumulator(cwd, featureCode, (accumulator) => ({
2734
+ ...accumulator,
2735
+ escalations: accumulator.escalations + 1,
2736
+ }));
2737
+ };
2738
+ context.recordReviewIteration = () => {
2739
+ updateBuildAccumulator(cwd, featureCode, (accumulator) => ({
2740
+ ...accumulator,
2741
+ review_iterations: accumulator.review_iterations + 1,
2742
+ }));
2743
+ };
2744
+ context.settleDispatches = ({ stepId, ...args } = {}) => settleDispatches(
2745
+ cwd,
2746
+ build_id,
2747
+ stepId,
2748
+ args,
2749
+ );
2750
+ context.onUsage = context.recordBuildUsage;
2259
2751
 
2260
2752
  let consumerArtifacts = null;
2261
2753
  const artifactsForRun = (runId, pins) => {
@@ -2507,7 +2999,13 @@ export async function runBuild(featureCode, opts = {}) {
2507
2999
  // capture, shipStepTestData stays null for all real builds and appendBuildHistory
2508
3000
  // never persists test_count/pass_rate. Must mirror the generic path exactly.
2509
3001
  const _interceptedTestMetrics = _extractShipTestMetrics(shipResult);
2510
- if (_interceptedTestMetrics !== null) shipStepTestData = _interceptedTestMetrics;
3002
+ if (Array.isArray(shipResult.filesChanged)) {
3003
+ context.recordFilesChanged(shipResult.filesChanged, { authoritativeShip: true });
3004
+ }
3005
+ if (_interceptedTestMetrics !== null) {
3006
+ shipStepTestData = _interceptedTestMetrics;
3007
+ context.recordShipTestMetrics(_interceptedTestMetrics);
3008
+ }
2511
3009
  stepHistory.push({
2512
3010
  stepId: 'ship',
2513
3011
  artifact: shipResult.artifact,
@@ -2618,17 +3116,36 @@ export async function runBuild(featureCode, opts = {}) {
2618
3116
  `Fix step "${stepId}" — the previous attempt failed:\n` +
2619
3117
  `- ${failureReason}\n\nFix every issue, then return the step's expected result.`;
2620
3118
  try {
2621
- await runAndNormalize(undefined, fixPrompt, { ...stepDispatch, agent: fixAgent }, {
3119
+ // The fixer's work is adjudicated by the retried step's own stepDone —
3120
+ // no settlement here (its dispatch event still records the cost).
3121
+ const fixResult = await runAndNormalize(undefined, fixPrompt, { ...stepDispatch, agent: fixAgent }, {
2622
3122
  progress,
2623
3123
  streamWriter,
2624
3124
  maxDurationMs: STEP_TIMEOUT_MS[stepId] ?? DEFAULT_TIMEOUT_MS,
2625
3125
  stratum,
2626
3126
  cwd: agentCwd,
2627
3127
  profile: resolveStepProfile(context.stepProfiles, stepId),
3128
+ telemetry: {
3129
+ site: 'review-repair',
3130
+ project_cwd: cwd,
3131
+ build_id,
3132
+ feature_code: featureCode,
3133
+ step_id: stepId,
3134
+ ...(typeof readyStep.attempt === 'number' ? { attempt: readyStep.attempt } : {}),
3135
+ },
2628
3136
  });
3137
+ if (fixResult?.usage && typeof context.recordBuildUsage === 'function') {
3138
+ try { context.recordBuildUsage(fixResult.usage); } catch { /* fail-open */ }
3139
+ }
2629
3140
  } catch (err) {
2630
- if (!(err instanceof AgentTimeoutError)) throw err;
2631
- console.warn(`\n⚠ Fix agent timed out on "${stepId}"`);
3141
+ if (err?.usage && typeof context.recordBuildUsage === 'function') {
3142
+ try { context.recordBuildUsage(err.usage); } catch { /* fail-open */ }
3143
+ }
3144
+ if (err instanceof AgentTimeoutError) {
3145
+ console.warn(`\n⚠ Fix agent timed out on "${stepId}"`);
3146
+ } else {
3147
+ throw err;
3148
+ }
2632
3149
  }
2633
3150
  progress.retry('build', stepId, readyStep.agent ?? response.agent ?? 'claude');
2634
3151
  }
@@ -2678,6 +3195,14 @@ export async function runBuild(featureCode, opts = {}) {
2678
3195
  reviewMode: isReviewMain,
2679
3196
  confidenceGate: confGateMain,
2680
3197
  lens: response.inputs?.lens_name ?? response.lens_name ?? 'general',
3198
+ telemetry: {
3199
+ site: isReviewMain ? 'review' : 'build-step',
3200
+ project_cwd: cwd,
3201
+ build_id,
3202
+ feature_code: featureCode,
3203
+ step_id: stepId,
3204
+ ...(typeof readyStep?.attempt === 'number' ? { attempt: readyStep.attempt } : {}),
3205
+ },
2681
3206
  // D6/V4: apply this ordinary step's compose-side profile (e.g.
2682
3207
  // blueprint → claude::critical, review_merge → claude:orchestrator),
2683
3208
  // normalizing scoped subflow ready ids to the bare step id.
@@ -2687,16 +3212,37 @@ export async function runBuild(featureCode, opts = {}) {
2687
3212
  if (err instanceof UserInterruptError) {
2688
3213
  if (err.action === 'skip') {
2689
3214
  if (progress) progress.info(` ⏭ Skipped step "${stepId}"`);
2690
- mainResult = { text: '', result: { outcome: 'skipped', summary: `Skipped by user` } };
3215
+ mainResult = {
3216
+ text: '',
3217
+ result: { outcome: 'skipped', summary: 'Skipped by user' },
3218
+ dispatchIds: { primary: err.dispatchId ?? null, repair: null },
3219
+ settlementFailureClass: 'agent',
3220
+ };
2691
3221
  } else {
2692
3222
  if (progress) progress.info(` ↻ Retrying step "${stepId}"`);
2693
- mainResult = { text: '', result: { outcome: 'failed', summary: `Retry requested by user` } };
3223
+ mainResult = {
3224
+ text: '',
3225
+ result: { outcome: 'failed', summary: 'Retry requested by user' },
3226
+ dispatchIds: { primary: err.dispatchId ?? null, repair: null },
3227
+ settlementFailureClass: 'agent',
3228
+ };
2694
3229
  }
2695
3230
  } else if (err instanceof AgentTimeoutError) {
2696
3231
  console.warn(`\n⚠ Agent timed out on step "${stepId}" after ${Math.round(err.durationMs / 1000)}s`);
2697
3232
  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` } };
3233
+ mainResult = {
3234
+ text: '',
3235
+ result: { outcome: 'failed', summary: `Timed out after ${Math.round(err.durationMs / 1000)}s` },
3236
+ dispatchIds: { primary: err.dispatchId ?? null, repair: null },
3237
+ settlementFailureClass: 'agent',
3238
+ ...(err.usage ? { usage: err.usage } : {}),
3239
+ };
2699
3240
  } else {
3241
+ // Fatal rethrow bypasses the post-stepDone usage fold — bill the
3242
+ // attempt's real cost to the accumulator before crashing.
3243
+ if (err?.usage && typeof context.recordBuildUsage === 'function') {
3244
+ try { context.recordBuildUsage(err.usage); } catch { /* fail-open */ }
3245
+ }
2700
3246
  streamWriter.write({ type: 'build_error', message: err.message, stepId });
2701
3247
  throw err;
2702
3248
  }
@@ -2791,6 +3337,7 @@ export async function runBuild(featureCode, opts = {}) {
2791
3337
  const existing = new Set(context.filesChanged ?? []);
2792
3338
  for (const f of files) existing.add(f);
2793
3339
  context.filesChanged = [...existing];
3340
+ context.recordFilesChanged(context.filesChanged);
2794
3341
  }
2795
3342
  } catch { /* git not available or no repo — skip */ }
2796
3343
  }
@@ -2804,7 +3351,14 @@ export async function runBuild(featureCode, opts = {}) {
2804
3351
  // so both paths produce identical shipStepTestData shapes.
2805
3352
  if (stepId === 'ship') {
2806
3353
  const _genericTestMetrics = _extractShipTestMetrics(result);
2807
- if (_genericTestMetrics !== null) shipStepTestData = _genericTestMetrics;
3354
+ const genericShipFiles = result?.filesChanged ?? result?.files_changed;
3355
+ if (Array.isArray(genericShipFiles)) {
3356
+ context.recordFilesChanged(genericShipFiles, { authoritativeShip: true });
3357
+ }
3358
+ if (_genericTestMetrics !== null) {
3359
+ shipStepTestData = _genericTestMetrics;
3360
+ context.recordShipTestMetrics(_genericTestMetrics);
3361
+ }
2808
3362
  }
2809
3363
 
2810
3364
  // Note: scope-step BuildProfile persistence has been replaced by pre-build triage.
@@ -2861,6 +3415,26 @@ export async function runBuild(featureCode, opts = {}) {
2861
3415
  response = await stratum.stepDone(
2862
3416
  flowId, stepId, stepDoneResult, readyStep?.dispatchToken,
2863
3417
  );
3418
+ {
3419
+ const isEnsureRetry = responseReissuesStep(response, stepId);
3420
+ const failureClass = ownershipFailure
3421
+ ? 'ownership'
3422
+ : vocabularyFailure
3423
+ ? 'vocabulary'
3424
+ : normalizationFailure || (stepDispatch.has_out_contract && result == null)
3425
+ ? 'normalization'
3426
+ : mainResult.settlementFailureClass
3427
+ ?? (result?.outcome === 'failed' ? 'agent' : null);
3428
+ context.settleDispatches({
3429
+ stepId,
3430
+ dispatchIds: mainResult.dispatchIds,
3431
+ accepted: !isEnsureRetry && !blockingFailure && !normalizationFailure
3432
+ && result?.outcome !== 'failed'
3433
+ && (!stepDispatch.has_out_contract || result != null),
3434
+ failureClass,
3435
+ isEnsureRetry,
3436
+ });
3437
+ }
2864
3438
  syncStepHistory(dataDir, stepHistory);
2865
3439
 
2866
3440
  // COMP-FIX-HARD T6: record accepted hypothesis on diagnose success (bug mode only).
@@ -2934,7 +3508,19 @@ export async function runBuild(featureCode, opts = {}) {
2934
3508
 
2935
3509
  // COMP-FIX-HARD T10: post-retro_check escalation gate (bug mode only).
2936
3510
  if (stepId === 'retro_check' && context.mode === 'bug' && context.bug_code) {
2937
- await maybeRunEscalation(stratum, context, progress, streamWriter, attemptCounter, dataDir);
3511
+ await maybeRunEscalation(
3512
+ stratum,
3513
+ {
3514
+ ...context,
3515
+ projectCwd: cwd,
3516
+ step_id: stepId,
3517
+ ...(typeof readyStep?.attempt === 'number' ? { attempt: readyStep.attempt } : {}),
3518
+ },
3519
+ progress,
3520
+ streamWriter,
3521
+ attemptCounter,
3522
+ dataDir,
3523
+ );
2938
3524
  }
2939
3525
 
2940
3526
  // Stream: step done — read retries/violations from active-build state
@@ -2947,6 +3533,7 @@ export async function runBuild(featureCode, opts = {}) {
2947
3533
  buildCostTotals.input_tokens += stepUsage.input_tokens ?? 0;
2948
3534
  buildCostTotals.output_tokens += stepUsage.output_tokens ?? 0;
2949
3535
  buildCostTotals.cost_usd += stepUsage.cost_usd ?? 0;
3536
+ context.recordBuildUsage(stepUsage);
2950
3537
  streamWriter.writeUsage(stepId, stepUsage);
2951
3538
  }
2952
3539
 
@@ -3113,6 +3700,7 @@ export async function runBuild(featureCode, opts = {}) {
3113
3700
  const files = new Set(context.filesChanged ?? []);
3114
3701
  for (const file of changed.split('\n').filter(Boolean)) files.add(file);
3115
3702
  context.filesChanged = [...files];
3703
+ context.recordFilesChanged(context.filesChanged);
3116
3704
  }
3117
3705
  } catch { /* best-effort build context projection */ }
3118
3706
  } catch (error) {
@@ -3219,6 +3807,7 @@ export async function runBuild(featureCode, opts = {}) {
3219
3807
  stepHistory.push({ stepId: gateStepId, artifact: null, summary: 'Review gate: clean', outcome: resolved.outcome });
3220
3808
  syncStepHistory(dataDir, stepHistory);
3221
3809
  } else {
3810
+ context.recordReviewIteration();
3222
3811
  // Persist the dirty lens ids so the revised triage re-runs only those.
3223
3812
  // J2: prefer the PRE-normalization dirty lenses (normalization resets
3224
3813
  // lenses_run and stamps a missing finding lens as 'general', which would
@@ -3242,11 +3831,25 @@ export async function runBuild(featureCode, opts = {}) {
3242
3831
  + (reviewResult.summary ? `\nSummary: ${reviewResult.summary}\n` : '')
3243
3832
  + findings.map((f) => `- ${f.file ?? '?'}:${f.line ?? '?'} [${f.lens ?? f.severity ?? ''}] ${f.finding ?? f.summary ?? ''}`).join('\n');
3244
3833
  try {
3245
- await runAndNormalize(undefined, fixPrompt, { step_id: 'review_fix', agent: fixAgent, flow_id: flowId }, {
3834
+ const gateFixResult = await runAndNormalize(undefined, fixPrompt, { step_id: 'review_fix', agent: fixAgent, flow_id: flowId }, {
3246
3835
  progress, streamWriter, maxDurationMs: STEP_TIMEOUT_MS.review_merge ?? DEFAULT_TIMEOUT_MS,
3247
3836
  stratum, cwd: agentCwd, profile: resolveStepProfile(effectiveProfiles, 'fix'),
3837
+ telemetry: {
3838
+ site: 'review-repair',
3839
+ project_cwd: cwd,
3840
+ build_id,
3841
+ feature_code: featureCode,
3842
+ step_id: 'review_fix',
3843
+ ...(typeof gateDispatch.attempt === 'number' ? { attempt: gateDispatch.attempt } : {}),
3844
+ },
3248
3845
  });
3846
+ if (gateFixResult?.usage && typeof context.recordBuildUsage === 'function') {
3847
+ try { context.recordBuildUsage(gateFixResult.usage); } catch { /* fail-open */ }
3848
+ }
3249
3849
  } catch (err) {
3850
+ if (err?.usage && typeof context.recordBuildUsage === 'function') {
3851
+ try { context.recordBuildUsage(err.usage); } catch { /* fail-open */ }
3852
+ }
3250
3853
  if (!(err instanceof AgentTimeoutError)) throw err;
3251
3854
  console.warn('\n⚠ Review fixer timed out');
3252
3855
  }
@@ -3691,6 +4294,13 @@ export async function runBuild(featureCode, opts = {}) {
3691
4294
  }
3692
4295
  throw err;
3693
4296
  } finally {
4297
+ let actualsError = null;
4298
+ try {
4299
+ finalizeBuildAttempt();
4300
+ } catch (error) {
4301
+ actualsError = error;
4302
+ attemptFinalized = true;
4303
+ }
3694
4304
  // Close stream writer with appropriate status (idempotent — signal handler may have already closed)
3695
4305
  if (streamWriter) {
3696
4306
  streamWriter.close(buildStatus, buildCostTotals);
@@ -3701,9 +4311,31 @@ export async function runBuild(featureCode, opts = {}) {
3701
4311
  }
3702
4312
  progress.finish();
3703
4313
  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();
4314
+ runtimeResourcesFinalized = true;
4315
+ if (actualsError) throw actualsError;
4316
+ }
4317
+ } finally {
4318
+ try {
4319
+ finalizeBuildAttempt();
4320
+ } finally {
4321
+ try {
4322
+ if (!runtimeResourcesFinalized) {
4323
+ if (streamWriter) streamWriter.close(buildStatus);
4324
+ if (signalHandler) {
4325
+ process.removeListener('SIGINT', signalHandler);
4326
+ process.removeListener('SIGTERM', signalHandler);
4327
+ }
4328
+ progress?.finish();
4329
+ if (stratum) await stratum.close();
4330
+ runtimeResourcesFinalized = true;
4331
+ }
4332
+ } finally {
4333
+ // COMP-MCP-MIGRATION-1: restore COMPOSE_BUILD_ID env to its prior value
4334
+ // (or unset) so subsequent processes / tests don't inherit a stale UUID.
4335
+ _restoreBuildIdEnv();
4336
+ attemptStarted = false;
4337
+ }
4338
+ }
3707
4339
  }
3708
4340
  }
3709
4341
 
@@ -3812,6 +4444,49 @@ export function _extractShipTestMetrics(shipResult) {
3812
4444
  return { test_count: shipResult.test_count, pass_rate: shipResult.pass_rate ?? 0 };
3813
4445
  }
3814
4446
 
4447
+ /** The guarded namespace — must equal the verifier's and the hook's (R2). */
4448
+ const JUDGMENT_TREE = 'docs/judgment';
4449
+
4450
+ /**
4451
+ * Run a git command and return its non-empty output lines.
4452
+ *
4453
+ * Deliberately does NOT swallow failures: this feeds the pre-commit judgment
4454
+ * gate, where an unreadable git state must fail CLOSED. The caller's catch turns
4455
+ * a throw into "verification errored; refusing to commit".
4456
+ */
4457
+ function gitLines(args, cwd) {
4458
+ return execFileSync('git', args, { cwd, encoding: 'utf-8', timeout: 5000 })
4459
+ .split('\n')
4460
+ .map((line) => line.trim())
4461
+ .filter(Boolean);
4462
+ }
4463
+
4464
+ function judgmentCanonDriftError({ treeDrift, projectionDrift, recordDrift }) {
4465
+ const tiers = [
4466
+ ['Tree drift', treeDrift],
4467
+ ['Projection drift', projectionDrift],
4468
+ // R1: records are covered by drift detection, not claimed as enforced.
4469
+ ['Record drift detection', recordDrift],
4470
+ ];
4471
+ const sections = tiers
4472
+ .filter(([, drift]) => drift.length > 0)
4473
+ .map(([label, drift]) => {
4474
+ const paths = drift.map((item) => {
4475
+ if (typeof item === 'string') return ` - ${item}`;
4476
+ return ` - ${item.path} [${item.kind}]`;
4477
+ });
4478
+ return `${label}:\n${paths.join('\n')}`;
4479
+ });
4480
+ const err = new Error(
4481
+ `Judgment canon drift detected; refusing to commit.\n${sections.join('\n')}`,
4482
+ );
4483
+ err.code = 'JUDGMENT_CANON_DRIFT';
4484
+ err.treeDrift = treeDrift;
4485
+ err.projectionDrift = projectionDrift;
4486
+ err.recordDrift = recordDrift;
4487
+ return err;
4488
+ }
4489
+
3815
4490
  /**
3816
4491
  * Execute the ship step: run tests, stage feature files, commit.
3817
4492
  * Returns a PhaseResult-shaped object.
@@ -3906,6 +4581,7 @@ export async function executeShipStep(featureCode, agentCwd, cwd, context, descr
3906
4581
  // the real feature dir even when opts.cwd differs from process.cwd().
3907
4582
  const _esc = await maybeEscalateLane({ featureCode, provider: _escProvider, featureDir: resolve(cwd, featureDir) });
3908
4583
  if (_esc.action === 'escalate') {
4584
+ context.recordEscalation?.();
3909
4585
  console.warn(`[triage] Test gate failed on lane '${_esc.from}' — escalated to '${_esc.to}'. Re-run to execute the heavier phases.`);
3910
4586
  } else if (_esc.action === 'stop') {
3911
4587
  console.warn(`[triage] Test gate failed and escalation bound reached (lane '${_esc.lane}') — see escalation-checkpoint.md; human review needed.`);
@@ -4032,6 +4708,49 @@ export async function executeShipStep(featureCode, agentCwd, cwd, context, descr
4032
4708
  } catch { /* file might not exist or already staged */ }
4033
4709
  }
4034
4710
 
4711
+ // COMP-CANON-GUARD S5 T5: this verifier is deliberately build-independent
4712
+ // and runs after staging so pre-staged judgment edits cannot slip through.
4713
+ // This is a hard pre-commit gate: unlike best-effort metadata collection,
4714
+ // unexpected verifier failures are rethrown and block the commit because
4715
+ // silently passing would allow an unverified canon to ship.
4716
+ //
4717
+ // Scope boundary (whole-branch review): the canon verified is the WORKSPACE's
4718
+ // (`cwd`). For a cross-repo build (`agentCwd !== cwd`) the commit lands in
4719
+ // another repo, and that repo's own judgment canon — if it has one — is not
4720
+ // verified here. The staged-divergence check below does run in `agentCwd`,
4721
+ // against the tree actually being committed.
4722
+ try {
4723
+ // The verifier reads the WORKING TREE; `git commit` ships the INDEX. A canon
4724
+ // file staged and then restored in the worktree therefore verifies GREEN while
4725
+ // forged bytes go into the commit — a false GREEN, which is the one failure
4726
+ // mode this whole slice exists to prevent. So before trusting the verdict,
4727
+ // require that every STAGED guarded path matches the bytes about to be
4728
+ // verified. A guarded path modified but NOT staged is not this case: it is not
4729
+ // being committed, and worktree drift is what the verifier already covers.
4730
+ const stagedGuarded = new Set(gitLines(['diff', '--cached', '--name-only', '--', JUDGMENT_TREE], agentCwd));
4731
+ const worktreeGuarded = gitLines(['diff', '--name-only', '--', JUDGMENT_TREE], agentCwd);
4732
+ const divergent = worktreeGuarded.filter((p) => stagedGuarded.has(p));
4733
+ if (divergent.length > 0) {
4734
+ throw judgmentCanonDriftError({
4735
+ treeDrift: divergent.map((path) => ({ path, kind: 'staged-differs-from-worktree' })),
4736
+ projectionDrift: [],
4737
+ recordDrift: [],
4738
+ });
4739
+ }
4740
+
4741
+ const { verifyJudgmentCanon } = await import('./judgment-verify.js');
4742
+ const verification = await verifyJudgmentCanon(cwd);
4743
+ if (!verification.ok) throw judgmentCanonDriftError(verification);
4744
+ } catch (err) {
4745
+ // eslint-disable-next-line no-console
4746
+ console.error(
4747
+ err?.code === 'JUDGMENT_CANON_DRIFT'
4748
+ ? `[ship] ${err.message}`
4749
+ : `[ship] Judgment canon verification errored; refusing to commit: ${err.message}`,
4750
+ );
4751
+ throw err;
4752
+ }
4753
+
4035
4754
  // 4. Check if there's anything to commit
4036
4755
  const staged = execSync('git diff --cached --name-only', {
4037
4756
  cwd: agentCwd, encoding: 'utf-8', timeout: 5000,
@@ -4085,6 +4804,7 @@ export async function executeShipStep(featureCode, agentCwd, cwd, context, descr
4085
4804
  } catch { /* metadata best-effort — leave [] */ }
4086
4805
  // If we got nothing from show, fall back to the staged list (still best-effort).
4087
4806
  if (filesChanged.length === 0 && sha) filesChanged = stagedFiles;
4807
+ context.recordFilesChanged?.(filesChanged, { authoritativeShip: true });
4088
4808
 
4089
4809
  // COMP-MCP-MIGRATION: write a commit-bound completion record via the
4090
4810
  // typed writer. The writer flips feature.status to COMPLETE atomically
@@ -4141,6 +4861,7 @@ export async function executeShipStep(featureCode, agentCwd, cwd, context, descr
4141
4861
  artifact: '',
4142
4862
  outcome: 'failed',
4143
4863
  summary: `Ship failed: ${err.message}`,
4864
+ ...(typeof err?.code === 'string' ? { error_code: err.code } : {}),
4144
4865
  };
4145
4866
  }
4146
4867
  }
@@ -4417,5 +5138,9 @@ export async function abortBuild(dataDir, featureCode, cwd, opts = {}) {
4417
5138
 
4418
5139
  // Write terminal state (file retained per STRAT-COMP-4 contract)
4419
5140
  writeActiveBuild(dataDir, { ...active, status: 'aborted', completedAt: new Date().toISOString() });
5141
+ const accumulator = readBuildAccumulator(cwd, active.featureCode);
5142
+ if (accumulator) {
5143
+ emitBuildActuals(cwd, accumulator, 'aborted');
5144
+ }
4420
5145
  console.log('Build aborted.');
4421
5146
  }