@smartmemory/compose 0.4.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/.claude/agents/compose-architect.md +40 -0
  2. package/.claude/agents/compose-explorer.md +35 -0
  3. package/.claude/hooks/canon-guard.mjs +52 -0
  4. package/README.md +15 -1
  5. package/bin/compose.js +57 -17
  6. package/bin/git-hooks/pre-push.template +26 -1
  7. package/bin/receipts-gate.js +39 -0
  8. package/contracts/fluid-record.schema.json +5 -0
  9. package/dist/assets/{App-Z4MU-H_F.js → App-DC7paCZv.js} +190 -190
  10. package/dist/assets/{_baseUniq-ClWoCPFl.js → _baseUniq-Czad7yiy.js} +1 -1
  11. package/dist/assets/{arc-DY26UIVo.js → arc-EquvLk8y.js} +1 -1
  12. package/dist/assets/{architectureDiagram-Q4EWVU46-6Ggq4DqJ.js → architectureDiagram-Q4EWVU46-Dr_qinWi.js} +1 -1
  13. package/dist/assets/{blockDiagram-DXYQGD6D-CH3Ked0l.js → blockDiagram-DXYQGD6D-D2z46ED_.js} +1 -1
  14. package/dist/assets/{c4Diagram-AHTNJAMY-Bk8dYilu.js → c4Diagram-AHTNJAMY-BHob1Yt0.js} +1 -1
  15. package/dist/assets/channel-B-7ZRCKC.js +1 -0
  16. package/dist/assets/{chunk-4BX2VUAB-BMR0XaAQ.js → chunk-4BX2VUAB-DomWBRa_.js} +1 -1
  17. package/dist/assets/{chunk-4TB4RGXK-JytR14a9.js → chunk-4TB4RGXK-WyC_x_DH.js} +1 -1
  18. package/dist/assets/{chunk-55IACEB6-B4Q97BCP.js → chunk-55IACEB6-BajRv3zx.js} +1 -1
  19. package/dist/assets/{chunk-EDXVE4YY-R_qarkSf.js → chunk-EDXVE4YY-rMnedK_r.js} +1 -1
  20. package/dist/assets/{chunk-FMBD7UC4-C9s7KR9m.js → chunk-FMBD7UC4-BPi03Hcb.js} +1 -1
  21. package/dist/assets/{chunk-OYMX7WX6-BySQzVxc.js → chunk-OYMX7WX6-B7J_mKX0.js} +1 -1
  22. package/dist/assets/{chunk-QZHKN3VN-DdpSYZsW.js → chunk-QZHKN3VN-BLXTVr8N.js} +1 -1
  23. package/dist/assets/{chunk-YZCP3GAM-iE_tzriw.js → chunk-YZCP3GAM-BYWjo2OJ.js} +1 -1
  24. package/dist/assets/classDiagram-6PBFFD2Q-Balz1OEB.js +1 -0
  25. package/dist/assets/classDiagram-v2-HSJHXN6E-Balz1OEB.js +1 -0
  26. package/dist/assets/clone-CfNV0lUO.js +1 -0
  27. package/dist/assets/{cose-bilkent-S5V4N54A-BdlU6ZX_.js → cose-bilkent-S5V4N54A-Coaq0xaU.js} +1 -1
  28. package/dist/assets/{dagre-KV5264BT-Cp3F5KTn.js → dagre-KV5264BT-DvUvAxlj.js} +1 -1
  29. package/dist/assets/{diagram-5BDNPKRD-DiR6_2q_.js → diagram-5BDNPKRD-70bXRUXV.js} +1 -1
  30. package/dist/assets/{diagram-G4DWMVQ6-w0i-p5HX.js → diagram-G4DWMVQ6-hMA8wgzx.js} +1 -1
  31. package/dist/assets/{diagram-MMDJMWI5-tIHhwUv3.js → diagram-MMDJMWI5-BNir7C6i.js} +1 -1
  32. package/dist/assets/{diagram-TYMM5635-BAeY3B19.js → diagram-TYMM5635-BCYl1xrE.js} +1 -1
  33. package/dist/assets/{erDiagram-SMLLAGMA-Ckx_Knko.js → erDiagram-SMLLAGMA-bjxP0_bt.js} +1 -1
  34. package/dist/assets/{flowDiagram-DWJPFMVM-DeoNka6J.js → flowDiagram-DWJPFMVM-CBn9fhEp.js} +1 -1
  35. package/dist/assets/{ganttDiagram-T4ZO3ILL-BmGnFbEg.js → ganttDiagram-T4ZO3ILL-y1O7mWzn.js} +1 -1
  36. package/dist/assets/{gitGraphDiagram-UUTBAWPF-Dk48IHsx.js → gitGraphDiagram-UUTBAWPF-DIxwDXHB.js} +1 -1
  37. package/dist/assets/{graph-BNzKGvoy.js → graph-9D1ZumWp.js} +1 -1
  38. package/dist/assets/{index-BEfrNBp8.js → index-Ds_IXQo3.js} +2 -2
  39. package/dist/assets/{infoDiagram-42DDH7IO-BRf827i0.js → infoDiagram-42DDH7IO-DsWLGhaY.js} +1 -1
  40. package/dist/assets/{ishikawaDiagram-UXIWVN3A-0kCZaeCM.js → ishikawaDiagram-UXIWVN3A-CipZIE90.js} +1 -1
  41. package/dist/assets/{journeyDiagram-VCZTEJTY-rvU7ayRt.js → journeyDiagram-VCZTEJTY-Vr5xqcQm.js} +1 -1
  42. package/dist/assets/{kanban-definition-6JOO6SKY-DpQwX1C5.js → kanban-definition-6JOO6SKY-EqUYneyh.js} +1 -1
  43. package/dist/assets/{layout-BI8cXFPI.js → layout-hfWIIs0-.js} +1 -1
  44. package/dist/assets/{linear-a0glcDiw.js → linear-BdDWoN0t.js} +1 -1
  45. package/dist/assets/{min-vPHfnXcC.js → min-Bn_xAS7n.js} +1 -1
  46. package/dist/assets/{mindmap-definition-QFDTVHPH-D14eF-7C.js → mindmap-definition-QFDTVHPH-qsgubzCF.js} +1 -1
  47. package/dist/assets/{pieDiagram-DEJITSTG-Cno-gETh.js → pieDiagram-DEJITSTG-Bv1xq_58.js} +1 -1
  48. package/dist/assets/{quadrantDiagram-34T5L4WZ-BUQM1Hfm.js → quadrantDiagram-34T5L4WZ-DwMbAegF.js} +1 -1
  49. package/dist/assets/{requirementDiagram-MS252O5E-pOXlN2-q.js → requirementDiagram-MS252O5E-BJVmLNcp.js} +1 -1
  50. package/dist/assets/{sankeyDiagram-XADWPNL6-Crynd3_b.js → sankeyDiagram-XADWPNL6-o5GZb8Y1.js} +1 -1
  51. package/dist/assets/{sequenceDiagram-FGHM5R23-D9fZdCM8.js → sequenceDiagram-FGHM5R23-ocqJp2qk.js} +1 -1
  52. package/dist/assets/{stateDiagram-FHFEXIEX-CW9qVec8.js → stateDiagram-FHFEXIEX-DGaDUFxP.js} +1 -1
  53. package/dist/assets/stateDiagram-v2-QKLJ7IA2-Dz-15i-r.js +1 -0
  54. package/dist/assets/{timeline-definition-GMOUNBTQ-BcHzhm_8.js → timeline-definition-GMOUNBTQ-C4YwFvAn.js} +1 -1
  55. package/dist/assets/{vennDiagram-DHZGUBPP-BfytJcWk.js → vennDiagram-DHZGUBPP-uOKn9j-y.js} +1 -1
  56. package/dist/assets/{wardley-RL74JXVD-DLj-IjyB.js → wardley-RL74JXVD-DIQSmQde.js} +1 -1
  57. package/dist/assets/{wardleyDiagram-NUSXRM2D-Ds0Ue68c.js → wardleyDiagram-NUSXRM2D-CdamsEDC.js} +1 -1
  58. package/dist/assets/{xychartDiagram-5P7HB3ND-vjWDXFL6.js → xychartDiagram-5P7HB3ND-DhLs41yk.js} +1 -1
  59. package/dist/index.html +1 -1
  60. package/lib/agent-string.js +9 -4
  61. package/lib/build-cancel.js +205 -0
  62. package/lib/build-stream-writer.js +6 -0
  63. package/lib/build.js +1189 -165
  64. package/lib/canon-guard.js +3 -24
  65. package/lib/canon-registry.js +2 -71
  66. package/lib/codex-preflight.js +8 -0
  67. package/lib/colleague/context.js +123 -0
  68. package/lib/consumer-fanout.js +427 -17
  69. package/lib/decision-blocks.js +38 -0
  70. package/lib/dispatch-ledger.js +7 -0
  71. package/lib/experiment-pricing.js +5 -1
  72. package/lib/flow-state.js +38 -0
  73. package/lib/fluid/factory.js +112 -1
  74. package/lib/fluid/ideabox-manifest.js +203 -0
  75. package/lib/fluid/ideabox-migrate.js +177 -29
  76. package/lib/fluid/ideabox-preamble.js +155 -0
  77. package/lib/fluid/ideabox-readable.js +83 -0
  78. package/lib/fluid/ideabox-recover.js +393 -0
  79. package/lib/fluid/import-ideabox.js +188 -45
  80. package/lib/fluid/local-provider.js +6 -0
  81. package/lib/fluid/portfolio.js +255 -0
  82. package/lib/fluid/record-shape.js +7 -0
  83. package/lib/fluid/render-ideabox.js +153 -7
  84. package/lib/fluid/smartmemory-provider.js +6 -0
  85. package/lib/gate-prompt.js +14 -7
  86. package/lib/gsd.js +95 -48
  87. package/lib/ideabox-cli.js +68 -0
  88. package/lib/ideabox.js +209 -9
  89. package/lib/maya-identity.js +16 -2
  90. package/lib/model-pricing.js +4 -1
  91. package/lib/output-gate.js +81 -0
  92. package/lib/pipeline-profiles.js +200 -0
  93. package/lib/process-termination.js +121 -3
  94. package/lib/receipts-gate.js +268 -0
  95. package/lib/result-normalizer.js +41 -1
  96. package/lib/smartmemory-client.js +68 -1
  97. package/lib/stratum-mcp-client.js +104 -5
  98. package/lib/team-flag.js +1 -1
  99. package/lib/tool-inventory.js +0 -1
  100. package/lib/version-check.js +9 -3
  101. package/lib/wave-checkpoint.js +100 -0
  102. package/package.json +7 -5
  103. package/presets/team-fable-astra.profiles.json +18 -0
  104. package/presets/team-fable-astra.stratum.yaml +236 -0
  105. package/server/build-stream-bridge.js +43 -1
  106. package/server/cc-session-watcher.js +54 -5
  107. package/server/compose-mcp-tools.js +48 -50
  108. package/server/compose-mcp.js +0 -2
  109. package/server/design-routes.js +1 -1
  110. package/server/file-watcher.js +14 -0
  111. package/server/ideabox-routes.js +10 -0
  112. package/server/index.js +5 -1
  113. package/server/lifecycle-guard.js +13 -0
  114. package/server/maya-routes.js +111 -7
  115. package/server/mcp-tool-defs.js +0 -25
  116. package/server/mcp-tool-policy.js +6 -13
  117. package/server/model-tiers.js +14 -6
  118. package/server/stratum-client.js +61 -15
  119. package/server/supervisor.js +18 -4
  120. package/server/vision-routes.js +9 -3
  121. package/dist/assets/channel-SnZzzh7k.js +0 -1
  122. package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +0 -1
  123. package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +0 -1
  124. package/dist/assets/clone-DgklGjHm.js +0 -1
  125. package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +0 -1
  126. package/lib/append-integrity.js +0 -81
  127. package/lib/canon-override.js +0 -196
package/lib/build.js CHANGED
@@ -9,12 +9,13 @@
9
9
  */
10
10
 
11
11
  import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, renameSync, symlinkSync } from 'node:fs';
12
- import { join, resolve, dirname, relative, posix } from 'node:path';
12
+ import { join, resolve, dirname, basename, relative, posix } from 'node:path';
13
13
  import { fileURLToPath } from 'node:url';
14
14
  import { execSync, execFileSync } from 'node:child_process';
15
15
  import { createHash, randomUUID } from 'node:crypto';
16
+ import { setTimeout as sleep } from 'node:timers/promises';
16
17
 
17
- import { StratumMcpClient, StratumError, resolvePlanSpecValues, resolveStepProfile } from './stratum-mcp-client.js';
18
+ import { StratumMcpClient, StratumError, resolvePlanSpecValues, resolveStepProfile, asTransportRefusal, isUnknownFlowError } from './stratum-mcp-client.js';
18
19
  import { resolveStratumMcpConnection } from './stratum-engine.js';
19
20
  import { runAndNormalize, mergeUsage, AgentTimeoutError, AgentAbortedError, UserInterruptError, AgentError } from './result-normalizer.js';
20
21
  import { checkCapabilityViolation } from './capability-checker.js';
@@ -26,14 +27,14 @@ import { preflightCodexWorktreeProbe, codexProbeAbortMessage } from './codex-pre
26
27
  import { buildStepPrompt, buildGateContext, clearAmbientContextCache } from './step-prompt.js';
27
28
  import { promptGate } from './gate-prompt.js';
28
29
  import { VisionWriter, ServerUnreachableError } from './vision-writer.js';
29
- import { readFlowRound } from './flow-state.js';
30
+ import { readFlowRound, readFlowSnapshot, readFlowSpend } from './flow-state.js';
30
31
  import { resolvePort } from './resolve-port.js';
31
32
  import { probeServer } from './server-probe.js';
32
33
  import { CliProgress } from './cli-progress.js';
33
34
  import { BuildStreamWriter } from './build-stream-writer.js';
34
35
  import { appendBuildHistory, projectHistorySteps, stepOutcomeToStatus } from './build-history.js';
35
36
  import { KNOWN_VERSIONS } from './build-stream-schema.js';
36
- import { resolveAgentConfig, parseAgentString } from './agent-string.js';
37
+ import { resolveAgentConfig, parseAgentString, validateAgentString } from './agent-string.js';
37
38
  import { emitSections as emitPlanSections, appendTrailers as appendSectionTrailers, analyzeRollup, writeRollup } from './sections.js';
38
39
  import { SECTIONS_DIR } from './constants.js';
39
40
  import { rtkPrefix } from './rtk.js';
@@ -58,6 +59,20 @@ import { isTriageStale } from './triage.js';
58
59
  import { applyFrontTriage, maybeEscalateLane } from './lane-gate.js';
59
60
  import { LENS_DEFINITIONS } from './review-lenses.js';
60
61
  import { injectCertInstructions } from './cert-inject.js';
62
+ // COMP-BUILD-CANCEL: the build-level cancel handle, its registry and the bounded
63
+ // SIGINT/SIGTERM teardown (blueprint §3.4-§3.6).
64
+ import {
65
+ createBuildCancel,
66
+ confirmCancellation,
67
+ isRunCancelled,
68
+ flowTag,
69
+ runCancelTeardown,
70
+ cancelBudgets,
71
+ withDeadline,
72
+ registerBuildCancel,
73
+ unregisterBuildCancel,
74
+ lookupBuildCancel,
75
+ } from './build-cancel.js';
61
76
  import { buildReviewPrompt } from './review-prompt.js';
62
77
  import { detectTestFramework, scaffoldTestFramework, parseTestSummary, deriveTestsPass, deriveTestsAttested, isTestFile } from './test-bootstrap.js';
63
78
  import { classifyStepAsTier, evaluateTiers } from './gate-tiers.js';
@@ -76,10 +91,14 @@ import {
76
91
  ConsumerArtifactError,
77
92
  ConsumerFanoutArtifacts,
78
93
  ConsumerMergeDecisionError,
94
+ MergeAfterCancelError,
79
95
  isConsumerDescriptor,
80
96
  recoverAdvancedConsumerArtifacts,
81
97
  verifyConsumerRunRevision,
82
98
  } from './consumer-fanout.js';
99
+ import { preflightPipelineProfiles as preflightProfiles, mergeRuntimeProfiles, validateWaveAdmission, resolveConsumerProfile, profilesDigest, PipelineProfileError } from './pipeline-profiles.js';
100
+ import { decideGateFromOutput } from './output-gate.js';
101
+ import { readCheckpointRef, worktreeBaseFor, squashOntoBase, removeCheckpointRef, WaveCheckpointError } from './wave-checkpoint.js';
83
102
  import { appendEvent as appendDispatchEvent, readEvents as readDispatchEvents } from './dispatch-ledger.js';
84
103
  import { appendEvent as appendFeatureEvent } from './feature-events.js';
85
104
 
@@ -687,6 +706,16 @@ function responseReissuesStep(response, stepId) {
687
706
  return response.ready.some((ready) => ready?.id === stepId && ready?.previousFailure);
688
707
  }
689
708
 
