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