@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.
- package/.claude/skills/compose/SKILL.md +42 -88
- package/bin/compose.js +288 -0
- package/bin/git-hooks/pre-push.template +29 -0
- package/bin/judgment-import.js +572 -0
- package/contracts/feature-json.schema.json +5 -0
- package/contracts/judgment-record.schema.json +821 -0
- package/dist/assets/App-PkZzHeMj.js +894 -0
- package/dist/assets/_baseUniq-Bo837sRJ.js +1 -0
- package/dist/assets/arc-BafGpyqE.js +1 -0
- package/dist/assets/architectureDiagram-Q4EWVU46-BOBfUsqL.js +36 -0
- package/dist/assets/blockDiagram-DXYQGD6D-Dwodev1a.js +132 -0
- package/dist/assets/{browser-BSM23If2.js → browser-1ntj1-x_.js} +6 -6
- package/dist/assets/{c4Diagram-LMCZKHZV-CUmTCrMW.js → c4Diagram-AHTNJAMY-CU_bhYag.js} +1 -1
- package/dist/assets/channel-qVK_qn4E.js +1 -0
- package/dist/assets/{chunk-JWPE2WC7-DQFs7gjy.js → chunk-4BX2VUAB-p8WsDwnO.js} +1 -1
- package/dist/assets/chunk-4TB4RGXK-B8h7-eR0.js +206 -0
- package/dist/assets/{chunk-XXDRQBXY-CSdt06Hp.js → chunk-55IACEB6-DxeEr98s.js} +1 -1
- package/dist/assets/{chunk-VR4S4FIN-Dvhgnm7E.js → chunk-EDXVE4YY-BYt8F151.js} +1 -1
- package/dist/assets/{chunk-5VM5RSS4-Qw-L7OoJ.js → chunk-FMBD7UC4-DGSOVeie.js} +1 -1
- package/dist/assets/chunk-OYMX7WX6-B-QdgYR2.js +231 -0
- package/dist/assets/{chunk-2Q5K7J3B-Cyvi1AFH.js → chunk-QZHKN3VN-Du5UAZLs.js} +1 -1
- package/dist/assets/{chunk-32BRIVSS-B1y5FpOd.js → chunk-YZCP3GAM-C8JbNBSk.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-B8UcfC1q.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-B8UcfC1q.js +1 -0
- package/dist/assets/clone-Pu3RyLUh.js +1 -0
- package/dist/assets/{cose-bilkent-JH36ORCC-G8l05tXy.js → cose-bilkent-S5V4N54A-O1ESaqge.js} +1 -1
- package/dist/assets/dagre-KV5264BT-CPTmFPHw.js +4 -0
- package/dist/assets/diagram-5BDNPKRD-B3PNrWs5.js +10 -0
- package/dist/assets/diagram-G4DWMVQ6-Cscfr6vc.js +24 -0
- package/dist/assets/diagram-MMDJMWI5-CSfqZ-TM.js +43 -0
- package/dist/assets/diagram-TYMM5635-Cg4aYS7W.js +24 -0
- package/dist/assets/erDiagram-SMLLAGMA-_ZqwG5pl.js +85 -0
- package/dist/assets/flowDiagram-DWJPFMVM-C83boxFT.js +162 -0
- package/dist/assets/ganttDiagram-T4ZO3ILL-CWnIjuEi.js +292 -0
- package/dist/assets/gitGraphDiagram-UUTBAWPF-DrMdxZfH.js +106 -0
- package/dist/assets/graph-Bi99_6Yf.js +331 -0
- package/dist/assets/graph-RE4I7Ty7.js +1 -0
- package/dist/assets/index-Rm2RE-c0.js +123 -0
- package/dist/assets/infoDiagram-42DDH7IO-BLmP4Epr.js +2 -0
- package/dist/assets/{ishikawaDiagram-FXEZZL3T-Hm4zSDlM.js → ishikawaDiagram-UXIWVN3A-yuWWshKN.js} +5 -5
- package/dist/assets/{journeyDiagram-5HDEW3XC-CIqcTFuO.js → journeyDiagram-VCZTEJTY-BOfhaJov.js} +1 -1
- package/dist/assets/{kanban-definition-HUTT4EX6-CB9jCXj_.js → kanban-definition-6JOO6SKY-Bbolde15.js} +7 -7
- package/dist/assets/katex-DkKDou_j.js +257 -0
- package/dist/assets/layout-BSf33zm8.js +1 -0
- package/dist/assets/{linear-CsXqv70E.js → linear-AvSTWMqx.js} +1 -1
- package/dist/assets/min-QBM8H4xN.js +1 -0
- package/dist/assets/{mindmap-definition-LN4V7U3C-CpqH5F_P.js → mindmap-definition-QFDTVHPH-BuvgtqIc.js} +7 -7
- package/dist/assets/{mobile-CaoXUwAr.js → mobile-BnXEOE3U.js} +2 -2
- package/dist/assets/pieDiagram-DEJITSTG-DIzF16vh.js +30 -0
- package/dist/assets/quadrantDiagram-34T5L4WZ-D-mbUIjS.js +7 -0
- package/dist/assets/{requirementDiagram-TGXJPOKE-D1lG1P7h.js → requirementDiagram-MS252O5E-CEs4kCLd.js} +3 -3
- package/dist/assets/sankeyDiagram-XADWPNL6-DFsnCr9n.js +10 -0
- package/dist/assets/sequenceDiagram-FGHM5R23-BEJYdTjQ.js +157 -0
- package/dist/assets/stateDiagram-FHFEXIEX-BBXs57uY.js +1 -0
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-BqKuX4rj.js +1 -0
- package/dist/assets/{timeline-definition-FHXFAJF6-CzqqUDvt.js → timeline-definition-GMOUNBTQ-BGvLoVAY.js} +3 -3
- package/dist/assets/vennDiagram-DHZGUBPP-9LaBTMe0.js +34 -0
- package/dist/assets/wardley-RL74JXVD-P4MEqMTP.js +162 -0
- package/dist/assets/wardleyDiagram-NUSXRM2D-o-tmxnlC.js +20 -0
- package/dist/assets/xychartDiagram-5P7HB3ND-Dpn7V6qk.js +7 -0
- package/dist/index.html +2 -2
- package/lib/bug-escalation.js +30 -4
- package/lib/build.js +777 -52
- package/lib/canon-guard.js +223 -0
- package/lib/canon-registry.js +187 -0
- package/lib/codex-preflight.js +26 -4
- package/lib/dispatch-ledger.js +301 -0
- package/lib/dispatch-metrics.js +236 -0
- package/lib/experiment-judge.js +6 -1
- package/lib/feature-writer.js +9 -0
- package/lib/gsd.js +11 -2
- package/lib/hooks-status.js +32 -3
- package/lib/idempotency.js +33 -8
- package/lib/judgment/schema.js +23 -0
- package/lib/judgment/store/index.js +207 -0
- package/lib/judgment/store/records.js +405 -0
- package/lib/judgment-attest.js +259 -0
- package/lib/judgment-gen.js +691 -0
- package/lib/judgment-verify.js +153 -0
- package/lib/judgment-write-guard.js +230 -0
- package/lib/judgment-writer.js +3403 -0
- package/lib/lane-gate.js +2 -0
- package/lib/lifecycle-modes.js +42 -0
- package/lib/local-claude-connector.js +199 -54
- package/lib/mcp-enforcement.js +21 -35
- package/lib/result-normalizer.js +93 -15
- package/lib/review-normalize.js +4 -0
- package/lib/stratum-mcp-client.js +131 -6
- package/package.json +2 -2
- package/server/compose-mcp-tools.js +49 -1
- package/server/compose-mcp.js +220 -0
- package/server/mcp-tool-policy.js +1 -0
- package/dist/assets/App-BG92y_CY.js +0 -896
- package/dist/assets/abnfDiagram-VRR7QNED-Bip6cMjP.js +0 -1
- package/dist/assets/arc-CyMTM07c.js +0 -1
- package/dist/assets/architectureDiagram-ZJ3FMSHR-BukV0CJw.js +0 -36
- package/dist/assets/blockDiagram-677ZJIJ3-Bjb_MNB4.js +0 -132
- package/dist/assets/channel-BzkmtaLy.js +0 -1
- package/dist/assets/chunk-EX3LRPZG-G_MZEji_.js +0 -231
- package/dist/assets/chunk-MOJQB5TN-CQZQYMcz.js +0 -88
- package/dist/assets/chunk-RYQCIY6F-CmAda4sZ.js +0 -1
- package/dist/assets/chunk-V7JOEXUC-CbjBz-Ek.js +0 -206
- package/dist/assets/classDiagram-OUVF2IWQ-BIvLcbex.js +0 -1
- package/dist/assets/classDiagram-v2-EOCWNBFH-BIvLcbex.js +0 -1
- package/dist/assets/cynefin-VYW2F7L2-C3n1PNJC.js +0 -178
- package/dist/assets/cynefinDiagram-TSTJHNR4-C8N_CvKJ.js +0 -62
- package/dist/assets/dagre-VKFMJZFB-B2pkwDhN.js +0 -4
- package/dist/assets/diagram-FQU43EPY-DPHD_t-1.js +0 -3
- package/dist/assets/diagram-G47NLZAW-DGmHIZRr.js +0 -24
- package/dist/assets/diagram-NH7WQ7WH-BlLWwKVt.js +0 -24
- package/dist/assets/diagram-OA4YK3LP-DEv704Re.js +0 -30
- package/dist/assets/diagram-WEI45ONY-Bj6PGUCe.js +0 -41
- package/dist/assets/ebnfDiagram-CCIWWBDH-Ch-icksT.js +0 -1
- package/dist/assets/erDiagram-Q63AITRT-CI3lwEFA.js +0 -85
- package/dist/assets/flowDiagram-23GEKE2U-DcUANpAz.js +0 -156
- package/dist/assets/ganttDiagram-NO4QXBWP-Cy0Ie8KS.js +0 -292
- package/dist/assets/gitGraphDiagram-IHSO6WYX-BswI52az.js +0 -106
- package/dist/assets/graph-BXPQrYYB.js +0 -331
- package/dist/assets/graph-C9eacEi8.js +0 -1
- package/dist/assets/index-B1H7-G2y.js +0 -119
- package/dist/assets/infoDiagram-FWYZ7A6U-BBunqvr-.js +0 -2
- package/dist/assets/katex-C5jXJg4s.js +0 -257
- package/dist/assets/layout-DEXfKzaS.js +0 -1
- package/dist/assets/map-Czzmt4hB.js +0 -1
- package/dist/assets/pegDiagram-2B236MQR-HpQyrDcm.js +0 -1
- package/dist/assets/pieDiagram-ENE6RG2P-xkcpHwnY.js +0 -39
- package/dist/assets/quadrantDiagram-ABIIQ3AL-kebZIOmu.js +0 -7
- package/dist/assets/railroadDiagram-RFXS5EU6-DNvGwWm9.js +0 -1
- package/dist/assets/sankeyDiagram-HTMAVEWB-DOEEcn52.js +0 -40
- package/dist/assets/sequenceDiagram-DBY2YBRQ-DynIVCk_.js +0 -162
- package/dist/assets/sizeCapture-X5ZJPWSS-DJXFjUUL.js +0 -1
- package/dist/assets/stateDiagram-2N3HPSRC-OZ7trFh5.js +0 -1
- package/dist/assets/stateDiagram-v2-6OUMAXLB-GcPRHAJT.js +0 -1
- package/dist/assets/swimlanes-5IMT3BWC-DULYAFmB.js +0 -2
- package/dist/assets/swimlanesDiagram-G3AALYLV-BB0gCy01.js +0 -8
- package/dist/assets/vennDiagram-L72KCM5P-CdKmtcFL.js +0 -34
- package/dist/assets/wardleyDiagram-EHGQE667-DknGwjCA.js +0 -78
- 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, {
|
|
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
|
-
|
|
2264
|
+
progress = new CliProgress();
|
|
1887
2265
|
|
|
1888
2266
|
// Stratum MCP client (test override permitted via opts.stratum)
|
|
1889
|
-
|
|
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
|
-
|
|
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 = {
|
|
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
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
2631
|
-
|
|
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 = {
|
|
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 = {
|
|
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 = {
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
3705
|
-
|
|
3706
|
-
|
|
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
|
}
|