709
+ async function callFlowWithCancellation(stratum, method, flowId, buildCancel, ...args) {
710
+ if (buildCancel.cancelled) throw buildCancel.signal.reason;
711
+ try {
712
+ return await stratum[method](flowId, ...args);
713
+ } catch (error) {
714
+ await confirmCancellation(error, { stratum, flowId, buildCancel });
715
+ throw error;
716
+ }
717
+ }
718
+
690
719
  async function reportConsumerStepDone({
691
720
  descriptor,
692
721
  flowId,
@@ -695,7 +724,9 @@ async function reportConsumerStepDone({
695
724
  artifacts,
696
725
  progress,
697
726
  streamWriter,
727
+ buildCancel,
698
728
  }) {
729
+ if (buildCancel?.cancelled) throw buildCancel.signal.reason;
699
730
  try {
700
731
  return {
701
732
  response: await stratum.stepDone(
@@ -704,6 +735,7 @@ async function reportConsumerStepDone({
704
735
  skipped: false,
705
736
  };
706
737
  } catch (error) {
738
+ if (await confirmCancellation(error, { stratum, flowId, buildCancel })) throw error;
707
739
  if (!isStaleOrDuplicateConsumerReportError(error)) throw error;
708
740
 
709
741
  const summary = `consumer item ${descriptor.id} (index ${descriptor.itemIndex}) step_done skipped:`
@@ -744,6 +776,257 @@ async function reportConsumerStepDone({
744
776
  }
745
777
  }
746
778
 
779
+ export function waveProfilesEnabled(profiles) {
780
+ return Object.entries(profiles ?? {}).some(([id, entry]) =>
781
+ id === '_consumer' || id === '_costCeiling' || (!id.startsWith('_') && typeof entry === 'object'));
782
+ }
783
+
784
+ /** One durable delivery spool for metadata and paid-call receipts. */
785
+ export async function flushWaveReceipts(context, dispatchIds) {
786
+ const artifacts = context.artifacts;
787
+ for (const pending of artifacts?.journal.pendingUsageReceipts ?? []) {
788
+ if (pending.state === 'acknowledged' || dispatchIds && !dispatchIds.includes(pending.dispatchId)) continue;
789
+ if (context.buildCancel?.cancelled || typeof context.stratum?.usageReport !== 'function') {
790
+ throw new ConsumerArtifactError('WAVE_EVIDENCE_INCOMPLETE', 'Receipt retained locally; replication unavailable or cancelled');
791
+ }
792
+ try {
793
+ const ack = await context.stratum.usageReport(context.flowId, pending.receipt);
794
+ if (!['ok', 'accepted', 'duplicate'].includes(ack?.status)) throw new Error('Receipt acknowledgement missing');
795
+ artifacts.acknowledgeUsageReceipt({ dispatchId: pending.dispatchId, seq: ack.receipt?.seq ?? ack.seq });
796
+ } catch (error) {
797
+ await confirmCancellation(error, context);
798
+ throw new ConsumerArtifactError('WAVE_EVIDENCE_INCOMPLETE', `Receipt ${pending.dispatchId} retained locally: ${error.message}`);
799
+ }
800
+ }
801
+ }
802
+
803
+ export async function reportWaveEvidence(context, kind, token, detail, state) {
804
+ const dispatchId = `compose:${kind}:${context.flowId}:${token}${state ? `:${state}` : ''}`;
805
+ const receipt = { dispatchId, source: `compose:${kind}`, usage: {}, detail };
806
+ context.artifacts.recordPendingUsageReceipt({ dispatchId, receipt });
807
+ await flushWaveReceipts(context, [dispatchId]);
808
+ return dispatchId;
809
+ }
810
+
811
+ /** Validate the recorded full input, before a concurrency-truncated ready list runs. */
812
+ export async function admitConsumerWave({ descriptor, descriptors = [descriptor], audit, localSpec,
813
+ profiles = {}, artifacts, stratum, flowId }) {
814
+ const policy = profiles._consumer?.[descriptor.step] ?? {};
815
+ if (!profiles[descriptor.step]?.tier_from && !profiles._consumer?.[descriptor.step]) return null;
816
+ audit ??= await stratum.audit(flowId);
817
+ const steps = localSpec.flows[localSpec.flows.entry]?.steps ?? [];
818
+ const step = steps.find(s => s.id === descriptor.step);
819
+ const state = audit?.steps?.[descriptor.step];
820
+ // Fresh engine fanouts omit the parent epoch but stamp every audit item with 0.
821
+ // Derive from that recorded item evidence; never admit an unrecorded epoch.
822
+ const epoch = state?.epoch ?? state?.fanout?.items?.[0]?.epoch;
823
+ const findings = [];
824
+ const add = message => findings.push({ code: 'WAVE_INPUT_INVALID', message });
825
+ let items;
826
+ let sourceProvenance;
827
+ try {
828
+ const reference = step?.fanout?.over;
829
+ const expression = typeof reference === 'string' && reference.match(/^\$\{([a-z][a-z0-9_-]*)((?:\.[A-Za-z_]\w*|\[\d+\])*)\}$/);
830
+ if (!expression || ['item', 'prev'].includes(expression[1])) throw new Error('Unsupported recorded fanout reference');
831
+ const root = expression[1];
832
+ const path = [...expression[2].matchAll(/\.([A-Za-z_]\w*)|\[(\d+)\]/g)].map(m => m[1] ?? Number(m[2]));
833
+ const access = (value, segments) => {
834
+ for (const key of segments) {
835
+ if (value === null || typeof value !== 'object' || !Object.hasOwn(value, key)
836
+ || ['__proto__', 'prototype', 'constructor'].includes(key)) return undefined;
837
+ value = value[key];
838
+ }
839
+ return value;
840
+ };
841
+ if (root === 'input' && path.length) {
842
+ const snapshot = readFlowSnapshot(flowId, { revisionDigest: artifacts.journal.revisionDigest });
843
+ items = access(snapshot.input, path);
844
+ sourceProvenance = { reference, inputDigest: profilesDigest(snapshot.input) };
845
+ } else if (path[0] === 'output') {
846
+ const source = audit.steps?.[root];
847
+ // Ordinary source steps also omit their initial epoch (the engine uses 0).
848
+ const sourceEpoch = source?.epoch ?? 0;
849
+ if (source?.status !== 'succeeded' || sourceEpoch !== epoch) throw new Error('Wave source is not current and succeeded');
850
+ items = access(source.output, path.slice(1));
851
+ sourceProvenance = { reference, step: root, epoch: sourceEpoch,
852
+ acceptedDispatchToken: source.acceptedDispatchToken, outputDigest: profilesDigest(source.output) };
853
+ } else {
854
+ // Engine carry expressions are bare names (${wave.tasks}), not ${carry.wave.tasks}.
855
+ const carry = Object.hasOwn(audit.carry ?? {}, root) ? audit.carry[root] : undefined;
856
+ items = access(carry?.value, path);
857
+ sourceProvenance = { reference, carry: carry ?? null };
858
+ }
859
+ if (!Array.isArray(items) || !Number.isInteger(epoch) || epoch < 0
860
+ || !Array.isArray(state?.fanout?.items) || items.length !== state.fanout.items.length) {
861
+ throw new Error('Recorded wave length/epoch differs from fanout');
862
+ }
863
+ for (const [index, item] of state.fanout.items.entries()) {
864
+ if ((item.epoch ?? state.epoch) !== epoch || item.index !== undefined && item.index !== index) {
865
+ throw new Error('Recorded fanout item index/epoch differs from wave');
866
+ }
867
+ }
868
+ for (const d of descriptors.filter(d => d.step === descriptor.step)) {
869
+ const recorded = state.fanout.items[d.itemIndex];
870
+ if (!recorded || !Number.isInteger(d.itemIndex) || d.itemIndex < 0
871
+ || !Number.isInteger(d.stage) || d.stage < 0 || d.stage >= step.fanout.steps.length
872
+ || recorded.generation !== d.generation
873
+ || d.epoch !== undefined && d.epoch !== epoch
874
+ || profilesDigest(d.item) !== profilesDigest(items[d.itemIndex])) throw new Error('Descriptor differs from recorded wave item');
875
+ }
876
+ } catch (error) { add(error.message); }
877
+ const profilesByStage = [];
878
+ if (!findings.length) for (const stage of step.fanout.steps) {
879
+ const result = validateWaveAdmission(profiles[step.id] ?? stage.agent ?? 'claude', items,
880
+ { provider: stage.agent?.startsWith('$.input')
881
+ ? resolveConsumerProfile(profiles[step.id], {}).provider : stage.agent ?? 'claude',
882
+ ownership: policy.ownership, independent: policy.independent });
883
+ findings.push(...result.findings);
884
+ profilesByStage.push(result.profiles.map(p => ({ ...p, profilesDigest: artifacts.journal.profilesDigest })));
885
+ }
886
+ if (findings.length) {
887
+ const failure = `${findings[0].code}: ${findings.map(f => `item ${(f.itemIndex ?? -1) + 1} ${f.message}`).join('; ')}`;
888
+ await reportWaveEvidence({ artifacts, stratum, flowId }, 'wave_admission',
889
+ `${descriptor.step}:${epoch ?? 'unknown'}`, { findings, failure, items: items ?? null });
890
+ return { failure, findings };
891
+ }
892
+ if (policy.checkpoint_gate) artifacts.initializeWave({ ref: `refs/heads/compose/wave/${flowId}`,
893
+ profilesDigest: artifacts.journal.profilesDigest });
894
+ if (artifacts.journal.wave?.checkpoints.some(c => c.state !== 'published' || !c.evidenceReceiptId)) {
895
+ throw new WaveCheckpointError('WAVE_CHECKPOINT_EVIDENCE_MISSING', 'Checkpoint publication/evidence pending before admission');
896
+ }
897
+ const previous = artifacts.journal.waveAdmissions?.find(a => a.fanoutStepId === step.id && a.epoch === epoch);
898
+ const baseCommit = previous?.baseCommit ?? (artifacts.journal.wave
899
+ ? worktreeBaseFor({ journal: artifacts.journal, ref: readCheckpointRef({ cwd: artifacts.targetCwd, ref: artifacts.journal.wave.ref }) })
900
+ : execFileSync('git', ['rev-parse', 'HEAD'], { cwd: artifacts.targetCwd, encoding: 'utf8' }).trim());
901
+ artifacts.recordWaveAdmission({ fanoutStepId: step.id, epoch, inputDigest: profilesDigest(items),
902
+ sourceProvenance, baseCommit, items: items.map((item, itemIndex) => ({ itemIndex,
903
+ itemDigest: profilesDigest(item), filesOwned: item.files_owned ?? [],
904
+ profilesByStage: profilesByStage.map(stage => stage[itemIndex]) })) });
905
+ const bindings = {};
906
+ for (const d of descriptors.filter(d => d.step === descriptor.step)) {
907
+ bindings[d.dispatchToken] = artifacts.recordDispatchBinding({ dispatchToken: d.dispatchToken,
908
+ itemBinding: { item: items[d.itemIndex], itemDigest: profilesDigest(items[d.itemIndex]), epoch, sourceProvenance },
909
+ resolvedProfile: profilesByStage[d.stage][d.itemIndex] });
910
+ }
911
+ return { bindings };
912
+ }
913
+
914
+ /** recoverCheckpoint owns prepared-fsync -> CAS -> published, using the merge witness. */
915
+ export async function publishConsumerCheckpoint(context, transaction) {
916
+ const artifacts = context.artifacts;
917
+ if (!artifacts?.journal.wave || !transaction) return;
918
+ artifacts.recoverCheckpoint(transaction);
919
+ await replicateCheckpoints(context);
920
+ }
921
+
922
+ function capturedWavePaths(artifacts) {
923
+ return [...new Set((artifacts.journal.wave?.checkpoints ?? []).flatMap(checkpoint =>
924
+ execFileSync('git', ['diff', '--name-only', '-z', '--no-renames', checkpoint.baselineTree, checkpoint.tree, '--'],
925
+ { cwd: artifacts.targetCwd, encoding: 'utf8' }).split('\0').filter(Boolean)))];
926
+ }
927
+
928
+ async function replicateCheckpoints(context) {
929
+ const artifacts = context.artifacts;
930
+ for (const checkpoint of artifacts?.journal.wave?.checkpoints ?? []) {
931
+ if (checkpoint.evidenceReceiptId) continue;
932
+ const { publishedAt, materializedTree, preparedAt, state, ...identity } = checkpoint;
933
+ const evidenceReceiptId = await reportWaveEvidence(context, 'checkpoint', checkpoint.gateToken, identity, 'published');
934
+ artifacts.markCheckpointPublished({ gateToken: checkpoint.gateToken, commit: checkpoint.commit, evidenceReceiptId });
935
+ context.streamWriter?.write({ type: 'wave_checkpoint', ...identity, flowId: context.flowId });
936
+ }
937
+ if (artifacts?.journal.wave) {
938
+ context.filesChanged = capturedWavePaths(artifacts);
939
+ context.recordFilesChanged?.(context.filesChanged);
940
+ }
941
+ }
942
+
943
+ /** Current audit only; a token's durable human hold survives a ceiling override. */
944
+ export async function evaluateConfiguredGate(context, { localSpec, gateStepId, gateToken, audit, costCeilingUsd }) {
945
+ const profiles = context.pipelineProfiles ?? {};
946
+ const config = profiles[gateStepId]?.decide_from ? profiles[gateStepId] : null;
947
+ const budget = profiles._costCeiling?.gates.includes(gateStepId) ? profiles._costCeiling : null;
948
+ if (!config && !budget) return null;
949
+ audit ??= await context.stratum.audit(context.flowId);
950
+ let decision = null;
951
+ let ceiling;
952
+ if (budget) {
953
+ try {
954
+ if (!context.receiptsMode) throw new Error('Usage receipt surface unavailable');
955
+ await flushWaveReceipts(context);
956
+ const read = readFlowSpend(context.flowId, { revisionDigest: context.artifacts.journal.revisionDigest,
957
+ gateStepId, gateToken }, context.artifacts.journal.pendingUsageReceipts);
958
+ const limit = costCeilingUsd ?? read.input?.[budget.input] ?? budget.default;
959
+ if (!Number.isFinite(limit) || limit <= 0) throw new Error('Ceiling must be finite and positive');
960
+ ceiling = { spent: read.spent, ceiling: limit };
961
+ const control = { ceiling: limit, input: budget.input, override: costCeilingUsd ?? null };
962
+ await reportWaveEvidence(context, 'cost_ceiling', gateToken, control, profilesDigest(control));
963
+ if (read.spent > limit) decision = { outcome: null, reason: 'WAVE_COST_CEILING_EXCEEDED', ...ceiling };
964
+ } catch (error) {
965
+ decision = { outcome: null, reason: 'WAVE_COST_UNVERIFIED', message: error.message };
966
+ }
967
+ }
968
+ const held = context.artifacts.journal.pendingUsageReceipts?.find(p =>
969
+ p.dispatchId === `compose:gate_hold:${context.flowId}:${gateToken}`);
970
+ if (held) return { ...held.receipt.detail, outcome: null };
971
+ if (!decision && config) {
972
+ const steps = localSpec.flows[localSpec.flows.entry].steps;
973
+ const execute = steps.find(s => s.id === 'execute' && s.fanout?.dispatch === 'consumer')
974
+ ?? steps.find(s => profiles._consumer?.[s.id]?.independent)
975
+ ?? steps.find(s => profiles[s.id]?.tier_from)
976
+ ?? steps.find(s => profiles._consumer?.[s.id]) ?? steps.find(s => s.fanout?.dispatch === 'consumer');
977
+ const executeProfile = profiles[execute?.id] ?? execute?.fanout?.steps?.[0]?.agent ?? 'claude';
978
+ const executeProvider = resolveConsumerProfile(executeProfile, {}).provider;
979
+ // The engine omits `epoch` on a step that has never been revised (initial epoch
980
+ // = 0, the same convention admission applies at the wave seam); the gate's
981
+ // staleness fence compares integers, so materialise the convention here rather
982
+ // than reading a fresh run as stale (GATE_SOURCE_STALE on every first pass).
983
+ const states = Object.fromEntries(Object.entries(audit.steps ?? {}).map(([id, state]) =>
984
+ [id, state && typeof state === 'object' ? { ...state, epoch: state.epoch ?? 0 } : state]));
985
+ decision = decideGateFromOutput(config, states, { gateStepId, gateToken, ceiling, executeProfile, executeProvider });
986
+ }
987
+ if (!decision) return null;
988
+ if (!decision.outcome) {
989
+ // Queue first even if the cost-delivery barrier itself is unavailable.
990
+ try { await reportWaveEvidence(context, 'gate_hold', gateToken, decision); }
991
+ catch (error) { if (context.buildCancel?.cancelled) throw error; }
992
+ } else {
993
+ await reportWaveEvidence(context, 'gate_decision', gateToken, decision, 'proposed');
994
+ const fresh = await context.stratum.audit(context.flowId);
995
+ if (fresh.steps?.[gateStepId]?.gateToken !== gateToken
996
+ || [config.decide_from.step, ...(config.validators ?? []).map(v => v.review_step)].some(id =>
997
+ profilesDigest(fresh.steps?.[id] ?? null) !== profilesDigest(audit.steps?.[id] ?? null))) {
998
+ return { outcome: null, reason: 'GATE_SOURCE_STALE' };
999
+ }
1000
+ }
1001
+ return decision;
1002
+ }
1003
+
1004
+ export function prepareWaveShip(context) {
1005
+ const artifacts = context?.artifacts;
1006
+ const wave = artifacts?.journal.wave;
1007
+ if (!wave?.checkpoints.length) return;
1008
+ const checkpoint = wave.checkpoints.at(-1);
1009
+ if (checkpoint.state !== 'published' || !checkpoint.evidenceReceiptId) {
1010
+ throw new WaveCheckpointError('WAVE_CHECKPOINT_EVIDENCE_MISSING', 'Ship requires acknowledged checkpoint evidence');
1011
+ }
1012
+ const head = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: artifacts.targetCwd, encoding: 'utf8' }).trim();
1013
+ if (head !== wave.baseCommit) {
1014
+ const shipped = artifacts.journal.pendingUsageReceipts?.find(p =>
1015
+ p.receipt.source === 'compose:wave_ship' && p.receipt.detail.commit === head);
1016
+ const parent = execFileSync('git', ['rev-parse', `${head}^`], { cwd: artifacts.targetCwd, encoding: 'utf8' }).trim();
1017
+ if (!shipped || parent !== wave.baseCommit || shipped.receipt.detail.checkpointTree !== checkpoint.tree) {
1018
+ throw new WaveCheckpointError('WAVE_CHECKPOINT_DIVERGED', 'HEAD differs from base and recorded ship');
1019
+ }
1020
+ context.filesChanged = shipped.receipt.detail.filesChanged;
1021
+ return { replay: shipped };
1022
+ }
1023
+ const tree = squashOntoBase({ cwd: artifacts.targetCwd, ref: wave.ref, base: wave.baseCommit });
1024
+ if (tree !== checkpoint.tree) throw new WaveCheckpointError('WAVE_CHECKPOINT_DIVERGED', 'Ship tree differs from checkpoint');
1025
+ context.filesChanged = [...new Set([...(context.filesChanged ?? []), ...capturedWavePaths(artifacts)])];
1026
+ context.recordFilesChanged?.(context.filesChanged);
1027
+ return tree;
1028
+ }
1029
+
747
1030
  export async function runConsumerIssuance({
748
1031
  descriptor,
749
1032
  flowId,
@@ -761,11 +1044,37 @@ export async function runConsumerIssuance({
761
1044
  // events for this item and halts a spinning run.
762
1045
  perItemTimeoutMs = null,
763
1046
  stuckDetector = null,
1047
+ // COMP-BUILD-CANCEL S03-5: the build-level cancel handle, so an item's agent dies with
1048
+ // the build. Optional — lib/gsd.js calls this without one.
1049
+ buildCancel = null,
764
1050
  // D6: the full agent profile string for this fanout step (from the compose
765
1051
  // sidecar), applied at invocation so e.g. an isolation:none review fanout runs
766
1052
  // read-only. Absent → the descriptor's bare provider literal (no restrictions).
767
1053
  profile = null,
1054
+ admission = null,
768
1055
  }) {
1056
+ if (profile && typeof profile !== 'string') throw new Error('Consumer profile must be a resolved string; pass pipelineProfiles for item admission');
1057
+ const configured = context.pipelineProfiles?.[descriptor.step]?.tier_from
1058
+ || context.pipelineProfiles?._consumer?.[descriptor.step];
1059
+ if (configured && !admission) admission = await admitConsumerWave({
1060
+ descriptor, descriptors: [descriptor], audit: await stratum.audit(flowId),
1061
+ localSpec, profiles: context.pipelineProfiles, artifacts, stratum, flowId,
1062
+ });
1063
+ const bound = admission?.bindings?.[descriptor.dispatchToken];
1064
+ if (bound) profile = bound.resolvedProfile.profile;
1065
+ const evidenceContext = { ...context, stratum, flowId, artifacts, buildCancel };
1066
+ if (admission?.failure) {
1067
+ const envelope = { failure: admission.failure };
1068
+ const entry = artifacts.prepareArtifactFailure(descriptor, envelope,
1069
+ new ConsumerArtifactError(admission.findings[0]?.code ?? 'WAVE_INPUT_INVALID', admission.failure));
1070
+ await reportWaveEvidence(evidenceContext, 'wave_rejected', descriptor.dispatchToken,
1071
+ { findings: admission.findings });
1072
+ const report = await reportConsumerStepDone({ descriptor, flowId, envelope: entry.envelope,
1073
+ stratum, artifacts, progress, streamWriter, buildCancel });
1074
+ if (!report.skipped) artifacts.reconcileAudit(await stratum.audit(flowId),
1075
+ { fanoutStepId: descriptor.step, itemIndex: descriptor.itemIndex });
1076
+ return report.response;
1077
+ }
769
1078
  let recovery;
770
1079
  try {
771
1080
  recovery = artifacts.reconcileDescriptor(descriptor, audit);
@@ -782,7 +1091,7 @@ export async function runConsumerIssuance({
782
1091
  const envelope = { failure: `${artifactError.code}: ${artifactError.message}` };
783
1092
  artifacts.prepareArtifactFailure(descriptor, envelope, artifactError);
784
1093
  const report = await reportConsumerStepDone({
785
- descriptor, flowId, envelope, stratum, artifacts, progress, streamWriter,
1094
+ descriptor, flowId, envelope, stratum, artifacts, progress, streamWriter, buildCancel,
786
1095
  });
787
1096
  if (report.skipped) return report.response;
788
1097
  const { response } = report;
@@ -797,10 +1106,13 @@ export async function runConsumerIssuance({
797
1106
  throw new Error(`accepted consumer issuance ${descriptor.id} unexpectedly remained ready`);
798
1107
  }
799
1108
  if (recovery.action === 'report') {
1109
+ const entry = artifacts.journal.issuances.find(e => e.dispatchToken === descriptor.dispatchToken);
1110
+ if (entry?.findings?.length) await reportWaveEvidence(evidenceContext, 'ownership', descriptor.dispatchToken, { findings: entry.findings });
800
1111
  const report = await reportConsumerStepDone({
801
1112
  descriptor,
802
1113
  flowId,
803
1114
  envelope: recovery.envelope,
1115
+ buildCancel,
804
1116
  stratum,
805
1117
  artifacts,
806
1118
  progress,
@@ -866,8 +1178,7 @@ export async function runConsumerIssuance({
866
1178
  // D3: stable per-item key for the stuck detector's per-task bookkeeping. In
867
1179
  // GSD mode this becomes the operator-facing stuck task id (stuck.json/pause.json/
868
1180
  // stuck.md), so it MUST be the decompose task id (T01), matching the blackboard
869
- // + milestone report. The engine's GSD consumer descriptor carries no `.item`,
870
- // so the real runGsd call site threads the resolved id via context.gsdTaskId
1181
+ // + milestone report. GSD threads descriptor.item.id via context.gsdTaskId
871
1182
  // (same precedence as gsdTaskId below); build-mode fanout passes no gsdTaskId and
872
1183
  // keeps the item-id/index key unchanged (byte-identical).
873
1184
  const stuckTaskId = context?.gsdTaskId ?? descriptor.item?.id ?? `${descriptor.step ?? descriptor.id}:${descriptor.itemIndex}`;
@@ -928,6 +1239,12 @@ export async function runConsumerIssuance({
928
1239
  lane,
929
1240
  });
930
1241
 
1242
+ if (bound) {
1243
+ streamWriter.write({ type: 'step_model', stepId: descriptor.id, flowId,
1244
+ itemIndex: descriptor.itemIndex, ...bound.resolvedProfile });
1245
+ await reportWaveEvidence(evidenceContext, 'item_model', descriptor.dispatchToken,
1246
+ { intended: bound.resolvedProfile, itemBinding: bound.itemBinding }, 'proposed');
1247
+ }
931
1248
  let mainResult;
932
1249
  try {
933
1250
  mainResult = await runAndNormalize(null, prompt, dispatch, {
@@ -943,6 +1260,14 @@ export async function runConsumerIssuance({
943
1260
  reviewMode: reviewOpts.reviewMode,
944
1261
  confidenceGate: reviewOpts.confidenceGate,
945
1262
  lens: reviewOpts.lens,
1263
+ // COMP-BUILD-CANCEL S03-4: tag the item so `stratum_flow_cancel` can reach it
1264
+ // from another process, and chain the build handle so a local `isolation: none`
1265
+ // item — which never enters stratum — dies with the build.
1266
+ ...(flowTag(flowId, descriptor.step ?? descriptor.id, descriptor.itemIndex)
1267
+ ? { flow: flowTag(flowId, descriptor.step ?? descriptor.id, descriptor.itemIndex) }
1268
+ : {}),
1269
+ flowId, buildCancel,
1270
+ buildSignal: buildCancel?.signal,
946
1271
  telemetry: {
947
1272
  site: context.gsd ? 'gsd' : 'consumer',
948
1273
  project_cwd: context.projectCwd ?? context.cwd,
@@ -956,6 +1281,7 @@ export async function runConsumerIssuance({
956
1281
  localExecution: descriptor.policy?.isolation === 'none',
957
1282
  });
958
1283
  } catch (error) {
1284
+ if (buildCancel?.cancelled) throw error;
959
1285
  const failedUsage = failureUsageFields(error);
960
1286
  // Control failures do not settle the item, so record known dispatch usage
961
1287
  // before aborting the pump. Never retry work with uncertain termination.
@@ -1020,6 +1346,9 @@ export async function runConsumerIssuance({
1020
1346
  const { result, normalizationFailure } = mainResult;
1021
1347
  // D2(b): forward the item's agent usage so GSD can debit the cumulative
1022
1348
  // budget ledger. Build mode passes no onUsage sink → byte-identical no-op.
1349
+ if (context.pipelineProfiles?._costCeiling && !mainResult?.usage) {
1350
+ await context.onUsage?.({ dispatch_id: mainResult.dispatchIds?.primary ?? descriptor.dispatchToken }, { stepId: descriptor.step, source: 'fanout' });
1351
+ }
1023
1352
  if (typeof context?.onUsage === 'function' && mainResult?.usage) {
1024
1353
  await context.onUsage(usagePayload(mainResult.usage, mainResult.usages), {
1025
1354
  dispatchId: mainResult.dispatchIds?.primary,
@@ -1042,7 +1371,7 @@ export async function runConsumerIssuance({
1042
1371
  );
1043
1372
  if (gateFailure) localFailure = `pre_merge failed: ${JSON.stringify(gateFailure)}`;
1044
1373
  }
1045
- const envelope = localFailure
1374
+ let envelope = localFailure
1046
1375
  ? { failure: String(localFailure) }
1047
1376
  : contract.hasOutContract
1048
1377
  ? result != null
@@ -1066,8 +1395,18 @@ export async function runConsumerIssuance({
1066
1395
  });
1067
1396
  }
1068
1397
  const preparedEntry = artifacts.prepareIssuance(descriptor, envelope, {
1069
- finalStage,
1398
+ finalStage, ...(bound ?? {}),
1399
+ ownership: context.pipelineProfiles?._consumer?.[descriptor.step]?.ownership,
1070
1400
  });
1401
+ // Legacy injected artifact adapters predate returned envelopes. Configured
1402
+ // admission/ownership requires the authoritative primitive result.
1403
+ if (configured || bound || preparedEntry?.envelope) envelope = preparedEntry.envelope;
1404
+ localFailure = envelope.failure ?? localFailure;
1405
+ if (preparedEntry?.findings?.length) await reportWaveEvidence(evidenceContext, 'ownership',
1406
+ descriptor.dispatchToken, { findings: preparedEntry.findings });
1407
+ if (bound) await reportWaveEvidence(evidenceContext, 'item_model', descriptor.dispatchToken,
1408
+ { intended: bound.resolvedProfile, observed: { normalizedUsageModel: mainResult.usage?.model ?? null,
1409
+ connectorIdentityVerified: false } }, 'observed');
1071
1410
  // H3: record this item's timing + (final-stage) diff snapshot for the GSD
1072
1411
  // milestone report. `preparedEntry.diff` is the cumulative worktree diff the
1073
1412
  // artifacts journal already computed at final stage (null otherwise) — tapped
@@ -1089,7 +1428,7 @@ export async function runConsumerIssuance({
1089
1428
  }
1090
1429
 
1091
1430
  const report = await reportConsumerStepDone({
1092
- descriptor, flowId, envelope, stratum, artifacts, progress, streamWriter,
1431
+ descriptor, flowId, envelope, stratum, artifacts, progress, streamWriter, buildCancel,
1093
1432
  });
1094
1433
  if (report.skipped) return report.response;
1095
1434
  const { response } = report;
@@ -1114,7 +1453,7 @@ export async function runConsumerIssuance({
1114
1453
  stepId: descriptor.id,
1115
1454
  summary: result?.summary ?? `consumer item ${descriptor.itemIndex} stage ${descriptor.stage} reported`,
1116
1455
  retries: Math.max(0, (descriptor.attempt ?? 1) - 1),
1117
- violations: [],
1456
+ violations: preparedEntry?.findings ?? [],
1118
1457
  flowId,
1119
1458
  consumer: true,
1120
1459
  // H6: matches the item's start stepId so the UI decrements the same task
@@ -1123,7 +1462,7 @@ export async function runConsumerIssuance({
1123
1462
  // COMP-AGENT-LANES (C4): terminal status is explicit at source — the UI
1124
1463
  // must not infer "complete" from the done event's existence.
1125
1464
  status: localFailure ? 'failed' : 'succeeded',
1126
- outcome: result?.outcome ?? (localFailure ? 'failed' : 'succeeded'),
1465
+ outcome: localFailure ? 'failed' : (result?.outcome ?? 'succeeded'),
1127
1466
  itemIndex: descriptor.itemIndex,
1128
1467
  stage: descriptor.stage,
1129
1468
  generation: descriptor.generation,
@@ -1171,28 +1510,160 @@ function extractFilesChanged(response) {
1171
1510
  * @param {Array<{id?:string, files_owned?:string[]}>} tasks
1172
1511
  * @returns {string|null}
1173
1512
  */
1513
+ /**
1514
+ * A local copy of a bundled preset whose sidecar carries EXECUTION configuration
1515
+ * (object-form agent entries with tier_from, gate decide_from/validators,
1516
+ * `_consumer`, `_costCeiling`) must keep that sidecar: without it the output-driven
1517
+ * gate, per-item routing, ownership and the ceiling silently vanish and a blocking
1518
+ * repair decision ships (slice 4 review r1 #1). A string-only sidecar carries tool
1519
+ * restrictions and tiers, for which "missing → bare defaults" stays the documented
1520
+ * behaviour — and a custom spec that merely shares a bundled basename (every build
1521
+ * test fixture writes its own pipelines/build.stratum.yaml) is not a copy.
1522
+ */
1523
+ export function sidecarCarriesExecutionConfig(profiles) {
1524
+ if (!profiles || typeof profiles !== 'object') return false;
1525
+ return Object.entries(profiles).some(([key, value]) =>
1526
+ key === '_consumer' || key === '_costCeiling'
1527
+ || (!key.startsWith('_') && value !== null && typeof value === 'object'));
1528
+ }
1529
+
1530
+ export function requirePipelineSidecar(specPath) {
1531
+ const localPath = resolve(specPath);
1532
+ const name = basename(localPath).replace(/\.stratum\.ya?ml$/, '');
1533
+ if (name === basename(localPath)) return;
1534
+ const sidecarName = `${name}.profiles.json`;
1535
+ if (existsSync(join(dirname(localPath), sidecarName))) return;
1536
+
1537
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
1538
+ const bundledSpecs = ['presets', 'pipelines'].flatMap(directory =>
1539
+ ['yaml', 'yml'].map(ext => join(packageRoot, directory, `${name}.stratum.${ext}`)));
1540
+ if (bundledSpecs.includes(localPath)) return;
1541
+ for (const path of bundledSpecs) {
1542
+ const bundledSidecar = join(dirname(path), sidecarName);
1543
+ if (!existsSync(path) || !existsSync(bundledSidecar)) continue;
1544
+ let bundledProfiles;
1545
+ try { bundledProfiles = JSON.parse(readFileSync(bundledSidecar, 'utf-8')); } catch { continue; }
1546
+ if (sidecarCarriesExecutionConfig(bundledProfiles)) {
1547
+ throw Object.assign(new Error(
1548
+ `PROFILE_SIDECAR_REQUIRED: ${specPath} has no adjacent ${sidecarName} but the bundled ${name} ships one that configures gates/routing/ownership — copy both files (see docs/team-presets.md)`,
1549
+ ), { code: 'PROFILE_SIDECAR_REQUIRED' });
1550
+ }
1551
+ }
1552
+ }
1553
+
1174
1554
  /**
1175
1555
  * D6: load the compose-owned profile sidecar next to a pipeline spec. The engine
1176
1556
  * accepts only the literal claude|codex agent, so the full profile strings that
1177
1557
  * carry tool restrictions + model tiers (claude:read-only-reviewer,
1178
1558
  * claude::critical, claude:orchestrator, ...) live in <spec>.profiles.json keyed
1179
1559
  * by step id and are applied compose-side at invocation. Absent → {} (bare
1180
- * literals; no restrictions).
1560
+ * literals; no restrictions), after runBuild enforces required bundled sidecars.
1561
+ * Malformed or non-object sidecars fail closed.
1181
1562
  *
1182
1563
  * @param {string} specPath path to the .stratum.yaml spec
1183
1564
  * @returns {Record<string,string>} step id → agent profile string
1184
1565
  */
1185
1566
  export function loadPipelineProfiles(specPath) {
1567
+ const sidecar = String(specPath).replace(/\.stratum\.ya?ml$/, '.profiles.json');
1568
+ if (sidecar === String(specPath)) return {};
1186
1569
  try {
1187
- const sidecar = String(specPath).replace(/\.stratum\.ya?ml$/, '.profiles.json');
1188
- if (sidecar === String(specPath) || !existsSync(sidecar)) return {};
1189
1570
  const parsed = JSON.parse(readFileSync(sidecar, 'utf-8'));
1190
- return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
1191
- } catch {
1192
- return {};
1571
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
1572
+ throw new Error('expected an object mapping step ids to profiles');
1573
+ }
1574
+ return parsed;
1575
+ } catch (error) {
1576
+ if (error.code === 'ENOENT') return {};
1577
+ throw new Error(`Profile sidecar ${sidecar} is invalid: ${error.message}`);
1193
1578
  }
1194
1579
  }
1195
1580
 
1581
+ /**
1582
+ * Pure profile validation across all flows and fanout stages. Runtime references
1583
+ * use the supplied role inputs (build defaults for the initial static check).
1584
+ * No tier with modelID null means the documented connector default and is legal;
1585
+ * an explicit tier with modelID null is unavailable and must fail closed.
1586
+ * Metadata keys starting with '_' are ignored; stale step keys are errors.
1587
+ */
1588
+ export function preflightPipelineProfiles(stepProfiles, specYaml, specName = '<spec>', inputs = {
1589
+ implementer_agent: 'claude', reviewer_agent: 'codex',
1590
+ }) {
1591
+ const rawProfiles = stepProfiles;
1592
+ if (Array.isArray(rawProfiles._costCeiling?.gates) && rawProfiles._costCeiling.gates.includes('review_gate')) {
1593
+ throw new PipelineProfileError('WAVE_COST_CEILING_RESERVED_GATE',
1594
+ `Profile preflight failed for ${specName}: _costCeiling.gates cannot target reserved review_gate`);
1595
+ }
1596
+ stepProfiles = Object.fromEntries(Object.entries(stepProfiles).filter(([, entry]) => !entry?.decide_from).map(([id, entry]) => [id, entry?.default ?? entry]));
1597
+ const rawSpec = typeof specYaml === 'string' ? YAML.parse(specYaml) : structuredClone(specYaml);
1598
+ const spec = resolvePlanSpecValues(structuredClone(rawSpec), inputs);
1599
+ const rawSteps = Object.values(rawSpec?.flows ?? {}).flatMap(flow => flow?.steps ?? []);
1600
+ const rawById = new Map(rawSteps.map(step => [step.id, step]));
1601
+ const steps = Object.values(spec?.flows ?? {}).flatMap(flow => flow?.steps ?? []);
1602
+ const stepIds = new Set(steps.map(step => step.id));
1603
+ const failures = [];
1604
+ const resolved = {};
1605
+ const check = (id, profile, { engineDispatch = false } = {}) => {
1606
+ try {
1607
+ if (typeof profile !== 'string' || !profile.trim()) {
1608
+ throw new Error('profile must be a non-empty agent string');
1609
+ }
1610
+ validateAgentString(profile);
1611
+ const { provider, template, tier, modelID } = resolveAgentConfig(profile);
1612
+ if (tier && modelID === null) throw new Error(`tier "${tier}" has no model for provider "${provider}"`);
1613
+ // Review r1 #3: an engine-dispatched fanout invokes its connector inside
1614
+ // Stratum, so compose-side tiers/templates never reach the call — certifying
1615
+ // a model here that the engine will not use is worse than not checking.
1616
+ if (engineDispatch && (tier || template)) {
1617
+ throw new Error(`profile "${profile}" carries a tier/template but the fanout is dispatch: engine, where compose profiles are not applied`);
1618
+ }
1619
+ resolved[id] = { profile, provider, tier, modelID };
1620
+ } catch (error) {
1621
+ failures.push(`step "${id}": ${error.message}`);
1622
+ }
1623
+ };
1624
+ for (const id of Object.keys(stepProfiles)) {
1625
+ if (!id.startsWith('_') && !stepIds.has(id)) failures.push(`step "${id}": not found in spec`);
1626
+ }
1627
+ for (const step of steps) {
1628
+ const hasProfile = !step.id.startsWith('_') && Object.hasOwn(stepProfiles, step.id);
1629
+ const stages = step.fanout?.steps ?? [];
1630
+ const engineDispatch = step.fanout?.dispatch === 'engine';
1631
+ if (Object.hasOwn(step, 'agent') || (hasProfile && stages.length === 0)) {
1632
+ check(step.id, hasProfile ? stepProfiles[step.id] : step.agent);
1633
+ }
1634
+ // Review r1 #1: profiles (sidecar and runtime) are keyed by the FANOUT id and
1635
+ // applied to every stage at invocation (resolvePlanSpecValues records the
1636
+ // last stage's runtime profile under step.id). A multi-stage fanout whose raw
1637
+ // stage agents differ therefore cannot be routed honestly — fail closed.
1638
+ if (stages.length > 1) {
1639
+ const rawStages = rawById.get(step.id)?.fanout?.steps ?? [];
1640
+ const rawAgents = new Set(rawStages.map(stage => JSON.stringify(stage?.agent ?? null)));
1641
+ const runtimeRef = rawStages.some(stage => typeof stage?.agent === 'string' && stage.agent.startsWith('$.input'));
1642
+ // Bare literals with no profile run as written — nothing collapses.
1643
+ if (rawAgents.size > 1 && (hasProfile || runtimeRef)) {
1644
+ failures.push(`step "${step.id}": multi-stage fanout stages declare different agents (${[...rawAgents].join(', ')}); profiles are keyed by the fanout id and apply to every stage`);
1645
+ continue;
1646
+ }
1647
+ }
1648
+ for (const [index, stage] of stages.entries()) {
1649
+ // Review r1 #2: a stage with no explicit agent inherits claude but still
1650
+ // consumes the enclosing sidecar profile at invocation — check it too.
1651
+ if (!Object.hasOwn(stage, 'agent') && !hasProfile) continue;
1652
+ // Single-stage fanouts use the enclosing id, as invocation profile lookup does.
1653
+ const id = stages.length === 1 ? step.id : `${step.id}/${index}`;
1654
+ check(id, hasProfile ? stepProfiles[step.id] : stage.agent, { engineDispatch });
1655
+ }
1656
+ }
1657
+ if (failures.length) throw new Error(`Profile preflight failed for ${specName}: ${failures.join('; ')}`);
1658
+ let checked;
1659
+ try { checked = preflightProfiles(rawProfiles, spec); }
1660
+ catch (error) { throw new Error(`Profile preflight failed for ${specName}: ${error.message}`, { cause: error }); }
1661
+ // Keep the public legacy projection; runner-only pins are additive.
1662
+ return Object.defineProperties({ ok: true, resolved }, {
1663
+ normalized: { value: checked.normalized }, profilesDigest: { value: checked.profilesDigest },
1664
+ });
1665
+ }
1666
+
1196
1667
  /**
1197
1668
  * V1: convert a compose usage record (runAndNormalize's normalized shape, or the
1198
1669
  * raw TS complete usage) into the engine Budget shape `{tokens?, usd?, ms?}` the
@@ -1243,7 +1714,8 @@ function usagePayload(usage, usages) {
1243
1714
 
1244
1715
  /** Send one surface-15 receipt per underlying model dispatch. */
1245
1716
  export async function reportUsageReceipts(context, usage, meta = {}) {
1246
- if (!context?.receiptsMode || !context.flowId || typeof context.stratum?.usageReport !== 'function') {
1717
+ if ((!context?.pipelineProfiles?._costCeiling && context?.buildCancel?.cancelled)
1718
+ || !context?.receiptsMode || !context.flowId || typeof context.stratum?.usageReport !== 'function') {
1247
1719
  return [];
1248
1720
  }
1249
1721
  const entries = Array.isArray(usage)
@@ -1251,9 +1723,10 @@ export async function reportUsageReceipts(context, usage, meta = {}) {
1251
1723
  : (Array.isArray(usage?.usages) ? usage.usages : (usage ? [usage] : []));
1252
1724
  const responses = [];
1253
1725
  for (const entry of entries) {
1726
+ if (context.buildCancel?.cancelled && !context.pipelineProfiles?._costCeiling) break;
1254
1727
  if (!entry || typeof entry !== 'object') continue;
1255
- const engineUsage = toEngineUsage(entry);
1256
- if (!engineUsage) continue;
1728
+ const engineUsage = toEngineUsage(entry) ?? (context.pipelineProfiles?._costCeiling ? {} : null);
1729
+ if (!engineUsage && !context.pipelineProfiles?._costCeiling) continue;
1257
1730
  // Surface 15 requires explicit USD provenance. Normalized UsageRecords carry
1258
1731
  // `usd_source`; raw engine usage ({tokens, usd, ms}) does not. Preserve raw
1259
1732
  // token/time usage, but fail closed on an unlabelled dollar value instead of
@@ -1262,7 +1735,7 @@ export async function reportUsageReceipts(context, usage, meta = {}) {
1262
1735
  ? entry.usd_source
1263
1736
  : null;
1264
1737
  if (Object.hasOwn(engineUsage, 'usd') && !usdSource) delete engineUsage.usd;
1265
- if (Object.keys(engineUsage).length === 0) continue;
1738
+ if (Object.keys(engineUsage).length === 0 && !context.pipelineProfiles?._costCeiling) continue;
1266
1739
  const input = entry.input_tokens;
1267
1740
  const output = entry.output_tokens;
1268
1741
  const receipt = {
@@ -1288,10 +1761,16 @@ export async function reportUsageReceipts(context, usage, meta = {}) {
1288
1761
  } }
1289
1762
  : {}),
1290
1763
  ...(Object.hasOwn(engineUsage, 'usd') ? { usdSource } : {}),
1764
+ ...(context.pipelineProfiles?._costCeiling && (!usdSource || !Number.isFinite(entry.cost_usd ?? entry.usd))
1765
+ ? { detail: { costUnknown: true } } : {}),
1291
1766
  };
1292
1767
  try {
1293
- responses.push(await context.stratum.usageReport(context.flowId, receipt));
1768
+ if (context.pipelineProfiles?._costCeiling) {
1769
+ context.artifacts.recordPendingUsageReceipt({ dispatchId: receipt.dispatchId, receipt });
1770
+ await flushWaveReceipts(context, [receipt.dispatchId]);
1771
+ } else responses.push(await context.stratum.usageReport(context.flowId, receipt));
1294
1772
  } catch (error) {
1773
+ if (await confirmCancellation(error, context)) break;
1295
1774
  console.warn(`[usage-receipt] failed for ${receipt.dispatchId}: ${error?.message ?? error}`);
1296
1775
  }
1297
1776
  }
@@ -1466,12 +1945,38 @@ function readActiveBuild(dataDir) {
1466
1945
  }
1467
1946
  }
1468
1947
 
1469
- function writeActiveBuild(dataDir, state) {
1948
+ /**
1949
+ * COMP-BUILD-CANCEL §3.7 — re-read and CLAIM before ANY mutation of the active-build
1950
+ * record. Strict on purpose: a missing flow id must never match anything.
1951
+ *
1952
+ * Returns the claimed record, or a REASON. C48: the fallback validates PRESENCE before
1953
+ * equality, because `undefined === undefined` is true and two records that both lack a
1954
+ * pid would otherwise claim each other.
1955
+ */
1956
+ function claimActiveBuild(dataDir, active) {
1957
+ const cur = readActiveBuild(dataDir);
1958
+ if (!cur) return { ok: false, reason: 'ownership_lost' };
1959
+ if (cur.featureCode !== active.featureCode) return { ok: false, reason: 'ownership_lost' };
1960
+ if (cur.flowId && active.flowId) {
1961
+ return cur.flowId === active.flowId
1962
+ ? { ok: true, record: cur }
1963
+ : { ok: false, reason: 'ownership_lost' };
1964
+ }
1965
+ // No flow id on one side: fall back to pid + startedAt, but ONLY when all four are present.
1966
+ const present = Boolean(cur.pid) && Boolean(active.pid)
1967
+ && Boolean(cur.startedAt) && Boolean(active.startedAt);
1968
+ if (!present) return { ok: false, reason: 'ownership_unverifiable' };
1969
+ return (cur.pid === active.pid && cur.startedAt === active.startedAt)
1970
+ ? { ok: true, record: cur }
1971
+ : { ok: false, reason: 'ownership_lost' };
1972
+ }
1973
+
1974
+ export function writeActiveBuild(dataDir, state, { stampPid = true } = {}) {
1470
1975
  mkdirSync(dataDir, { recursive: true });
1471
- // Always stamp PID so concurrent processes can detect each other
1472
- state.pid = process.pid;
1976
+ // The aborter must preserve the driver's identity when it owns the terminal write.
1977
+ if (stampPid) state.pid = process.pid;
1473
1978
  const target = activeBuildPath(dataDir);
1474
- const tmp = target + '.tmp';
1979
+ const tmp = `${target}.${process.pid}.${randomUUID().slice(0, 8)}.tmp`;
1475
1980
  writeFileSync(tmp, JSON.stringify(state, null, 2));
1476
1981
  renameSync(tmp, target);
1477
1982
  }
@@ -1971,8 +2476,15 @@ export function makeAskAgent(stratum, context, gateDispatch, gateExtras) {
1971
2476
  `${fileRef} this question concisely:\n\n` +
1972
2477
  `${question}\n\n` +
1973
2478
  `Keep your answer brief — 2-3 sentences max.`;
2479
+ if (context.buildCancel?.cancelled) throw context.buildCancel.signal.reason;
2480
+ const taggedFlow = flowTag(context.flowId, gateDispatch.step_id ?? gateDispatch.id);
1974
2481
  const text = await stratum.runAgentText('claude', qaPrompt, {
1975
2482
  cwd: context.cwd,
2483
+ // S03-4 (R1): a gate pause leaves the run `running`, so a gate-time agent IS
2484
+ // admitted and tags successfully (C1). Conditional on `context.flowId` because
2485
+ // `makeAskAgent` is exported and called with a bare, flow-less context in tests.
2486
+ ...(taggedFlow ? { flow: taggedFlow } : {}),
2487
+ signal: context.buildCancel?.signal,
1976
2488
  telemetry: {
1977
2489
  site: 'gate-qa',
1978
2490
  project_cwd: context.projectCwd ?? context.cwd,
@@ -1990,6 +2502,9 @@ export function makeAskAgent(stratum, context, gateDispatch, gateExtras) {
1990
2502
  budgetExhausted = true;
1991
2503
  }
1992
2504
  },
2505
+ }).catch(async error => {
2506
+ await confirmCancellation(error, { stratum, flowId: context.flowId, buildCancel: context.buildCancel, tagged: Boolean(taggedFlow) });
2507
+ throw error;
1993
2508
  });
1994
2509
  return text || '(no answer)';
1995
2510
  };
@@ -2009,8 +2524,8 @@ export function deleteActiveBuild(dataDir) {
2009
2524
  * never produce more steps. Used to detect stale lock files and decide
2010
2525
  * whether a resumed flow needs a fresh start.
2011
2526
  */
2012
- function isTerminalFlow(status) {
2013
- return ['completed', 'failed', 'budget_exhausted'].includes(status);
2527
+ export function isTerminalFlow(status) {
2528
+ return ['completed', 'failed', 'budget_exhausted', 'cancelled'].includes(status);
2014
2529
  }
2015
2530
 
2016
2531
  function isRecoverableFlowProbeError(err) {
@@ -2158,7 +2673,34 @@ export async function emitBugCheckpointOnTerminalFailure(context, exhaustedStepI
2158
2673
  }
2159
2674
  }
2160
2675
 
2676
+ /** Guard the actual vision mutation, after updateItemStatus's async server probe.
2677
+ * Use a per-call receiver so a late best-effort update cannot outlive its claim. */
2678
+ async function killOwnedBuildVision(visionWriter, itemId, claimOwnership) {
2679
+ const ownedWriter = Object.create(visionWriter);
2680
+ for (const method of ['_directUpdateItemStatus', '_restUpdateItemStatus']) {
2681
+ ownedWriter[method] = (...args) => {
2682
+ const claim = claimOwnership();
2683
+ if (!claim.ok) throw Object.assign(new Error(claim.reason), { reason: claim.reason });
2684
+ return visionWriter[method](...args);
2685
+ };
2686
+ }
2687
+ return ownedWriter.updateItemStatus(itemId, 'killed');
2688
+ }
2689
+
2690
+ /** Claims protect the cancelled driver's record AND its vision projection. */
2691
+ async function terminalizeCancelledBuild({ dataDir, buildIdentity, visionWriter, itemId, failureReason }) {
2692
+ if (!buildIdentity) return;
2693
+ const claim = claimActiveBuild(dataDir, buildIdentity);
2694
+ if (!claim.ok) return;
2695
+ writeActiveBuild(dataDir, {
2696
+ ...claim.record, status: 'aborted', failureReason,
2697
+ completedAt: claim.record.completedAt ?? new Date().toISOString(),
2698
+ });
2699
+ try { await killOwnedBuildVision(visionWriter, itemId, () => claimActiveBuild(dataDir, buildIdentity)); } catch { /* best-effort projection */ }
2700
+ }
2701
+
2161
2702
  async function terminalizeThrownBuild({
2703
+ buildCancel, buildIdentity,
2162
2704
  cwd,
2163
2705
  dataDir,
2164
2706
  cfg,
@@ -2175,7 +2717,9 @@ async function terminalizeThrownBuild({
2175
2717
  }) {
2176
2718
  const flowId = response?.runId ?? null;
2177
2719
  if (!flowId) return false;
2178
- await writeFailedBuildTerminalState({
2720
+ if (buildCancel?.cancelled) {
2721
+ await terminalizeCancelledBuild({ dataDir, buildIdentity, visionWriter, itemId, failureReason });
2722
+ } else await writeFailedBuildTerminalState({
2179
2723
  cwd,
2180
2724
  dataDir,
2181
2725
  cfg,
@@ -2190,7 +2734,7 @@ async function terminalizeThrownBuild({
2190
2734
  featureCode,
2191
2735
  flowId,
2192
2736
  mode,
2193
- status: 'failed',
2737
+ status: buildCancel?.cancelled ? 'aborted' : 'failed',
2194
2738
  startedAt: buildStartedAt,
2195
2739
  completedAt: new Date().toISOString(),
2196
2740
  durationMs: Date.now() - new Date(buildStartedAt).getTime(),
@@ -2249,8 +2793,7 @@ export async function runBuild(featureCode, opts = {}) {
2249
2793
  // Handle --abort early (featureCode may be null). C2: pass the project root so
2250
2794
  // the engine is resolved from this project's capabilities, not process.cwd().
2251
2795
  if (opts.abort) {
2252
- await abortBuild(dataDir, featureCode, cwd);
2253
- return;
2796
+ return await abortBuild(dataDir, featureCode, cwd);
2254
2797
  }
2255
2798
 
2256
2799
  const {
@@ -2265,6 +2808,15 @@ export async function runBuild(featureCode, opts = {}) {
2265
2808
  let accumulatorPersisted = !isNewAccumulator;
2266
2809
  const buildStartedAt = new Date().toISOString();
2267
2810
  let buildStatus = 'failed';
2811
+ // COMP-BUILD-CANCEL §3.4: one handle per runBuild. `signal` is the D-F chain into every
2812
+ // dispatch; `cancelled` is the merge guard's flag; `teardownStarted` is what a second
2813
+ // Ctrl-C keys on. Registered by flow id once the runId exists (S03-5b).
2814
+ const buildCancel = createBuildCancel();
2815
+ let buildIdentity = null;
2816
+ // The flow id this build registered its handle under, so the outermost finally can
2817
+ // unregister without reaching into the inner try's `response`.
2818
+ let registeredFlowId = null;
2819
+ const { cancelMs: cancelTimeoutMs, drainMs: drainTimeoutMs, joinMs: teardownJoinMs } = cancelBudgets();
2268
2820
  let attemptStarted = true;
2269
2821
  let attemptFinalized = false;
2270
2822
  // COMP-TRIAGE-6-4: function-scoped so finalizeBuildAttempt's ownership guard
@@ -2278,6 +2830,7 @@ export async function runBuild(featureCode, opts = {}) {
2278
2830
  let streamWriter = null;
2279
2831
  let signalHandler = null;
2280
2832
  let runtimeResourcesFinalized = false;
2833
+ let suspended = false;
2281
2834
 
2282
2835
  const _priorBuildIdEnv = process.env.COMPOSE_BUILD_ID;
2283
2836
  if (_priorBuildIdEnv !== undefined) {
@@ -2293,7 +2846,8 @@ export async function runBuild(featureCode, opts = {}) {
2293
2846
  else process.env.COMPOSE_BUILD_ID = _priorBuildIdEnv;
2294
2847
  };
2295
2848
  const finalizeBuildAttempt = () => {
2296
- if (!attemptStarted || attemptFinalized) return;
2849
+ if (!attemptStarted || attemptFinalized || (suspended && !buildCancel.cancelled)) return;
2850
+ if (buildCancel.teardown || buildCancel.cancelled) buildStatus = 'aborted';
2297
2851
  const terminalStatus = buildStatus === 'complete'
2298
2852
  ? 'complete'
2299
2853
  : (buildStatus === 'killed' || buildStatus === 'aborted')
@@ -2470,11 +3024,16 @@ export async function runBuild(featureCode, opts = {}) {
2470
3024
  if (!existsSync(specPath)) {
2471
3025
  throw new Error(`Lifecycle spec not found: ${specPath}`);
2472
3026
  }
3027
+ requirePipelineSidecar(specPath);
2473
3028
  // D6: compose-owned per-step agent profiles (tool restrictions + model tiers)
2474
3029
  // for this pipeline. The engine ships only bare claude|codex; these restore
2475
- // the stripped profiles at invocation. Absent sidecar → {} (bare literals).
3030
+ // the stripped profiles at invocation. Optional absent sidecar → {} (bare literals).
2476
3031
  const stepProfiles = loadPipelineProfiles(specPath);
2477
3032
  let specYaml = readFileSync(specPath, 'utf-8');
3033
+ preflightPipelineProfiles(stepProfiles, specYaml, specPath);
3034
+ if (opts.costCeilingUsd !== undefined && (!Number.isFinite(opts.costCeilingUsd) || opts.costCeilingUsd <= 0 || !stepProfiles._costCeiling)) {
3035
+ throw new Error('costCeilingUsd requires a finite positive value and a configured _costCeiling');
3036
+ }
2478
3037
 
2479
3038
  // COMP-PIPELINE-QUARANTINE: refuse a retired-dialect spec HERE, at the one
2480
3039
  // seam every template passes through (build, fix, plan, --quick, --template,
@@ -2745,6 +3304,28 @@ export async function runBuild(featureCode, opts = {}) {
2745
3304
  }
2746
3305
  }
2747
3306
  const roles = { implementerAgent, reviewerAgent };
3307
+ // Resolve and validate runtime overrides BEFORE startFresh can create a flow.
3308
+ // Recompute on resume when persisted roles replace this invocation's flags.
3309
+ let pipelineProfiles;
3310
+ let effectiveProfiles;
3311
+ let profilePreflight;
3312
+ const refreshProfilePreflight = () => {
3313
+ const runtimeProfiles = {};
3314
+ const inputs = { implementer_agent: implementerAgent, reviewer_agent: reviewerAgent };
3315
+ resolvePlanSpecValues(structuredClone(localSpec), inputs, runtimeProfiles);
3316
+ pipelineProfiles = stepProfiles;
3317
+ for (const [id, override] of Object.entries(runtimeProfiles)) {
3318
+ try { pipelineProfiles = mergeRuntimeProfiles(pipelineProfiles, { [id]: override }); }
3319
+ catch (error) { throw new Error(`Profile preflight failed for ${specPath}: step "${id}": ${error.message}`, { cause: error }); }
3320
+ }
3321
+ // Pass the RAW local spec: the preflight resolves inputs itself and needs the
3322
+ // unresolved stage agents to detect a multi-stage runtime-profile collapse.
3323
+ profilePreflight = preflightPipelineProfiles(pipelineProfiles, localSpec, specPath, inputs);
3324
+ effectiveProfiles = Object.fromEntries(Object.entries(pipelineProfiles)
3325
+ .filter(([id, entry]) => !id.startsWith('_') && !entry?.decide_from)
3326
+ .map(([id, entry]) => [id, entry?.default ?? entry]));
3327
+ };
3328
+ refreshProfilePreflight();
2748
3329
  // Restore persisted roles when (and only when) a resume actually happens.
2749
3330
  const restoreRolesFromActive = (src) => {
2750
3331
  if (mode !== 'feature' || !src || !src.implementerAgent) return;
@@ -2756,6 +3337,7 @@ export async function runBuild(featureCode, opts = {}) {
2756
3337
  }
2757
3338
  implementerAgent = src.implementerAgent;
2758
3339
  reviewerAgent = src.reviewerAgent ?? reviewerAgent;
3340
+ refreshProfilePreflight();
2759
3341
  };
2760
3342
 
2761
3343
  const activeForDecision = active && active.featureCode === featureCode ? active : null;
@@ -2766,24 +3348,55 @@ export async function runBuild(featureCode, opts = {}) {
2766
3348
  && isProcessAlive(activeForDecision.pid)
2767
3349
  );
2768
3350
  const sameMode = !activeForDecision?.mode || activeForDecision.mode === mode;
2769
- let flowTerminal = !activeForDecision?.flowId;
2770
- if (activeForDecision?.flowId && ['complete', 'aborted', 'killed'].includes(activeForDecision.status)) {
2771
- flowTerminal = true;
2772
- } else if (activeForDecision?.flowId && !pidAlive) {
3351
+ let flowTerminal = !activeForDecision?.flowId
3352
+ || ['complete', 'aborted', 'killed'].includes(activeForDecision.status);
3353
+ const probeFlowId = opts.resumeFlowId ?? activeForDecision?.flowId;
3354
+ // Probe only when the engine can tell us something the local record cannot: an
3355
+ // explicit resume target, or a record that is not already terminal. A locally
3356
+ // terminal record needs no probe (pre-S05 semantics, kept).
3357
+ if (probeFlowId && !opts.fresh && (opts.resumeFlowId || !flowTerminal)) {
2773
3358
  try {
2774
- const audit = await stratum.audit(activeForDecision.flowId);
2775
- flowTerminal = isTerminalFlow(audit?.status);
2776
- if (flowTerminal) {
2777
- recoverAdvancedConsumerArtifacts({
2778
- runId: activeForDecision.flowId,
2779
- targetCwd: agentCwd,
2780
- artifactRoot: opts.consumerArtifactsRoot,
2781
- audit,
3359
+ const audit = await stratum.audit(probeFlowId);
3360
+ if (isTerminalFlow(audit?.status)) {
3361
+ const terminalJournal = verifyConsumerRunRevision({ runId: probeFlowId, targetCwd: agentCwd,
3362
+ artifactRoot: opts.consumerArtifactsRoot, specDigest: localSpecDigest,
3363
+ profilesDigest: waveProfilesEnabled(pipelineProfiles) ? profilePreflight.profilesDigest : undefined });
3364
+ recoverAdvancedConsumerArtifacts({ runId: probeFlowId, targetCwd: agentCwd,
3365
+ artifactRoot: opts.consumerArtifactsRoot, audit });
3366
+ if (terminalJournal?.wave) {
3367
+ const artifacts = new ConsumerFanoutArtifacts({ runId: probeFlowId, targetCwd: agentCwd, artifactRoot: opts.consumerArtifactsRoot });
3368
+ try { await replicateCheckpoints({ artifacts, stratum, flowId: probeFlowId, buildCancel }); }
3369
+ catch (error) {
3370
+ if (audit.status !== 'cancelled') throw error;
3371
+ console.warn(`[wave-checkpoint] ${error.message}; local checkpoint preserved, replication incomplete`);
3372
+ }
3373
+ }
3374
+ }
3375
+ if (audit?.status === 'cancelled') {
3376
+ // C42/C49: a resume probe is not necessarily the owning driver.
3377
+ const handle = lookupBuildCancel(probeFlowId);
3378
+ if (handle) {
3379
+ handle.cancel('flow_cancelled');
3380
+ } else if (activeForDecision?.flowId === probeFlowId) {
3381
+ const claim = claimActiveBuild(dataDir, activeForDecision);
3382
+ if (claim.ok) writeActiveBuild(dataDir, {
3383
+ ...claim.record, status: 'aborted', failureReason: 'flow_cancelled',
3384
+ completedAt: new Date().toISOString(),
3385
+ });
3386
+ }
3387
+ buildStatus = 'aborted';
3388
+ throw Object.assign(new Error(`Flow ${probeFlowId} was cancelled; it cannot be resumed. Run with --fresh to start a new build.`), {
3389
+ code: 'FLOW_CANCELLED', reason: 'flow_cancelled',
2782
3390
  });
2783
3391
  }
3392
+ flowTerminal = isTerminalFlow(audit?.status);
3393
+
2784
3394
  } catch (err) {
2785
3395
  if (isRecoverableFlowProbeError(err)) {
2786
- flowTerminal = true;
3396
+ // An unknown flow is only evidence the build is over when its driver is
3397
+ // gone. With a live driver pid it is not evidence of anything, and the
3398
+ // same-feature conflict guard below must still hold (pre-S05 semantics).
3399
+ if (!pidAlive) flowTerminal = true;
2787
3400
  } else {
2788
3401
  throw err;
2789
3402
  }
@@ -2798,8 +3411,18 @@ export async function runBuild(featureCode, opts = {}) {
2798
3411
  sameMode,
2799
3412
  });
2800
3413
  isFreshStart = verdict.action === 'fresh';
3414
+ if (isFreshStart && opts.fresh && activeForDecision?.flowId) {
3415
+ if (pidAlive) throw new Error('Cannot remove a wave ref owned by a live driver');
3416
+ const prior = new ConsumerFanoutArtifacts({ runId: activeForDecision.flowId,
3417
+ targetCwd: agentCwd, artifactRoot: opts.consumerArtifactsRoot }).journal;
3418
+ if (prior?.wave) {
3419
+ const expected = prior.wave.checkpoints.at(-1)?.commit;
3420
+ if (expected) removeCheckpointRef({ cwd: agentCwd, ref: prior.wave.ref, expected });
3421
+ }
3422
+ }
2801
3423
 
2802
3424
  if (verdict.action === 'resume') {
3425
+ restoreRolesFromActive(activeForDecision);
2803
3426
  const resumeFlowId = verdict.flowId;
2804
3427
  console.log(`Resuming flow ${resumeFlowId} for ${featureCode}...`);
2805
3428
  response = await stratum.resume(resumeFlowId);
@@ -2813,6 +3436,7 @@ export async function runBuild(featureCode, opts = {}) {
2813
3436
  artifactRoot: opts.consumerArtifactsRoot,
2814
3437
  specDigest: localSpecDigest,
2815
3438
  resumeRevisionDigest: response?.revisionDigest,
3439
+ profilesDigest: waveProfilesEnabled(pipelineProfiles) ? profilePreflight.profilesDigest : undefined,
2816
3440
  });
2817
3441
  try {
2818
3442
  recoverAdvancedConsumerArtifacts({
@@ -2822,7 +3446,7 @@ export async function runBuild(featureCode, opts = {}) {
2822
3446
  audit: await stratum.audit(resumeFlowId),
2823
3447
  });
2824
3448
  } catch (error) {
2825
- if (error instanceof ConsumerArtifactError) throw error;
3449
+ if (error instanceof ConsumerArtifactError || error instanceof WaveCheckpointError) throw error;
2826
3450
  // Audit/cleanup projection is best-effort for ordinary non-consumer runs.
2827
3451
  }
2828
3452
  // A BARE programmatic resumeFlowId (no --resume flag) resumes the named
@@ -2902,6 +3526,58 @@ export async function runBuild(featureCode, opts = {}) {
2902
3526
  if (triageEstimate) emitTriageEstimate(cwd, triageEstimate);
2903
3527
  }
2904
3528
 
3529
+ // COMP-BUILD-CANCEL S03-5b (C36): register the in-process handle the moment the run id
3530
+ // exists — BEFORE the Codex worktree preflight below, which can hold for up to
3531
+ // PROBE_AGENT_TIMEOUT_MS. `active-build.json` already advertises this flow id and pid,
3532
+ // so a same-process abort arriving in that window would otherwise find no handle, fall
3533
+ // through to the foreign-pid branch, and SIGTERM the compose server itself.
3534
+ registeredFlowId = response?.runId ?? null;
3535
+ registerBuildCancel(registeredFlowId, buildCancel);
3536
+ buildIdentity = { featureCode, flowId: registeredFlowId, pid: process.pid,
3537
+ startedAt: readActiveBuild(dataDir)?.startedAt };
3538
+
3539
+ // SIGINT/SIGTERM: cancel the flow, tear down, exit (COMP-BUILD-CANCEL §9). A SECOND
3540
+ // signal during teardown exits immediately — a user pressing Ctrl-C twice is asking to
3541
+ // stop waiting, and the durable state a bounded teardown would have written is worth
3542
+ // less than obeying that.
3543
+ signalHandler = (signal) => {
3544
+ // No local `tearingDown` flag: the handle owns that state (C27), so a teardown
3545
+ // started by any other path is visible here too.
3546
+ if (buildCancel.teardownStarted) { process.exit(signal === 'SIGINT' ? 130 : 143); return; }
3547
+ buildStatus = 'aborted';
3548
+ // ASSIGN, do not fire and forget (C45). The promise is what the outer catch reads to
3549
+ // stand down and what the outermost finally and the CLI join on. `runCancelTeardown`
3550
+ // sets `teardownStarted` synchronously, so a second signal takes the branch above.
3551
+ buildCancel.teardown = runCancelTeardown({
3552
+ buildCancel,
3553
+ signal,
3554
+ flowId: response?.runId,
3555
+ flowCancel: (id) => stratum.flowCancel(id),
3556
+ timeoutMs: cancelTimeoutMs,
3557
+ drainMs: drainTimeoutMs,
3558
+ claimOwnership: () => claimActiveBuild(dataDir, buildIdentity),
3559
+ killVision: () => killOwnedBuildVision(visionWriter, itemId, () => claimActiveBuild(dataDir, buildIdentity)),
3560
+ // The teardown re-claims after vision, immediately before this write (§3.7).
3561
+ writeTerminal: (record) => {
3562
+ writeActiveBuild(dataDir, { ...record, status: 'aborted', completedAt: new Date().toISOString() });
3563
+ },
3564
+ removeListeners: () => {
3565
+ process.removeListener('SIGINT', onSigint);
3566
+ process.removeListener('SIGTERM', onSigterm);
3567
+ },
3568
+ exit: (code) => process.exit(code),
3569
+ // eslint-disable-next-line no-console
3570
+ log: (message) => console.warn(`[build] ${message}`),
3571
+ });
3572
+ };
3573
+ // Named wrappers, because the two existing removeListener sites must remove the same
3574
+ // function references that were registered.
3575
+ const onSigint = () => signalHandler('SIGINT');
3576
+ const onSigterm = () => signalHandler('SIGTERM');
3577
+ signalHandler.listeners = { onSigint, onSigterm };
3578
+ process.on('SIGINT', onSigint);
3579
+ process.on('SIGTERM', onSigterm);
3580
+
2905
3581
  // COMP-CODEX-IMPL: verify Codex can write inside a detached git worktree (the
2906
3582
  // execute step's isolation primitive) before any step dispatches. Runs on the
2907
3583
  // EFFECTIVE role — after resume restoration above — so a resumed Codex build is
@@ -2922,8 +3598,12 @@ export async function runBuild(featureCode, opts = {}) {
2922
3598
  stratum,
2923
3599
  dataDir,
2924
3600
  ts: new Date().toISOString().replace(/[:.]/g, '-'),
3601
+ // C36/C51: an abort during the probe stops the probe too.
3602
+ signal: buildCancel.signal,
2925
3603
  });
2926
3604
  if (!probe.ok) {
3605
+ // The signal teardown owns terminal writes; let the catch/finally drain it.
3606
+ if (buildCancel.teardown) throw new Error(codexProbeAbortMessage(probe.reason));
2927
3607
  // The plan/resume above already persisted feature.json=IN_PROGRESS and
2928
3608
  // active-build.json='running'. A preflight abort throws before the normal
2929
3609
  // terminal handlers run, so roll BOTH back here (mirrors the killed/failed
@@ -2972,27 +3652,7 @@ export async function runBuild(featureCode, opts = {}) {
2972
3652
  specPath: `pipelines/${templateName}.stratum.yaml`,
2973
3653
  });
2974
3654
 
2975
- // SIGINT/SIGTERM: mark build as killed
2976
- signalHandler = () => {
2977
- buildStatus = 'killed';
2978
- streamWriter.close('killed');
2979
- };
2980
- process.on('SIGINT', signalHandler);
2981
- process.on('SIGTERM', signalHandler);
2982
-
2983
- // V4: merge the runtime profile map over the static sidecar (runtime wins).
2984
- // Tiers/templates carried by $.input.* agents (e.g. --implementer=claude::critical)
2985
- // are stripped to the bare provider at resolution for the engine; recover the
2986
- // full string here so the tier/capability profile still binds at invocation.
2987
- const runtimeProfiles = {};
2988
- try {
2989
- resolvePlanSpecValues(
2990
- YAML.parse(readFileSync(specPath, 'utf-8')),
2991
- { implementer_agent: implementerAgent, reviewer_agent: reviewerAgent },
2992
- runtimeProfiles,
2993
- );
2994
- } catch { /* best-effort — a malformed spec fails later at plan() */ }
2995
- const effectiveProfiles = { ...stepProfiles, ...runtimeProfiles };
3655
+ streamWriter.write({ type: 'profile_preflight', steps: profilePreflight.resolved });
2996
3656
  // H1: reducer steps — ReviewResult-out steps that MERGE/deduplicate rather
2997
3657
  // than review (e.g. review_merge). They still get review normalization +
2998
3658
  // confidence handling, but must NOT get the reviewer scaffold. python's
@@ -3020,6 +3680,7 @@ export async function runBuild(featureCode, opts = {}) {
3020
3680
  const context = {
3021
3681
  stratum,
3022
3682
  flowId: response.runId,
3683
+ buildCancel,
3023
3684
  receiptsMode,
3024
3685
  cwd: agentCwd,
3025
3686
  projectCwd: cwd,
@@ -3031,6 +3692,7 @@ export async function runBuild(featureCode, opts = {}) {
3031
3692
  // V4: merged runtime + static agent profiles let scoped consumer steps
3032
3693
  // recover their tier/capability profile via resolveStepProfile normalization.
3033
3694
  stepProfiles: effectiveProfiles,
3695
+ pipelineProfiles,
3034
3696
  // COMP-BUILD-QUICK-1: the pipeline template (e.g. 'build-quick') so the ship
3035
3697
  // step can stamp built_via onto feature.json for the validator's exemption.
3036
3698
  templateName,
@@ -3149,12 +3811,23 @@ export async function runBuild(featureCode, opts = {}) {
3149
3811
  // unpinned journal a drifted spec could re-pin (R1).
3150
3812
  revisionDigest: pins?.revisionDigest,
3151
3813
  specDigest: pins?.specDigest,
3814
+ profilesDigest: waveProfilesEnabled(pipelineProfiles) ? profilePreflight.profilesDigest : undefined,
3152
3815
  });
3153
3816
  } else if (consumerArtifacts.runId !== runId) {
3154
3817
  throw new Error(`consumer artifact manager is bound to ${consumerArtifacts.runId}, not ${runId}`);
3155
3818
  }
3819
+ context.artifacts = consumerArtifacts;
3156
3820
  return consumerArtifacts;
3157
3821
  };
3822
+ if (waveProfilesEnabled(pipelineProfiles)) {
3823
+ artifactsForRun(context.flowId, { revisionDigest: response.revisionDigest, specDigest: localSpecDigest });
3824
+ context.streamWriter = streamWriter;
3825
+ // A ceiling gate turns delivery failures into a durable human hold below.
3826
+ try { await flushWaveReceipts(context); } catch (error) {
3827
+ if (!pipelineProfiles._costCeiling || buildCancel.cancelled) throw error;
3828
+ }
3829
+ await replicateCheckpoints(context);
3830
+ }
3158
3831
 
3159
3832
 
3160
3833
  // COMP-PLAN-GATE-LOOP: per-step gate re-entry counter. A `revise` that
@@ -3195,7 +3868,7 @@ export async function runBuild(featureCode, opts = {}) {
3195
3868
  }
3196
3869
  };
3197
3870
 
3198
- const runConsumerDescriptor = async (descriptor, sourceResponse) => {
3871
+ const runConsumerDescriptor = async (descriptor, sourceResponse, admission) => {
3199
3872
  const flowId = sourceResponse.runId ?? sourceResponse.flow_id;
3200
3873
  const artifacts = artifactsForRun(flowId);
3201
3874
  const audit = await stratum.audit(flowId);
@@ -3206,7 +3879,9 @@ export async function runBuild(featureCode, opts = {}) {
3206
3879
  });
3207
3880
  return runConsumerIssuance({
3208
3881
  descriptor,
3882
+ admission,
3209
3883
  flowId,
3884
+ buildCancel,
3210
3885
  stratum,
3211
3886
  artifacts,
3212
3887
  audit,
@@ -3218,18 +3893,18 @@ export async function runBuild(featureCode, opts = {}) {
3218
3893
  // claude:read-only-reviewer, or a runtime --implementer=claude::critical)
3219
3894
  // so an isolation:none review item runs read-only instead of with
3220
3895
  // Edit/Write/Bash in the target workspace.
3221
- profile: resolveStepProfile(effectiveProfiles, descriptor.step)
3222
- ?? resolveStepProfile(effectiveProfiles, descriptor.id),
3896
+ profile: resolveStepProfile(context.stepProfiles, descriptor.step)
3897
+ ?? resolveStepProfile(context.stepProfiles, descriptor.id),
3223
3898
  });
3224
3899
  };
3225
3900
 
3226
3901
  const launchPendingConsumers = () => {
3227
- while (!consumerFatalError
3902
+ while (!consumerFatalError && !buildCancel.cancelled
3228
3903
  && consumerInFlight.size < consumerConcurrency
3229
3904
  && consumerPending.length > 0) {
3230
3905
  const work = consumerPending.shift();
3231
3906
  const token = work.descriptor.dispatchToken;
3232
- const task = runConsumerDescriptor(work.descriptor, work.sourceResponse)
3907
+ const task = runConsumerDescriptor(work.descriptor, work.sourceResponse, work.admission)
3233
3908
  .then((nextResponse) => {
3234
3909
  consumerCompleted.push(nextResponse);
3235
3910
  }, (error) => {
@@ -3255,7 +3930,8 @@ export async function runBuild(featureCode, opts = {}) {
3255
3930
  for (const descriptor of descriptors) {
3256
3931
  if (consumerSeenTokens.has(descriptor.dispatchToken)) continue;
3257
3932
  const flowId = engineResponse.runId ?? engineResponse.flow_id;
3258
- const runPins = { revisionDigest: descriptor.revisionDigest, specDigest: localSpecDigest };
3933
+ const runPins = { revisionDigest: descriptor.revisionDigest, specDigest: localSpecDigest,
3934
+ profilesDigest: waveProfilesEnabled(pipelineProfiles) ? profilePreflight.profilesDigest : undefined };
3259
3935
  // Preflight a whole ready batch before launching it. This preserves the
3260
3936
  // shipped crash-before-first-bind boundary (zero issuances may execute)
3261
3937
  // while allowing the work after revision fencing to overlap.
@@ -3264,8 +3940,11 @@ export async function runBuild(featureCode, opts = {}) {
3264
3940
  await artifacts.hooks.beforeRevisionBind({ descriptor, journal: artifacts.journal });
3265
3941
  }
3266
3942
  artifacts.bindRunRevision(runPins);
3943
+ const admission = await admitConsumerWave({ descriptor, descriptors,
3944
+ localSpec, profiles: pipelineProfiles,
3945
+ artifacts, stratum, flowId });
3267
3946
  consumerSeenTokens.add(descriptor.dispatchToken);
3268
- consumerPending.push({ descriptor, sourceResponse: engineResponse });
3947
+ consumerPending.push({ descriptor, sourceResponse: engineResponse, admission });
3269
3948
  }
3270
3949
  launchPendingConsumers();
3271
3950
  }
@@ -3286,6 +3965,10 @@ export async function runBuild(featureCode, opts = {}) {
3286
3965
  let current = engineResponse;
3287
3966
  let latestNonReady = current?.status !== 'ready' ? current : null;
3288
3967
  while (true) {
3968
+ if (buildCancel.cancelled) {
3969
+ consumerFatalError ??= buildCancel.signal.reason;
3970
+ await drainConsumerFatal();
3971
+ }
3289
3972
  let ordinary;
3290
3973
  try {
3291
3974
  ordinary = await enqueueConsumerReady(current);
@@ -3331,7 +4014,7 @@ export async function runBuild(featureCode, opts = {}) {
3331
4014
  throw error;
3332
4015
  };
3333
4016
  try {
3334
- while (!isTerminalFlow(response.status)) {
4017
+ while (!isTerminalFlow(response.status) && !buildCancel.cancelled) {
3335
4018
  if (response?.revisionDigest) runRevisionDigest = response.revisionDigest;
3336
4019
  response = await mergeConsumerReady(response);
3337
4020
  const readyStep = response.status === 'ready' ? response.ready?.[0] : null;
@@ -3479,8 +4162,8 @@ export async function runBuild(featureCode, opts = {}) {
3479
4162
  const shipStepResult = response.status === 'ready'
3480
4163
  ? { output: tsShipOutput }
3481
4164
  : shipResult;
3482
- response = await stratum.stepDone(
3483
- flowId, stepId, shipStepResult, readyStep?.dispatchToken,
4165
+ response = await callFlowWithCancellation(stratum, 'stepDone', flowId, buildCancel,
4166
+ stepId, shipStepResult, readyStep?.dispatchToken,
3484
4167
  );
3485
4168
  streamWriter.write({
3486
4169
  type: 'build_step_done',
@@ -3516,9 +4199,12 @@ export async function runBuild(featureCode, opts = {}) {
3516
4199
  // here handed the fixer an unrestricted profile; the sibling
3517
4200
  // review-repair site keys off `fix` the same way. Identity still
3518
4201
  // comes from the dispatch (`agent: fixAgent`).
3519
- profile: resolveStepProfile(context.stepProfiles, 'fix')
4202
+ profile: resolveStepProfile(effectiveProfiles, 'fix')
3520
4203
  ?? resolveStepProfile(context.stepProfiles, stepId),
3521
4204
  sandboxMode: 'workspace-write',
4205
+ ...(flowTag(flowId, stepId) ? { flow: flowTag(flowId, stepId) } : {}),
4206
+ flowId, buildCancel,
4207
+ buildSignal: buildCancel.signal,
3522
4208
  telemetry: {
3523
4209
  site: 'review-repair',
3524
4210
  project_cwd: cwd,
@@ -3538,6 +4224,7 @@ export async function runBuild(featureCode, opts = {}) {
3538
4224
  } catch { /* fail-open */ }
3539
4225
  }
3540
4226
  } catch (err) {
4227
+ if (buildCancel.cancelled) throw err;
3541
4228
  if (err?.usage && typeof context.recordBuildUsage === 'function') {
3542
4229
  try {
3543
4230
  await context.recordBuildUsage(usagePayload(err.usage, err.usages), {
@@ -3603,6 +4290,9 @@ export async function runBuild(featureCode, opts = {}) {
3603
4290
  reviewMode: isReviewMain,
3604
4291
  confidenceGate: confGateMain,
3605
4292
  lens: response.inputs?.lens_name ?? response.lens_name ?? 'general',
4293
+ ...(flowTag(flowId, stepId) ? { flow: flowTag(flowId, stepId) } : {}),
4294
+ flowId, buildCancel,
4295
+ buildSignal: buildCancel.signal,
3606
4296
  telemetry: {
3607
4297
  site: isReviewMain ? 'review' : 'build-step',
3608
4298
  project_cwd: cwd,
@@ -3614,9 +4304,10 @@ export async function runBuild(featureCode, opts = {}) {
3614
4304
  // D6/V4: apply this ordinary step's compose-side profile (e.g.
3615
4305
  // blueprint → claude::critical, review_merge → claude:orchestrator),
3616
4306
  // normalizing scoped subflow ready ids to the bare step id.
3617
- profile: resolveStepProfile(effectiveProfiles, stepId),
4307
+ profile: resolveStepProfile(context.stepProfiles, stepId),
3618
4308
  });
3619
4309
  } catch (err) {
4310
+ if (buildCancel.cancelled) throw err;
3620
4311
  const failedUsage = failureUsageFields(err);
3621
4312
  if (err instanceof UserInterruptError) {
3622
4313
  if (err.action === 'skip') {
@@ -3691,7 +4382,10 @@ export async function runBuild(featureCode, opts = {}) {
3691
4382
  progress, streamWriter, maxDurationMs, stratum, cwd: agentCwd,
3692
4383
  reviewMode: isReviewMain,
3693
4384
  confidenceGate: confGateMain,
3694
- profile: resolveStepProfile(effectiveProfiles, stepId),
4385
+ profile: resolveStepProfile(context.stepProfiles, stepId),
4386
+ ...(flowTag(flowId, stepId) ? { flow: flowTag(flowId, stepId) } : {}),
4387
+ flowId, buildCancel,
4388
+ buildSignal: buildCancel.signal,
3695
4389
  telemetry: {
3696
4390
  site: 'policy-revision',
3697
4391
  project_cwd: cwd,
@@ -3739,6 +4433,7 @@ export async function runBuild(featureCode, opts = {}) {
3739
4433
  }
3740
4434
  // The second result stands either way — no further passes.
3741
4435
  } catch (err) {
4436
+ if (buildCancel.cancelled) throw err;
3742
4437
  if (err?.usage && typeof context.recordBuildUsage === 'function') {
3743
4438
  try {
3744
4439
  await context.recordBuildUsage(usagePayload(err.usage, err.usages), {
@@ -3931,6 +4626,9 @@ export async function runBuild(featureCode, opts = {}) {
3931
4626
  // Report each model dispatch before the outcome it funded. The merged
3932
4627
  // step usage remains the accumulator/build-stream shape used by existing
3933
4628
  // callers; receipts use mainResult.usages to preserve per-dispatch data.
4629
+ if (context.pipelineProfiles?._costCeiling && !toEngineUsage(stepUsage)) {
4630
+ await context.recordBuildUsage({ dispatch_id: mainResult.dispatchIds?.primary ?? readyStep?.dispatchToken }, { stepId, source: 'main' });
4631
+ }
3934
4632
  if (toEngineUsage(stepUsage)) {
3935
4633
  buildCostTotals.input_tokens += stepUsage.input_tokens ?? 0;
3936
4634
  buildCostTotals.output_tokens += stepUsage.output_tokens ?? 0;
@@ -3942,8 +4640,8 @@ export async function runBuild(featureCode, opts = {}) {
3942
4640
  });
3943
4641
  streamWriter.writeUsage(stepId, stepUsage);
3944
4642
  }
3945
- response = await stratum.stepDone(
3946
- flowId, stepId, stepDoneResult, readyStep?.dispatchToken,
4643
+ response = await callFlowWithCancellation(stratum, 'stepDone', flowId, buildCancel,
4644
+ stepId, stepDoneResult, readyStep?.dispatchToken,
3947
4645
  );
3948
4646
  {
3949
4647
  const isEnsureRetry = responseReissuesStep(response, stepId);
@@ -4183,10 +4881,28 @@ export async function runBuild(featureCode, opts = {}) {
4183
4881
  { gateStepId, localFanoutStepId: consumerFanoutStep.id, journaledFanoutStepId: journaledFanoutId },
4184
4882
  );
4185
4883
  }
4884
+ if (pipelineProfiles._consumer?.[consumerFanoutStep.id]?.checkpoint_gate === gateStepId) {
4885
+ consumerMergeArtifacts.initializeWave({ ref: `refs/heads/compose/wave/${flowId}`, profilesDigest: profilePreflight.profilesDigest });
4886
+ }
4186
4887
  consumerMergeArtifacts.recordGateBinding({ gateStepId, fanoutStepId: consumerFanoutStep.id });
4187
4888
  }
4188
4889
 
4189
4890
  const resolveGateWithConsumerMerge = async (requestedOutcome, requestedRationale, resolvedBy) => {
4891
+ let mergeApplied = false;
4892
+ const reverseCancelledMerge = () => {
4893
+ buildCancel.cancel('flow_cancelled');
4894
+ try {
4895
+ consumerMergeArtifacts.restoreMergeBaseline(consumerMergeTransaction, gateAudit, { reason: 'cancelled' });
4896
+ } catch (revertError) {
4897
+ // #mutate cannot persist anything when the restore callback throws.
4898
+ // This separate mutation is the durable indeterminate-tree finding.
4899
+ let journalFailure = '';
4900
+ try { consumerMergeArtifacts.markRollbackFailed(consumerMergeTransaction, revertError); }
4901
+ catch (error) { journalFailure = `; rollback failure journal also failed: ${error.message}`; }
4902
+ throw new MergeAfterCancelError(`merge reversal FAILED after cancel; working tree is indeterminate: ${revertError.message}${journalFailure}`);
4903
+ }
4904
+ throw new MergeAfterCancelError('merge reversed: the run was cancelled while the merge was applying');
4905
+ };
4190
4906
  let outcome = requestedOutcome;
4191
4907
  let rationale = requestedRationale;
4192
4908
  const repairOutcome = gateStep?.gate?.on_revise ? 'revise' : 'kill';
@@ -4220,9 +4936,16 @@ export async function runBuild(featureCode, opts = {}) {
4220
4936
  if (consumerMergePreparationError) {
4221
4937
  repairFor(consumerMergePreparationError);
4222
4938
  } else {
4939
+ // Both fences are outside the repairFor catch: cancellation cannot revise.
4940
+ if (buildCancel.cancelled) {
4941
+ streamWriter.write({ type: 'build_note', note: `merge skipped: build cancelled (${buildCancel.reason})`, flowId });
4942
+ throw new MergeAfterCancelError(`merge refused: build was cancelled at ${buildCancel.at}`);
4943
+ }
4223
4944
  try {
4224
4945
  await consumerMergeArtifacts.applyMerge(consumerMergeTransaction);
4946
+ mergeApplied = true;
4225
4947
  try {
4948
+ if (!consumerMergeArtifacts.journal.wave) {
4226
4949
  const changed = execSync(
4227
4950
  'git diff --name-only HEAD; git ls-files --others --exclude-standard',
4228
4951
  { cwd: agentCwd, encoding: 'utf8', timeout: 5000, stdio: 'pipe' },
@@ -4233,11 +4956,16 @@ export async function runBuild(featureCode, opts = {}) {
4233
4956
  context.filesChanged = [...files];
4234
4957
  context.recordFilesChanged(context.filesChanged);
4235
4958
  }
4959
+ }
4236
4960
  } catch { /* best-effort build context projection */ }
4237
4961
  } catch (error) {
4238
4962
  if (!(error instanceof ConsumerMergeDecisionError)) throw error;
4239
4963
  repairFor(error);
4240
4964
  }
4965
+ const runCancelled = mergeApplied && !buildCancel.cancelled && await isRunCancelled(stratum, flowId);
4966
+ if (mergeApplied && (buildCancel.cancelled || runCancelled)) {
4967
+ reverseCancelledMerge();
4968
+ }
4241
4969
  }
4242
4970
  }
4243
4971
  // Seam between a completed applyMerge (issuances now journaled `merged`)
@@ -4257,9 +4985,44 @@ export async function runBuild(featureCode, opts = {}) {
4257
4985
  consumerMergeArtifacts.restoreMergeBaseline(consumerMergeTransaction, gateAudit);
4258
4986
  }
4259
4987
 
4260
- let next = await stratum.gateResolve(
4261
- flowId, stepId, outcome, rationale, resolvedBy, gateToken,
4262
- );
4988
+ let next;
4989
+ try {
4990
+ next = await callFlowWithCancellation(stratum, 'gateResolve', flowId, buildCancel,
4991
+ stepId, outcome, rationale, resolvedBy, gateToken,
4992
+ );
4993
+ if (consumerMergeArtifacts?.journal.wave && outcome === 'approve') {
4994
+ consumerMergeArtifacts.markGateResolved(consumerMergeTransaction, outcome);
4995
+ // Acknowledged approval creates an obligation even if cancellation follows.
4996
+ consumerMergeArtifacts.recoverCheckpoint(consumerMergeTransaction);
4997
+ mergeApplied = false; // confirmed checkpoint must never be rolled back on cancel
4998
+ await replicateCheckpoints(context);
4999
+ }
5000
+ if (next.status === 'cancelled') buildCancel.cancel('flow_cancelled');
5001
+ if (buildCancel.cancelled) throw buildCancel.signal.reason;
5002
+ } catch (error) {
5003
+ if (waveProfilesEnabled(pipelineProfiles)) {
5004
+ const current = await stratum.audit(flowId);
5005
+ const ordinal = (gateAudit.events ?? []).filter(e => e.type === 'gate_resolved' && e.stepId === stepId).length;
5006
+ const decision = (current.events ?? []).filter(e => e.type === 'gate_resolved' && e.stepId === stepId)[ordinal]?.detail?.decision;
5007
+ if (decision) {
5008
+ outcome = decision;
5009
+ if (consumerMergeArtifacts && decision === 'approve') {
5010
+ consumerMergeArtifacts.markGateResolved(consumerMergeTransaction, decision);
5011
+ consumerMergeArtifacts.recoverCheckpoint(consumerMergeTransaction);
5012
+ mergeApplied = false;
5013
+ await replicateCheckpoints(context);
5014
+ }
5015
+ if (current.status === 'cancelled') throw error;
5016
+ next = await stratum.resume(flowId);
5017
+ } else {
5018
+ if (mergeApplied && buildCancel.cancelled) reverseCancelledMerge();
5019
+ throw error;
5020
+ }
5021
+ } else {
5022
+ if (mergeApplied && buildCancel.cancelled) reverseCancelledMerge();
5023
+ throw error;
5024
+ }
5025
+ }
4263
5026
  if (consumerMergeArtifacts) {
4264
5027
  if (typeof consumerMergeArtifacts.hooks.afterGateResolve === 'function') {
4265
5028
  await consumerMergeArtifacts.hooks.afterGateResolve({
@@ -4270,10 +5033,13 @@ export async function runBuild(featureCode, opts = {}) {
4270
5033
  response: next,
4271
5034
  });
4272
5035
  }
5036
+ if (mergeApplied && buildCancel.cancelled) reverseCancelledMerge();
4273
5037
  consumerMergeArtifacts.markGateResolved(consumerMergeTransaction, outcome);
4274
5038
  if (outcome === 'approve' || outcome === 'kill' || isTerminalFlow(next.status)) {
4275
5039
  consumerMergeArtifacts.cleanupWorktrees(
4276
5040
  outcome === 'approve' ? 'merge gate approved and advanced' : 'run terminalized',
5041
+ consumerMergeArtifacts.journal.wave && consumerMergeTransaction
5042
+ ? { dispatchTokens: consumerMergeTransaction.acceptedDispatchTokens } : {},
4277
5043
  );
4278
5044
  }
4279
5045
  }
@@ -4374,6 +5140,12 @@ export async function runBuild(featureCode, opts = {}) {
4374
5140
  const gateFixResult = await runAndNormalize(undefined, fixPrompt, { step_id: 'review_fix', agent: fixAgent, flow_id: flowId }, {
4375
5141
  progress, streamWriter, maxDurationMs: STEP_TIMEOUT_MS.review_merge ?? DEFAULT_TIMEOUT_MS,
4376
5142
  stratum, cwd: agentCwd, sandboxMode: 'workspace-write', profile: resolveStepProfile(effectiveProfiles, 'fix'),
5143
+ // S03-4: tag with `gateStepId`, not the literal `review_fix` this dispatch
5144
+ // carries as its step_id — the run's real step is the gate, and flow.stepId
5145
+ // is audit decoration (the server validates it against nothing).
5146
+ ...(flowTag(flowId, gateStepId) ? { flow: flowTag(flowId, gateStepId) } : {}),
5147
+ flowId, buildCancel,
5148
+ buildSignal: buildCancel.signal,
4377
5149
  telemetry: {
4378
5150
  site: 'review-repair',
4379
5151
  project_cwd: cwd,
@@ -4393,6 +5165,7 @@ export async function runBuild(featureCode, opts = {}) {
4393
5165
  } catch { /* fail-open */ }
4394
5166
  }
4395
5167
  } catch (err) {
5168
+ if (buildCancel.cancelled) throw err;
4396
5169
  if (err?.usage && typeof context.recordBuildUsage === 'function') {
4397
5170
  try {
4398
5171
  await context.recordBuildUsage(usagePayload(err.usage, err.usages), {
@@ -4415,8 +5188,22 @@ export async function runBuild(featureCode, opts = {}) {
4415
5188
  continue;
4416
5189
  }
4417
5190
 
5191
+ const outputDecision = await evaluateConfiguredGate(context, {
5192
+ localSpec, gateStepId: stepId, gateToken, costCeilingUsd: opts.costCeilingUsd,
5193
+ });
5194
+ if (outputDecision && !outputDecision.outcome && (opts.gateOpts?.nonInteractive ?? !process.stdin.isTTY)) {
5195
+ suspended = true;
5196
+ const reason = outputDecision.reason;
5197
+ writeActiveBuild(dataDir, { ...readActiveBuild(dataDir), status: 'waiting_gate', gateToken, reason });
5198
+ streamWriter.pause({ flowId, gateToken, reason });
5199
+ console.log(`Build paused at ${stepId}: ${reason}. Resume with a human gate decision.`);
5200
+ return { status: 'waiting_gate', flowId, gateToken, reason };
5201
+ }
5202
+
4418
5203
  // ── Policy evaluation (ITEM-23) ────────────────────────────────────
4419
- const policy = evaluatePolicy(policySettings, stepId, {
5204
+ const policy = outputDecision
5205
+ ? { mode: outputDecision.outcome ? 'skip' : 'gate', reason: outputDecision.rationale ?? outputDecision.reason }
5206
+ : evaluatePolicy(policySettings, stepId, {
4420
5207
  fromPhase: synthFromPhase,
4421
5208
  // Gate policy is keyed by the gate step id (evaluatePolicy falls back
4422
5209
  // to stepId when toPhase is absent); the synthesized approval target
@@ -4425,17 +5212,19 @@ export async function runBuild(featureCode, opts = {}) {
4425
5212
 
4426
5213
  if (policy.mode === 'skip') {
4427
5214
  // Silent pass-through — no gate record, no UI
4428
- const resolved = await resolveGateWithConsumerMerge('approve', policy.reason, 'system');
5215
+ const resolved = await resolveGateWithConsumerMerge(outputDecision?.outcome ?? 'approve', policy.reason, 'system');
4429
5216
  response = resolved.response;
4430
5217
  streamWriter.write({
4431
5218
  type: 'build_gate_resolved',
4432
- stepId, outcome: resolved.outcome, rationale: resolved.rationale, flowId, policyMode: 'skip',
5219
+ stepId, outcome: resolved.outcome, rationale: resolved.rationale, flowId, policyMode: outputDecision ? 'output' : 'skip',
4433
5220
  });
4434
5221
  // COMP-PLAN-SECTIONS T6: emit sections after plan_gate auto-approve
4435
5222
  if (resolved.outcome === 'approve') {
4436
5223
  maybeEmitSectionsAfterPlanGate(stepId, featureDir, { streamWriter, featureCode });
4437
5224
  }
4438
- stepHistory.push({ stepId, artifact: null, summary: `Gate skip: ${resolved.rationale}`, outcome: resolved.outcome });
5225
+ if (outputDecision?.outcome) await reportWaveEvidence(context, 'gate_decision', gateToken,
5226
+ { ...outputDecision, outcome: resolved.outcome, rationale: resolved.rationale }, 'accepted');
5227
+ stepHistory.push({ stepId, artifact: null, summary: `Gate ${outputDecision ? 'output' : 'skip'}: ${resolved.rationale}`, outcome: resolved.outcome });
4439
5228
  syncStepHistory(dataDir, stepHistory);
4440
5229
 
4441
5230
  } else if (policy.mode === 'flag') {
@@ -4482,13 +5271,20 @@ export async function runBuild(featureCode, opts = {}) {
4482
5271
  if (serverUp) {
4483
5272
  gateId = await visionWriter.createGate(flowId, stepId, itemId, { ...gateExtras, policyMode: 'gate', round });
4484
5273
  console.log('Gate delegated to web UI. Waiting for resolution...');
4485
- const resolved = await pollGateResolution(visionWriter, gateId);
5274
+ let resolved;
5275
+ try {
5276
+ resolved = await pollGateResolution(visionWriter, gateId, 2000, buildCancel.signal);
5277
+ } catch (error) {
5278
+ await confirmCancellation(error, { stratum, flowId, buildCancel });
5279
+ throw error;
5280
+ }
4486
5281
  if (resolved) {
4487
5282
  outcome = resolved.outcome;
4488
5283
  rationale = resolved.comment ?? '';
4489
5284
  } else {
4490
5285
  const result = await promptGate(gateDispatch, {
4491
5286
  ...(opts.gateOpts ?? {}),
5287
+ signal: buildCancel.signal,
4492
5288
  artifact: context.cwd,
4493
5289
  askAgent,
4494
5290
  gateExtras,
@@ -4500,6 +5296,7 @@ export async function runBuild(featureCode, opts = {}) {
4500
5296
  gateId = await visionWriter.createGate(flowId, stepId, itemId, { ...gateExtras, policyMode: 'gate', round });
4501
5297
  const result = await promptGate(gateDispatch, {
4502
5298
  ...(opts.gateOpts ?? {}),
5299
+ signal: buildCancel.signal,
4503
5300
  artifact: context.cwd,
4504
5301
  askAgent,
4505
5302
  gateExtras,
@@ -4508,7 +5305,10 @@ export async function runBuild(featureCode, opts = {}) {
4508
5305
  rationale = result.rationale;
4509
5306
  }
4510
5307
 
5308
+ if (outputDecision) await reportWaveEvidence(context, 'gate_human', gateToken, { outcome, rationale, hold: outputDecision }, 'proposed');
4511
5309
  const resolved = await resolveGateWithConsumerMerge(outcome, rationale, 'human');
5310
+ if (outputDecision) await reportWaveEvidence(context, 'gate_human', gateToken,
5311
+ { outcome: resolved.outcome, rationale: resolved.rationale, hold: outputDecision }, 'accepted');
4512
5312
  response = resolved.response;
4513
5313
  outcome = resolved.outcome;
4514
5314
  rationale = resolved.rationale;
@@ -4566,16 +5366,26 @@ export async function runBuild(featureCode, opts = {}) {
4566
5366
  consumerArtifacts.cleanupWorktrees('run terminalized');
4567
5367
  }
4568
5368
 
5369
+ if (response.status === 'cancelled') buildCancel.cancel('flow_cancelled');
5370
+
4569
5371
  // Flow complete — write terminal state (file retained per STRAT-COMP-4 contract).
4570
5372
  // COMP-COMPLETION-GATE slice 2: set when the flow completed and the feature is
4571
5373
  // eligible to be completed — the actual completion runs after the health gate.
4572
5374
  let pendingCompletion = null;
4573
- if (response.status === 'completed') buildStatus = 'complete';
4574
- if ((response.status === 'failed' || response.status === 'budget_exhausted') && !killedByGate) {
5375
+ if (!buildCancel.cancelled && response.status === 'completed') buildStatus = 'complete';
5376
+ if (!buildCancel.cancelled && (response.status === 'failed' || response.status === 'budget_exhausted') && !killedByGate) {
4575
5377
  buildStatus = 'failed';
4576
5378
  terminalFailureReason = response.failure?.reason ?? null;
4577
5379
  }
4578
- if (response.status === 'completed' && buildStatus === 'complete') {
5380
+ if (buildCancel.teardown) {
5381
+ // A successful final stepDone can race SIGINT. The teardown still owns
5382
+ // terminal state; retain aborted for history, stream closure and actuals.
5383
+ buildStatus = 'aborted';
5384
+ } else if (buildCancel.cancelled) {
5385
+ buildStatus = 'aborted';
5386
+ terminalFailureReason = buildCancel.reason;
5387
+ await terminalizeCancelledBuild({ dataDir, buildIdentity, visionWriter, itemId, failureReason: buildCancel.reason });
5388
+ } else if (response.status === 'completed' && buildStatus === 'complete') {
4579
5389
  console.log('\nBuild complete.');
4580
5390
  // COMP-COMPLETION-GATE slice 2: the COMPLETION does not happen here.
4581
5391
  //
@@ -4640,9 +5450,13 @@ export async function runBuild(featureCode, opts = {}) {
4640
5450
  // for the history record — health-gate failures have no failed step, so
4641
5451
  // the lastFailedStep-derived failureReason would otherwise be generic.
4642
5452
  let healthDowngradeReason = null;
4643
- if (streamWriter) {
5453
+ if (streamWriter && !buildCancel.teardown && !buildCancel.cancelled) {
5454
+ const checkHealthOwnership = () => {
5455
+ if (buildCancel.teardown || buildCancel.cancelled) throw buildCancel.signal.reason;
5456
+ };
4644
5457
  try {
4645
5458
  // Runtime errors signal — accumulated across all steps
5459
+ checkHealthOwnership();
4646
5460
  if (allViolations.length > 0) {
4647
5461
  buildSignals.runtime_errors = allViolations;
4648
5462
  } else if (!buildSignals.runtime_errors) {
@@ -4657,6 +5471,7 @@ export async function runBuild(featureCode, opts = {}) {
4657
5471
  // chain + mtimes, so it needs no marker to be written first.
4658
5472
  try {
4659
5473
  const { findStaleArtifacts } = await import('./lineage.js');
5474
+ checkHealthOwnership();
4660
5475
  buildSignals.doc_freshness = findStaleArtifacts(resolveItemDir(featureCode));
4661
5476
  } catch { /* staleness check is optional — skip on error */ }
4662
5477
 
@@ -4669,6 +5484,7 @@ export async function runBuild(featureCode, opts = {}) {
4669
5484
  // lifecycle-writer surface that materialises wasGeneratedBy/wasDerivedFrom.
4670
5485
  try {
4671
5486
  const { stampFeatureLineage } = await import('./lineage.js');
5487
+ checkHealthOwnership();
4672
5488
  stampFeatureLineage(resolveItemDir(featureCode));
4673
5489
  } catch { /* lineage stamping is optional — skip on error */ }
4674
5490
 
@@ -4688,10 +5504,12 @@ export async function runBuild(featureCode, opts = {}) {
4688
5504
  );
4689
5505
 
4690
5506
  // Emit to build stream
5507
+ checkHealthOwnership();
4691
5508
  streamWriter.writeHealthScore(score, breakdown, missing);
4692
5509
 
4693
5510
  // Persist to history
4694
5511
  try {
5512
+ checkHealthOwnership();
4695
5513
  recordScore(cwd, { featureCode, phase: buildStatus, score, breakdown });
4696
5514
  } catch (err) {
4697
5515
  console.warn(`[health] Failed to persist score: ${err.message}`);
@@ -4701,6 +5519,7 @@ export async function runBuild(featureCode, opts = {}) {
4701
5519
  // If health score is below the configured threshold, mark the build as failed
4702
5520
  // so downstream consumers (vision item status, exit code) reflect the rejection.
4703
5521
  const threshold = healthSettings.gate_threshold;
5522
+ checkHealthOwnership();
4704
5523
  if (typeof threshold === 'number' && score < threshold) {
4705
5524
  streamWriter.write({
4706
5525
  type: 'gate_health_rejection',
@@ -4711,12 +5530,14 @@ export async function runBuild(featureCode, opts = {}) {
4711
5530
  });
4712
5531
  console.warn(` [health] Build health score ${score} is below gate threshold ${threshold} — marking build as failed`);
4713
5532
  // Enforce: downgrade build status so the build is reported as failed
5533
+ checkHealthOwnership();
4714
5534
  buildStatus = 'failed';
4715
5535
  // COMP-MOBILE-1-1: re-persist the downgrade to active-build.json so the
4716
5536
  // file watcher re-broadcasts buildState over /ws/vision. Without this,
4717
5537
  // the terminal write above already said 'complete' and clients never
4718
5538
  // learn the build actually failed (mobile compensated via history).
4719
5539
  // Identity-guarded: no-ops if a concurrent build replaced the file.
5540
+ checkHealthOwnership();
4720
5541
  const downgraded = persistHealthGateDowngrade(dataDir, {
4721
5542
  score,
4722
5543
  threshold,
@@ -4730,7 +5551,7 @@ export async function runBuild(featureCode, opts = {}) {
4730
5551
  console.log(` Health score: ${score}/100 (${Object.keys(breakdown).length} dimensions scored)`);
4731
5552
  } catch (err) {
4732
5553
  // Non-fatal — health scoring never blocks the build
4733
- console.warn(`[health] Score computation failed: ${err.message}`);
5554
+ if (!buildCancel.teardown && !buildCancel.cancelled) console.warn(`[health] Score computation failed: ${err.message}`);
4734
5555
  }
4735
5556
  }
4736
5557
 
@@ -4741,7 +5562,7 @@ export async function runBuild(featureCode, opts = {}) {
4741
5562
  // a health-rejected build completes nothing: no completion record, no
4742
5563
  // COMPLETE status, no vision completion, no guard transition.
4743
5564
  // ---------------------------------------------------------------------
4744
- if (pendingCompletion && buildStatus === 'complete') {
5565
+ if (pendingCompletion && !buildCancel.teardown && !buildCancel.cancelled && buildStatus === 'complete') {
4745
5566
  const ev = context.completionEvidence || {};
4746
5567
  const acc = readBuildAccumulator(cwd, featureCode);
4747
5568
  // Persisted, because it survives a resume; the in-memory value wins when
@@ -4829,6 +5650,10 @@ export async function runBuild(featureCode, opts = {}) {
4829
5650
  // the COMP-HEALTH gate above may have downgraded buildStatus to 'failed'.
4830
5651
  // Assembled from the in-memory build context for THIS run (never re-read
4831
5652
  // active-build.json, which is last-writer-wins across concurrent builds).
5653
+ if (buildCancel.teardown || buildCancel.cancelled) {
5654
+ buildStatus = 'aborted';
5655
+ terminalFailureReason = buildCancel.reason;
5656
+ }
4832
5657
  if (['complete', 'aborted', 'failed', 'killed'].includes(buildStatus)) {
4833
5658
  const failureReason = buildFailureReason({
4834
5659
  buildStatus,
@@ -4929,10 +5754,18 @@ export async function runBuild(featureCode, opts = {}) {
4929
5754
  // File retained on disk per STRAT-COMP-4 — overwritten on next build start
4930
5755
 
4931
5756
  } catch (err) {
4932
- buildStatus = 'failed';
4933
- const failureReason = err?.message ?? 'Build failed';
5757
+ // COMP-BUILD-CANCEL §3.6: a pending teardown is the single terminal owner. Stand down —
5758
+ // do NOT terminalize (two writers, one record) and do NOT await it (the teardown is
5759
+ // waiting on `drained`, which this rethrow is what releases).
5760
+ if (buildCancel.teardown) {
5761
+ buildStatus = 'aborted';
5762
+ throw err;
5763
+ }
5764
+ buildStatus = buildCancel.cancelled ? 'aborted' : 'failed';
5765
+ const failureReason = buildCancel.cancelled ? buildCancel.reason : (err?.message ?? 'Build failed');
4934
5766
  try {
4935
5767
  await terminalizeThrownBuild({
5768
+ buildCancel, buildIdentity,
4936
5769
  cwd,
4937
5770
  dataDir,
4938
5771
  cfg,
@@ -4961,15 +5794,29 @@ export async function runBuild(featureCode, opts = {}) {
4961
5794
  }
4962
5795
  // Close stream writer with appropriate status (idempotent — signal handler may have already closed)
4963
5796
  if (streamWriter) {
4964
- streamWriter.close(buildStatus, buildCostTotals);
5797
+ if (suspended && !buildCancel.cancelled) streamWriter.pause();
5798
+ else streamWriter.close(buildStatus, buildCostTotals);
4965
5799
  }
4966
- if (signalHandler) {
4967
- process.removeListener('SIGINT', signalHandler);
4968
- process.removeListener('SIGTERM', signalHandler);
5800
+ // §3.6: the teardown removes the listeners itself, AFTER its writes. Removing them here
5801
+ // while it is pending would send a second Ctrl-C to the default handler, killing the
5802
+ // process mid-write.
5803
+ if (signalHandler && !buildCancel.teardown) {
5804
+ process.removeListener('SIGINT', signalHandler.listeners.onSigint);
5805
+ process.removeListener('SIGTERM', signalHandler.listeners.onSigterm);
4969
5806
  }
4970
5807
  progress.finish();
4971
- await stratum.close();
5808
+ // A pending teardown may still have a `flowCancel` in flight over this client; closing it
5809
+ // underneath would abandon the cancel. The teardown exits the process, so the deferred
5810
+ // close is a courtesy, not a leak.
5811
+ if (buildCancel.teardown) {
5812
+ void buildCancel.teardown.then(() => stratum.close()).catch(() => undefined);
5813
+ } else {
5814
+ await stratum.close();
5815
+ }
4972
5816
  runtimeResourcesFinalized = true;
5817
+ // §3.6: release the teardown's bounded wait — this build's resources are now closed and
5818
+ // finalizeBuildAttempt has emitted its actuals, so the teardown's writes cannot race it.
5819
+ buildCancel.resolveDrained();
4973
5820
  if (actualsError) throw actualsError;
4974
5821
  }
4975
5822
  } finally {
@@ -4978,16 +5825,34 @@ export async function runBuild(featureCode, opts = {}) {
4978
5825
  } finally {
4979
5826
  try {
4980
5827
  if (!runtimeResourcesFinalized) {
4981
- if (streamWriter) streamWriter.close(buildStatus);
4982
- if (signalHandler) {
4983
- process.removeListener('SIGINT', signalHandler);
4984
- process.removeListener('SIGTERM', signalHandler);
5828
+ if (streamWriter) {
5829
+ if (suspended && !buildCancel.cancelled) streamWriter.pause();
5830
+ else streamWriter.close(buildStatus);
5831
+ }
5832
+ if (signalHandler && !buildCancel.teardown) {
5833
+ process.removeListener('SIGINT', signalHandler.listeners.onSigint);
5834
+ process.removeListener('SIGTERM', signalHandler.listeners.onSigterm);
4985
5835
  }
4986
5836
  progress?.finish();
4987
- if (stratum) await stratum.close();
5837
+ if (stratum && !buildCancel.teardown) await stratum.close();
4988
5838
  runtimeResourcesFinalized = true;
5839
+ buildCancel.resolveDrained();
5840
+ }
5841
+ // §3.6: join the teardown so the CLI cannot exit out from under it. The bound is
5842
+ // DERIVED from the teardown's own two deadlines (C46), never chosen.
5843
+ if (buildCancel.teardown) {
5844
+ await withDeadline(buildCancel.teardown, teardownJoinMs).catch(() => undefined);
4989
5845
  }
4990
5846
  } finally {
5847
+ // S03-5b: a crashed build must leave no stale handle behind.
5848
+ // A join deadline is not settlement. Keep the handle visible to the CLI
5849
+ // until teardown finishes, even when this outer finally stops waiting.
5850
+ if (buildCancel.teardown) {
5851
+ const unregister = () => unregisterBuildCancel(registeredFlowId);
5852
+ void buildCancel.teardown.then(unregister, unregister);
5853
+ } else {
5854
+ unregisterBuildCancel(registeredFlowId);
5855
+ }
4991
5856
  // COMP-MCP-MIGRATION-1: restore COMPOSE_BUILD_ID env to its prior value
4992
5857
  // (or unset) so subsequent processes / tests don't inherit a stale UUID.
4993
5858
  _restoreBuildIdEnv();
@@ -5179,6 +6044,18 @@ export function toPhaseResultOutput(shipResult) {
5179
6044
  * Returns a PhaseResult-shaped object.
5180
6045
  */
5181
6046
  export async function executeShipStep(featureCode, agentCwd, cwd, context, description, progress) {
6047
+ const waveShipTree = prepareWaveShip(context);
6048
+ if (waveShipTree?.replay) {
6049
+ const { dispatchId, receipt } = waveShipTree.replay;
6050
+ const { result, completionEvidence } = receipt.detail;
6051
+ if (!result || result.outcome !== 'complete' || result.commit !== receipt.detail.commit) {
6052
+ throw new WaveCheckpointError('WAVE_CHECKPOINT_EVIDENCE_MISSING', 'Recorded ship result is unavailable');
6053
+ }
6054
+ await flushWaveReceipts(context, [dispatchId]);
6055
+ context.recordFilesChanged?.(result.filesChanged, { authoritativeShip: true });
6056
+ context.recordCompletionEvidence?.(completionEvidence);
6057
+ return structuredClone(result);
6058
+ }
5182
6059
  // COMP-FIX-HARD T4: bug mode stages docs/bugs/<code>/ instead of <featuresDir>/<code>/
5183
6060
  // COMP-MCP-MIGRATION-2: feature mode honors paths.features override.
5184
6061
  const featuresDir = loadFeaturesDir(cwd);
@@ -5485,6 +6362,25 @@ export async function executeShipStep(featureCode, agentCwd, cwd, context, descr
5485
6362
  if (filesChanged.length === 0 && sha) filesChanged = stagedFiles;
5486
6363
  context.recordFilesChanged?.(filesChanged, { authoritativeShip: true });
5487
6364
 
6365
+ const result = {
6366
+ phase: 'ship',
6367
+ artifact: sha ?? '',
6368
+ outcome: 'complete',
6369
+ summary: sha
6370
+ ? `Committed ${sha.slice(0, 8)}: ${commitMsg} (${stagedFiles.length} files)`
6371
+ : `Committed: ${commitMsg} (${stagedFiles.length} files)`,
6372
+ commit: sha,
6373
+ filesChanged,
6374
+ testsAttested,
6375
+ // Preserve structured test metrics on a replay as well as the first result.
6376
+ ...(testSummary.parsed ? { test_count: testSummary.test_count, pass_rate: testSummary.pass_rate } : {}),
6377
+ };
6378
+ const completionEvidence = { commitSha: sha, filesChanged, notes: shortDesc, builtVia, testsAttested, testSummary };
6379
+ if (waveShipTree && sha) await reportWaveEvidence(context, 'wave_ship',
6380
+ context.artifacts.journal.wave.checkpoints.at(-1).gateToken,
6381
+ { commit: sha, baseCommit: context.artifacts.journal.wave.baseCommit, checkpointTree: waveShipTree,
6382
+ filesChanged, result, completionEvidence });
6383
+
5488
6384
  // COMP-COMPLETION-GATE slice 2: the ship step no longer completes the feature.
5489
6385
  //
5490
6386
  // It used to call recordCompletion here, catch ANY failure, and still return
@@ -5495,34 +6391,14 @@ export async function executeShipStep(featureCode, agentCwd, cwd, context, descr
5495
6391
  //
5496
6392
  // Ship now collects evidence and stops. Exactly one completion happens, at
5497
6393
  // 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
- });
6394
+ context.recordCompletionEvidence?.(completionEvidence);
5505
6395
 
5506
6396
  // COMP-PATHS-EXTERNAL D6a: if ROADMAP / the feature folder resolved into a
5507
6397
  // DIFFERENT git repo, they were written but not committed here — tell the
5508
6398
  // user to commit them there (v1 does not auto-commit other repos).
5509
6399
  noticeExternalArtifacts(cwd, featureCode, gitToplevel(agentCwd));
5510
6400
 
5511
- return {
5512
- phase: 'ship',
5513
- artifact: sha ?? '',
5514
- outcome: 'complete',
5515
- summary: sha
5516
- ? `Committed ${sha.slice(0, 8)}: ${commitMsg} (${stagedFiles.length} files)`
5517
- : `Committed: ${commitMsg} (${stagedFiles.length} files)`,
5518
- commit: sha,
5519
- filesChanged,
5520
- testsAttested,
5521
- // COMP-MODEL-AB: thread structured test counts into the step result so the
5522
- // main loop can persist them to build-history.jsonl for metrics consumers.
5523
- // Only present when testSummary.parsed=true (framework detected + output parsed).
5524
- ...(testSummary.parsed ? { test_count: testSummary.test_count, pass_rate: testSummary.pass_rate } : {}),
5525
- };
6401
+ return result;
5526
6402
 
5527
6403
  } catch (err) {
5528
6404
  return {
@@ -5654,18 +6530,22 @@ function syncStepHistory(dataDir, stepHistory) {
5654
6530
  * @param {VisionWriter} visionWriter
5655
6531
  * @param {string} gateId
5656
6532
  * @param {number} [intervalMs=2000]
6533
+ * @param {AbortSignal} [signal]
5657
6534
  * @returns {Promise<object|null>} resolved gate or null (server lost mid-poll)
5658
6535
  */
5659
- async function pollGateResolution(visionWriter, gateId, intervalMs = 2000) {
6536
+ export async function pollGateResolution(visionWriter, gateId, intervalMs = 2000, signal) {
5660
6537
  let consecutiveFailures = 0;
5661
6538
  while (true) {
6539
+ signal?.throwIfAborted();
5662
6540
  try {
5663
6541
  const gate = await visionWriter.getGate(gateId, { requireServer: true });
6542
+ signal?.throwIfAborted();
5664
6543
  consecutiveFailures = 0;
5665
6544
  if (!gate) throw new Error(`Gate ${gateId} not found (404)`);
5666
6545
  if (gate.status === 'expired') throw new Error(`Gate ${gateId} expired`);
5667
6546
  if (gate.status !== 'pending') return gate;
5668
6547
  } catch (err) {
6548
+ signal?.throwIfAborted();
5669
6549
  if (err instanceof ServerUnreachableError) {
5670
6550
  consecutiveFailures++;
5671
6551
  if (consecutiveFailures >= 3) {
@@ -5676,7 +6556,7 @@ async function pollGateResolution(visionWriter, gateId, intervalMs = 2000) {
5676
6556
  throw err;
5677
6557
  }
5678
6558
  }
5679
- await new Promise(r => setTimeout(r, intervalMs));
6559
+ await sleep(intervalMs, undefined, { signal });
5680
6560
  }
5681
6561
  }
5682
6562
 
@@ -5796,51 +6676,195 @@ function loadFeatureDescription(featureDir, featureCode) {
5796
6676
  return featureCode;
5797
6677
  }
5798
6678
 
6679
+ const ABORT_TERMINAL_STATUSES = new Set(['complete', 'aborted', 'killed', 'failed']);
6680
+
6681
+ function abortSetting(name, fallback) {
6682
+ const value = Number(process.env[name] ?? fallback);
6683
+ return Number.isFinite(value) && value >= 0 ? Math.floor(value) : fallback;
6684
+ }
6685
+
6686
+ function abortRefusal(error) {
6687
+ if (['CANCELLATION_UNCONFIRMED', 'CANCELLATION_TEARDOWN_TIMEOUT', 'FLOW_NOT_FOUND'].includes(error?.code)) return error;
6688
+ if (isUnknownFlowError(error)) {
6689
+ return { code: 'FLOW_NOT_FOUND', reason: 'flow_not_found', status: null, flowSettled: false, agents: null };
6690
+ }
6691
+ return asTransportRefusal(error);
6692
+ }
6693
+
6694
+ function reportAbort(result) {
6695
+ console.log(`Flow status: ${result.status ?? 'unknown'} (settled: ${result.flowSettled}).`);
6696
+ if (result.reason) console.log(`Reason: ${result.reason}${result.holderPid == null ? '' : `; holderPid: ${result.holderPid}`}.`);
6697
+ for (const [counter, count] of Object.entries(result.agents ?? {})) {
6698
+ if (count > 0) console.log(`Agents ${counter}: ${count}.`);
6699
+ }
6700
+ console.log(`Driver: ${result.driverMode}; signalled: ${result.driverSignalled}; exited: ${result.driverExited}; terminal writer: ${result.terminalWriter}.`);
6701
+ console.log(result.ok ? 'Build aborted.' : `Build NOT aborted — ${result.reason}.`);
6702
+ return result;
6703
+ }
6704
+
6705
+ /** Retry only pre-settle lock refusals; a settled timeout gets one idempotent sweep. */
6706
+ async function cancelAbortFlow(stratum, flowId, result) {
6707
+ const retries = abortSetting('COMPOSE_ABORT_RETRIES', 2);
6708
+ let lockRetries = 0;
6709
+ let reswept = false;
6710
+ let settled = null;
6711
+ for (;;) {
6712
+ result.attempts++;
6713
+ let outcome;
6714
+ try {
6715
+ outcome = await stratum.flowCancel(flowId);
6716
+ } catch (error) {
6717
+ outcome = abortRefusal(error);
6718
+ }
6719
+ // Once durable settlement is known, a later broken pipe cannot undo it. Keep
6720
+ // the last settled counters/status and report the failed re-sweep separately.
6721
+ if (settled && outcome.flowSettled !== true) {
6722
+ console.log(`Re-sweep failed: ${outcome.reason ?? outcome.code ?? 'transport'}.`);
6723
+ return settled;
6724
+ }
6725
+ if (outcome.flowSettled === true) {
6726
+ settled = outcome;
6727
+ if (outcome.code === 'CANCELLATION_TEARDOWN_TIMEOUT' && !reswept) {
6728
+ reswept = true;
6729
+ console.log('Flow settled; re-sweeping agent teardown once.');
6730
+ continue;
6731
+ }
6732
+ return outcome;
6733
+ }
6734
+ if (outcome.code === 'CANCELLATION_UNCONFIRMED' && outcome.reason === 'run_lock_held'
6735
+ && Object.values(outcome.agents ?? {}).every(count => count === 0) && lockRetries < retries) {
6736
+ lockRetries++;
6737
+ console.log(`Cancel lock held${outcome.holderPid == null ? '' : ` by ${outcome.holderPid}`}; retry ${lockRetries}/${retries}.`);
6738
+ await new Promise(resolve => setTimeout(resolve, 100));
6739
+ continue;
6740
+ }
6741
+ return outcome;
6742
+ }
6743
+ }
6744
+
6745
+ /** The driver owns all terminal writes while it can still finish them itself. */
6746
+ async function waitForAbortDriver(dataDir, active, handle) {
6747
+ const deadline = Date.now() + abortSetting('COMPOSE_ABORT_DRIVER_WAIT_MS', 20000);
6748
+ for (;;) {
6749
+ const claim = claimActiveBuild(dataDir, active);
6750
+ if (!claim.ok) return claim;
6751
+ if (ABORT_TERMINAL_STATUSES.has(claim.record.status)
6752
+ && (!handle || !lookupBuildCancel(active.flowId))) {
6753
+ return { ...claim, exited: true };
6754
+ }
6755
+ const remaining = deadline - Date.now();
6756
+ if (remaining <= 0) return { ...claim, exited: false };
6757
+ await new Promise(resolve => setTimeout(resolve, Math.min(50, remaining)));
6758
+ }
6759
+ }
6760
+
5799
6761
  export async function abortBuild(dataDir, featureCode, cwd, opts = {}) {
5800
6762
  const active = readActiveBuild(dataDir);
6763
+ const result = {
6764
+ ok: false, flowId: active?.flowId ?? null, status: null, flowSettled: false,
6765
+ acknowledged: false, code: null, reason: null, holderPid: null, agents: null,
6766
+ attempts: 0, driverMode: 'none', driverSignalled: false, driverExited: false,
6767
+ terminalWriter: 'none', localCleanup: false,
6768
+ };
6769
+ const refuse = reason => reportAbort({ ...result, ok: false, reason, terminalWriter: 'none', localCleanup: false });
5801
6770
  if (!active) {
5802
6771
  console.log('No active build to abort.');
5803
- return;
6772
+ return refuse('no_active_build');
5804
6773
  }
5805
-
5806
6774
  if (featureCode && active.featureCode !== featureCode) {
5807
6775
  console.log(`Active build is for ${active.featureCode}, not ${featureCode}.`);
5808
- return;
6776
+ return refuse('feature_mismatch');
5809
6777
  }
6778
+ if (ABORT_TERMINAL_STATUSES.has(active.status)) return refuse(`already_${active.status}`);
5810
6779
 
6780
+ const handle = lookupBuildCancel(active.flowId);
6781
+ const foreignPid = !handle && active.pid && active.pid !== process.pid
6782
+ && active.status === 'running' && isProcessAlive(active.pid) ? active.pid : null;
6783
+ result.driverMode = handle ? 'in-process' : foreignPid ? 'foreign-pid' : 'none';
5811
6784
  console.log(`Aborting build for ${active.featureCode}...`);
5812
6785
 
5813
- // Probe the persisted TS run before abandoning the local build. Foreground TS
5814
- // runs advance only through this client, so closing it prevents more dispatch.
5815
- const stratum = opts.stratum ?? new StratumMcpClient();
5816
- const connection = resolveStratumMcpConnection(cwd);
6786
+ // Configuration errors (notably the retired Python pin) still reject BEFORE
6787
+ // connecting. Only a connection/transport failure belongs in the outcome table.
6788
+ const connection = active.flowId ? resolveStratumMcpConnection(cwd) : null;
6789
+ const stratum = connection ? (opts.stratum ?? new StratumMcpClient()) : null;
5817
6790
  try {
5818
- await stratum.connect(connection);
5819
- const audit = await stratum.audit(active.flowId);
5820
- if (isTerminalFlow(audit.status)) {
5821
- console.log(`Flow already ${audit.status}.`);
6791
+ let outcome = { flowSettled: true };
6792
+ if (stratum) {
6793
+ try {
6794
+ await stratum.connect({ ...connection,
6795
+ env: { ...process.env, STRATUM_CANCEL_LOCK_WAIT_MS: String(abortSetting('COMPOSE_ABORT_LOCK_WAIT_MS', 10000)) },
6796
+ });
6797
+ } catch (error) {
6798
+ outcome = asTransportRefusal(error);
6799
+ }
6800
+ if (!outcome.code) outcome = await cancelAbortFlow(stratum, active.flowId, result);
6801
+ }
6802
+ for (const key of ['status', 'code', 'reason', 'holderPid', 'agents']) result[key] = outcome[key] ?? null;
6803
+ result.flowSettled = outcome.flowSettled === true;
6804
+ result.acknowledged = outcome.acknowledged === true;
6805
+ const alreadyTerminal = !outcome.code && ['completed', 'failed', 'budget_exhausted'].includes(outcome.status)
6806
+ && outcome.reason === `already_${outcome.status}`;
6807
+ if (!result.flowSettled && !alreadyTerminal) return refuse(result.reason ?? 'transport');
6808
+
6809
+ // C41/C48: the awaits above may have handed ownership to a different build.
6810
+ // Claim BEFORE even cancelling the local controller or signalling a driver.
6811
+ let claim = claimActiveBuild(dataDir, active);
6812
+ if (!claim.ok) return refuse(claim.reason);
6813
+ if (result.flowSettled) {
6814
+ if (handle) handle.cancel('abort');
6815
+ else if (foreignPid && isProcessAlive(foreignPid)) {
6816
+ try { process.kill(foreignPid, 'SIGTERM'); result.driverSignalled = true; }
6817
+ catch (error) {
6818
+ if (error.code !== 'ESRCH') console.log(`Driver SIGTERM failed: ${error.code ?? error.message}.`);
6819
+ }
6820
+ }
6821
+ }
6822
+ if (result.driverMode !== 'none') {
6823
+ const driver = await waitForAbortDriver(dataDir, active, handle);
6824
+ if (!driver.ok) return refuse(driver.reason);
6825
+ if (driver.exited) {
6826
+ result.driverExited = true;
6827
+ result.terminalWriter = 'driver';
6828
+ result.ok = ['aborted', 'killed'].includes(driver.record.status);
6829
+ result.localCleanup = result.ok;
6830
+ if (!result.ok) result.reason = `already_${driver.record.status}`;
6831
+ return reportAbort(result);
6832
+ }
5822
6833
  }
5823
- } catch {
5824
- // A missing/unreadable run does not prevent local cleanup.
5825
- } finally {
5826
- await stratum.close();
5827
- }
5828
-
5829
- // Update vision state
5830
- const visionWriter = new VisionWriter(dataDir);
5831
- const item = await visionWriter.findFeatureItem(active.featureCode);
5832
- const itemId = item?.id;
5833
- if (itemId) {
5834
- await visionWriter.updateItemStatus(itemId, 'killed');
5835
- }
5836
6834
 
5837
- // Write terminal state (file retained per STRAT-COMP-4 contract)
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');
6835
+ // Re-claim after the bounded wait, covering vision + state + actuals as one
6836
+ // cleanup. The aborter never restamps the driver's pid.
6837
+ claim = claimActiveBuild(dataDir, active);
6838
+ if (!claim.ok) return refuse(claim.reason);
6839
+ if (ABORT_TERMINAL_STATUSES.has(claim.record.status)) return refuse(`already_${claim.record.status}`);
6840
+ const visionWriter = new VisionWriter(dataDir);
6841
+ const item = await visionWriter.findFeatureItem(active.featureCode);
6842
+ claim = claimActiveBuild(dataDir, active);
6843
+ if (!claim.ok) return refuse(claim.reason);
6844
+ if (ABORT_TERMINAL_STATUSES.has(claim.record.status)) return refuse(`already_${claim.record.status}`);
6845
+ if (item?.id) {
6846
+ try { await killOwnedBuildVision(visionWriter, item.id, () => claimActiveBuild(dataDir, active)); }
6847
+ catch (error) {
6848
+ if (['ownership_lost', 'ownership_unverifiable'].includes(error.reason)) return refuse(error.reason);
6849
+ throw error;
6850
+ }
6851
+ }
6852
+ claim = claimActiveBuild(dataDir, active);
6853
+ if (!claim.ok) return refuse(claim.reason);
6854
+ if (ABORT_TERMINAL_STATUSES.has(claim.record.status)) return refuse(`already_${claim.record.status}`);
6855
+ writeActiveBuild(dataDir, { ...claim.record, status: 'aborted', completedAt: new Date().toISOString() }, { stampPid: false });
6856
+ const accumulator = readBuildAccumulator(cwd, active.featureCode);
6857
+ if (accumulator) emitBuildActuals(cwd, accumulator, 'aborted');
6858
+ result.ok = true;
6859
+ result.localCleanup = true;
6860
+ result.terminalWriter = 'abort';
6861
+ return reportAbort(result);
6862
+ } finally {
6863
+ if (stratum) {
6864
+ try { await stratum.close(); }
6865
+ catch (error) { console.warn(`Abort client close failed: ${error.message}`); }
6866
+ }
5842
6867
  }
5843
- console.log('Build aborted.');
5844
6868
  }
5845
6869
 
5846
6870
  /** Optional revision failures keep the draft; only user control or uncertain teardown stops the build. */