@smartmemory/compose 0.5.0 → 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.
package/lib/build.js CHANGED
@@ -9,7 +9,7 @@
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';
@@ -27,14 +27,14 @@ import { preflightCodexWorktreeProbe, codexProbeAbortMessage } from './codex-pre
27
27
  import { buildStepPrompt, buildGateContext, clearAmbientContextCache } from './step-prompt.js';
28
28
  import { promptGate } from './gate-prompt.js';
29
29
  import { VisionWriter, ServerUnreachableError } from './vision-writer.js';
30
- import { readFlowRound } from './flow-state.js';
30
+ import { readFlowRound, readFlowSnapshot, readFlowSpend } from './flow-state.js';
31
31
  import { resolvePort } from './resolve-port.js';
32
32
  import { probeServer } from './server-probe.js';
33
33
  import { CliProgress } from './cli-progress.js';
34
34
  import { BuildStreamWriter } from './build-stream-writer.js';
35
35
  import { appendBuildHistory, projectHistorySteps, stepOutcomeToStatus } from './build-history.js';
36
36
  import { KNOWN_VERSIONS } from './build-stream-schema.js';
37
- import { resolveAgentConfig, parseAgentString } from './agent-string.js';
37
+ import { resolveAgentConfig, parseAgentString, validateAgentString } from './agent-string.js';
38
38
  import { emitSections as emitPlanSections, appendTrailers as appendSectionTrailers, analyzeRollup, writeRollup } from './sections.js';
39
39
  import { SECTIONS_DIR } from './constants.js';
40
40
  import { rtkPrefix } from './rtk.js';
@@ -96,6 +96,9 @@ import {
96
96
  recoverAdvancedConsumerArtifacts,
97
97
  verifyConsumerRunRevision,
98
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';
99
102
  import { appendEvent as appendDispatchEvent, readEvents as readDispatchEvents } from './dispatch-ledger.js';
100
103
  import { appendEvent as appendFeatureEvent } from './feature-events.js';
101
104
 
@@ -773,6 +776,257 @@ async function reportConsumerStepDone({
773
776
  }
774
777
  }
775
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
+
776
1030
  export async function runConsumerIssuance({
777
1031
  descriptor,
778
1032
  flowId,
@@ -797,7 +1051,30 @@ export async function runConsumerIssuance({
797
1051
  // sidecar), applied at invocation so e.g. an isolation:none review fanout runs
798
1052
  // read-only. Absent → the descriptor's bare provider literal (no restrictions).
799
1053
  profile = null,
1054
+ admission = null,
800
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
+ }
801
1078
  let recovery;
802
1079
  try {
803
1080
  recovery = artifacts.reconcileDescriptor(descriptor, audit);
@@ -829,6 +1106,8 @@ export async function runConsumerIssuance({
829
1106
  throw new Error(`accepted consumer issuance ${descriptor.id} unexpectedly remained ready`);
830
1107
  }
831
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 });
832
1111
  const report = await reportConsumerStepDone({
833
1112
  descriptor,
834
1113
  flowId,
@@ -899,8 +1178,7 @@ export async function runConsumerIssuance({
899
1178
  // D3: stable per-item key for the stuck detector's per-task bookkeeping. In
900
1179
  // GSD mode this becomes the operator-facing stuck task id (stuck.json/pause.json/
901
1180
  // stuck.md), so it MUST be the decompose task id (T01), matching the blackboard
902
- // + milestone report. The engine's GSD consumer descriptor carries no `.item`,
903
- // so the real runGsd call site threads the resolved id via context.gsdTaskId
1181
+ // + milestone report. GSD threads descriptor.item.id via context.gsdTaskId
904
1182
  // (same precedence as gsdTaskId below); build-mode fanout passes no gsdTaskId and
905
1183
  // keeps the item-id/index key unchanged (byte-identical).
906
1184
  const stuckTaskId = context?.gsdTaskId ?? descriptor.item?.id ?? `${descriptor.step ?? descriptor.id}:${descriptor.itemIndex}`;
@@ -961,6 +1239,12 @@ export async function runConsumerIssuance({
961
1239
  lane,
962
1240
  });
963
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
+ }
964
1248
  let mainResult;
965
1249
  try {
966
1250
  mainResult = await runAndNormalize(null, prompt, dispatch, {
@@ -1062,6 +1346,9 @@ export async function runConsumerIssuance({
1062
1346
  const { result, normalizationFailure } = mainResult;
1063
1347
  // D2(b): forward the item's agent usage so GSD can debit the cumulative
1064
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
+ }
1065
1352
  if (typeof context?.onUsage === 'function' && mainResult?.usage) {
1066
1353
  await context.onUsage(usagePayload(mainResult.usage, mainResult.usages), {
1067
1354
  dispatchId: mainResult.dispatchIds?.primary,
@@ -1084,7 +1371,7 @@ export async function runConsumerIssuance({
1084
1371
  );
1085
1372
  if (gateFailure) localFailure = `pre_merge failed: ${JSON.stringify(gateFailure)}`;
1086
1373
  }
1087
- const envelope = localFailure
1374
+ let envelope = localFailure
1088
1375
  ? { failure: String(localFailure) }
1089
1376
  : contract.hasOutContract
1090
1377
  ? result != null
@@ -1108,8 +1395,18 @@ export async function runConsumerIssuance({
1108
1395
  });
1109
1396
  }
1110
1397
  const preparedEntry = artifacts.prepareIssuance(descriptor, envelope, {
1111
- finalStage,
1398
+ finalStage, ...(bound ?? {}),
1399
+ ownership: context.pipelineProfiles?._consumer?.[descriptor.step]?.ownership,
1112
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');
1113
1410
  // H3: record this item's timing + (final-stage) diff snapshot for the GSD
1114
1411
  // milestone report. `preparedEntry.diff` is the cumulative worktree diff the
1115
1412
  // artifacts journal already computed at final stage (null otherwise) — tapped
@@ -1156,7 +1453,7 @@ export async function runConsumerIssuance({
1156
1453
  stepId: descriptor.id,
1157
1454
  summary: result?.summary ?? `consumer item ${descriptor.itemIndex} stage ${descriptor.stage} reported`,
1158
1455
  retries: Math.max(0, (descriptor.attempt ?? 1) - 1),
1159
- violations: [],
1456
+ violations: preparedEntry?.findings ?? [],
1160
1457
  flowId,
1161
1458
  consumer: true,
1162
1459
  // H6: matches the item's start stepId so the UI decrements the same task
@@ -1165,7 +1462,7 @@ export async function runConsumerIssuance({
1165
1462
  // COMP-AGENT-LANES (C4): terminal status is explicit at source — the UI
1166
1463
  // must not infer "complete" from the done event's existence.
1167
1464
  status: localFailure ? 'failed' : 'succeeded',
1168
- outcome: result?.outcome ?? (localFailure ? 'failed' : 'succeeded'),
1465
+ outcome: localFailure ? 'failed' : (result?.outcome ?? 'succeeded'),
1169
1466
  itemIndex: descriptor.itemIndex,
1170
1467
  stage: descriptor.stage,
1171
1468
  generation: descriptor.generation,
@@ -1213,26 +1510,158 @@ function extractFilesChanged(response) {
1213
1510
  * @param {Array<{id?:string, files_owned?:string[]}>} tasks
1214
1511
  * @returns {string|null}
1215
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
+
1216
1554
  /**
1217
1555
  * D6: load the compose-owned profile sidecar next to a pipeline spec. The engine
1218
1556
  * accepts only the literal claude|codex agent, so the full profile strings that
1219
1557
  * carry tool restrictions + model tiers (claude:read-only-reviewer,
1220
1558
  * claude::critical, claude:orchestrator, ...) live in <spec>.profiles.json keyed
1221
1559
  * by step id and are applied compose-side at invocation. Absent → {} (bare
1222
- * literals; no restrictions).
1560
+ * literals; no restrictions), after runBuild enforces required bundled sidecars.
1561
+ * Malformed or non-object sidecars fail closed.
1223
1562
  *
1224
1563
  * @param {string} specPath path to the .stratum.yaml spec
1225
1564
  * @returns {Record<string,string>} step id → agent profile string
1226
1565
  */
1227
1566
  export function loadPipelineProfiles(specPath) {
1567
+ const sidecar = String(specPath).replace(/\.stratum\.ya?ml$/, '.profiles.json');
1568
+ if (sidecar === String(specPath)) return {};
1228
1569
  try {
1229
- const sidecar = String(specPath).replace(/\.stratum\.ya?ml$/, '.profiles.json');
1230
- if (sidecar === String(specPath) || !existsSync(sidecar)) return {};
1231
1570
  const parsed = JSON.parse(readFileSync(sidecar, 'utf-8'));
1232
- return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
1233
- } catch {
1234
- 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}`);
1578
+ }
1579
+ }
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
+ }
1235
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
+ });
1236
1665
  }
1237
1666
 
1238
1667
  /**
@@ -1285,7 +1714,8 @@ function usagePayload(usage, usages) {
1285
1714
 
1286
1715
  /** Send one surface-15 receipt per underlying model dispatch. */
1287
1716
  export async function reportUsageReceipts(context, usage, meta = {}) {
1288
- if (context?.buildCancel?.cancelled || !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') {
1289
1719
  return [];
1290
1720
  }
1291
1721
  const entries = Array.isArray(usage)
@@ -1293,10 +1723,10 @@ export async function reportUsageReceipts(context, usage, meta = {}) {
1293
1723
  : (Array.isArray(usage?.usages) ? usage.usages : (usage ? [usage] : []));
1294
1724
  const responses = [];
1295
1725
  for (const entry of entries) {
1296
- if (context.buildCancel?.cancelled) break;
1726
+ if (context.buildCancel?.cancelled && !context.pipelineProfiles?._costCeiling) break;
1297
1727
  if (!entry || typeof entry !== 'object') continue;
1298
- const engineUsage = toEngineUsage(entry);
1299
- if (!engineUsage) continue;
1728
+ const engineUsage = toEngineUsage(entry) ?? (context.pipelineProfiles?._costCeiling ? {} : null);
1729
+ if (!engineUsage && !context.pipelineProfiles?._costCeiling) continue;
1300
1730
  // Surface 15 requires explicit USD provenance. Normalized UsageRecords carry
1301
1731
  // `usd_source`; raw engine usage ({tokens, usd, ms}) does not. Preserve raw
1302
1732
  // token/time usage, but fail closed on an unlabelled dollar value instead of
@@ -1305,7 +1735,7 @@ export async function reportUsageReceipts(context, usage, meta = {}) {
1305
1735
  ? entry.usd_source
1306
1736
  : null;
1307
1737
  if (Object.hasOwn(engineUsage, 'usd') && !usdSource) delete engineUsage.usd;
1308
- if (Object.keys(engineUsage).length === 0) continue;
1738
+ if (Object.keys(engineUsage).length === 0 && !context.pipelineProfiles?._costCeiling) continue;
1309
1739
  const input = entry.input_tokens;
1310
1740
  const output = entry.output_tokens;
1311
1741
  const receipt = {
@@ -1331,9 +1761,14 @@ export async function reportUsageReceipts(context, usage, meta = {}) {
1331
1761
  } }
1332
1762
  : {}),
1333
1763
  ...(Object.hasOwn(engineUsage, 'usd') ? { usdSource } : {}),
1764
+ ...(context.pipelineProfiles?._costCeiling && (!usdSource || !Number.isFinite(entry.cost_usd ?? entry.usd))
1765
+ ? { detail: { costUnknown: true } } : {}),
1334
1766
  };
1335
1767
  try {
1336
- 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));
1337
1772
  } catch (error) {
1338
1773
  if (await confirmCancellation(error, context)) break;
1339
1774
  console.warn(`[usage-receipt] failed for ${receipt.dispatchId}: ${error?.message ?? error}`);
@@ -2395,6 +2830,7 @@ export async function runBuild(featureCode, opts = {}) {
2395
2830
  let streamWriter = null;
2396
2831
  let signalHandler = null;
2397
2832
  let runtimeResourcesFinalized = false;
2833
+ let suspended = false;
2398
2834
 
2399
2835
  const _priorBuildIdEnv = process.env.COMPOSE_BUILD_ID;
2400
2836
  if (_priorBuildIdEnv !== undefined) {
@@ -2410,7 +2846,7 @@ export async function runBuild(featureCode, opts = {}) {
2410
2846
  else process.env.COMPOSE_BUILD_ID = _priorBuildIdEnv;
2411
2847
  };
2412
2848
  const finalizeBuildAttempt = () => {
2413
- if (!attemptStarted || attemptFinalized) return;
2849
+ if (!attemptStarted || attemptFinalized || (suspended && !buildCancel.cancelled)) return;
2414
2850
  if (buildCancel.teardown || buildCancel.cancelled) buildStatus = 'aborted';
2415
2851
  const terminalStatus = buildStatus === 'complete'
2416
2852
  ? 'complete'
@@ -2588,11 +3024,16 @@ export async function runBuild(featureCode, opts = {}) {
2588
3024
  if (!existsSync(specPath)) {
2589
3025
  throw new Error(`Lifecycle spec not found: ${specPath}`);
2590
3026
  }
3027
+ requirePipelineSidecar(specPath);
2591
3028
  // D6: compose-owned per-step agent profiles (tool restrictions + model tiers)
2592
3029
  // for this pipeline. The engine ships only bare claude|codex; these restore
2593
- // the stripped profiles at invocation. Absent sidecar → {} (bare literals).
3030
+ // the stripped profiles at invocation. Optional absent sidecar → {} (bare literals).
2594
3031
  const stepProfiles = loadPipelineProfiles(specPath);
2595
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
+ }
2596
3037
 
2597
3038
  // COMP-PIPELINE-QUARANTINE: refuse a retired-dialect spec HERE, at the one
2598
3039
  // seam every template passes through (build, fix, plan, --quick, --template,
@@ -2863,6 +3304,28 @@ export async function runBuild(featureCode, opts = {}) {
2863
3304
  }
2864
3305
  }
2865
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();
2866
3329
  // Restore persisted roles when (and only when) a resume actually happens.
2867
3330
  const restoreRolesFromActive = (src) => {
2868
3331
  if (mode !== 'feature' || !src || !src.implementerAgent) return;
@@ -2874,6 +3337,7 @@ export async function runBuild(featureCode, opts = {}) {
2874
3337
  }
2875
3338
  implementerAgent = src.implementerAgent;
2876
3339
  reviewerAgent = src.reviewerAgent ?? reviewerAgent;
3340
+ refreshProfilePreflight();
2877
3341
  };
2878
3342
 
2879
3343
  const activeForDecision = active && active.featureCode === featureCode ? active : null;
@@ -2893,6 +3357,21 @@ export async function runBuild(featureCode, opts = {}) {
2893
3357
  if (probeFlowId && !opts.fresh && (opts.resumeFlowId || !flowTerminal)) {
2894
3358
  try {
2895
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
+ }
2896
3375
  if (audit?.status === 'cancelled') {
2897
3376
  // C42/C49: a resume probe is not necessarily the owning driver.
2898
3377
  const handle = lookupBuildCancel(probeFlowId);
@@ -2911,14 +3390,7 @@ export async function runBuild(featureCode, opts = {}) {
2911
3390
  });
2912
3391
  }
2913
3392
  flowTerminal = isTerminalFlow(audit?.status);
2914
- if (flowTerminal) {
2915
- recoverAdvancedConsumerArtifacts({
2916
- runId: probeFlowId,
2917
- targetCwd: agentCwd,
2918
- artifactRoot: opts.consumerArtifactsRoot,
2919
- audit,
2920
- });
2921
- }
3393
+
2922
3394
  } catch (err) {
2923
3395
  if (isRecoverableFlowProbeError(err)) {
2924
3396
  // An unknown flow is only evidence the build is over when its driver is
@@ -2939,8 +3411,18 @@ export async function runBuild(featureCode, opts = {}) {
2939
3411
  sameMode,
2940
3412
  });
2941
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
+ }
2942
3423
 
2943
3424
  if (verdict.action === 'resume') {
3425
+ restoreRolesFromActive(activeForDecision);
2944
3426
  const resumeFlowId = verdict.flowId;
2945
3427
  console.log(`Resuming flow ${resumeFlowId} for ${featureCode}...`);
2946
3428
  response = await stratum.resume(resumeFlowId);
@@ -2954,6 +3436,7 @@ export async function runBuild(featureCode, opts = {}) {
2954
3436
  artifactRoot: opts.consumerArtifactsRoot,
2955
3437
  specDigest: localSpecDigest,
2956
3438
  resumeRevisionDigest: response?.revisionDigest,
3439
+ profilesDigest: waveProfilesEnabled(pipelineProfiles) ? profilePreflight.profilesDigest : undefined,
2957
3440
  });
2958
3441
  try {
2959
3442
  recoverAdvancedConsumerArtifacts({
@@ -2963,7 +3446,7 @@ export async function runBuild(featureCode, opts = {}) {
2963
3446
  audit: await stratum.audit(resumeFlowId),
2964
3447
  });
2965
3448
  } catch (error) {
2966
- if (error instanceof ConsumerArtifactError) throw error;
3449
+ if (error instanceof ConsumerArtifactError || error instanceof WaveCheckpointError) throw error;
2967
3450
  // Audit/cleanup projection is best-effort for ordinary non-consumer runs.
2968
3451
  }
2969
3452
  // A BARE programmatic resumeFlowId (no --resume flag) resumes the named
@@ -3169,19 +3652,7 @@ export async function runBuild(featureCode, opts = {}) {
3169
3652
  specPath: `pipelines/${templateName}.stratum.yaml`,
3170
3653
  });
3171
3654
 
3172
- // V4: merge the runtime profile map over the static sidecar (runtime wins).
3173
- // Tiers/templates carried by $.input.* agents (e.g. --implementer=claude::critical)
3174
- // are stripped to the bare provider at resolution for the engine; recover the
3175
- // full string here so the tier/capability profile still binds at invocation.
3176
- const runtimeProfiles = {};
3177
- try {
3178
- resolvePlanSpecValues(
3179
- YAML.parse(readFileSync(specPath, 'utf-8')),
3180
- { implementer_agent: implementerAgent, reviewer_agent: reviewerAgent },
3181
- runtimeProfiles,
3182
- );
3183
- } catch { /* best-effort — a malformed spec fails later at plan() */ }
3184
- const effectiveProfiles = { ...stepProfiles, ...runtimeProfiles };
3655
+ streamWriter.write({ type: 'profile_preflight', steps: profilePreflight.resolved });
3185
3656
  // H1: reducer steps — ReviewResult-out steps that MERGE/deduplicate rather
3186
3657
  // than review (e.g. review_merge). They still get review normalization +
3187
3658
  // confidence handling, but must NOT get the reviewer scaffold. python's
@@ -3221,6 +3692,7 @@ export async function runBuild(featureCode, opts = {}) {
3221
3692
  // V4: merged runtime + static agent profiles let scoped consumer steps
3222
3693
  // recover their tier/capability profile via resolveStepProfile normalization.
3223
3694
  stepProfiles: effectiveProfiles,
3695
+ pipelineProfiles,
3224
3696
  // COMP-BUILD-QUICK-1: the pipeline template (e.g. 'build-quick') so the ship
3225
3697
  // step can stamp built_via onto feature.json for the validator's exemption.
3226
3698
  templateName,
@@ -3339,12 +3811,23 @@ export async function runBuild(featureCode, opts = {}) {
3339
3811
  // unpinned journal a drifted spec could re-pin (R1).
3340
3812
  revisionDigest: pins?.revisionDigest,
3341
3813
  specDigest: pins?.specDigest,
3814
+ profilesDigest: waveProfilesEnabled(pipelineProfiles) ? profilePreflight.profilesDigest : undefined,
3342
3815
  });
3343
3816
  } else if (consumerArtifacts.runId !== runId) {
3344
3817
  throw new Error(`consumer artifact manager is bound to ${consumerArtifacts.runId}, not ${runId}`);
3345
3818
  }
3819
+ context.artifacts = consumerArtifacts;
3346
3820
  return consumerArtifacts;
3347
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
+ }
3348
3831
 
3349
3832
 
3350
3833
  // COMP-PLAN-GATE-LOOP: per-step gate re-entry counter. A `revise` that
@@ -3385,7 +3868,7 @@ export async function runBuild(featureCode, opts = {}) {
3385
3868
  }
3386
3869
  };
3387
3870
 
3388
- const runConsumerDescriptor = async (descriptor, sourceResponse) => {
3871
+ const runConsumerDescriptor = async (descriptor, sourceResponse, admission) => {
3389
3872
  const flowId = sourceResponse.runId ?? sourceResponse.flow_id;
3390
3873
  const artifacts = artifactsForRun(flowId);
3391
3874
  const audit = await stratum.audit(flowId);
@@ -3396,6 +3879,7 @@ export async function runBuild(featureCode, opts = {}) {
3396
3879
  });
3397
3880
  return runConsumerIssuance({
3398
3881
  descriptor,
3882
+ admission,
3399
3883
  flowId,
3400
3884
  buildCancel,
3401
3885
  stratum,
@@ -3409,8 +3893,8 @@ export async function runBuild(featureCode, opts = {}) {
3409
3893
  // claude:read-only-reviewer, or a runtime --implementer=claude::critical)
3410
3894
  // so an isolation:none review item runs read-only instead of with
3411
3895
  // Edit/Write/Bash in the target workspace.
3412
- profile: resolveStepProfile(effectiveProfiles, descriptor.step)
3413
- ?? resolveStepProfile(effectiveProfiles, descriptor.id),
3896
+ profile: resolveStepProfile(context.stepProfiles, descriptor.step)
3897
+ ?? resolveStepProfile(context.stepProfiles, descriptor.id),
3414
3898
  });
3415
3899
  };
3416
3900
 
@@ -3420,7 +3904,7 @@ export async function runBuild(featureCode, opts = {}) {
3420
3904
  && consumerPending.length > 0) {
3421
3905
  const work = consumerPending.shift();
3422
3906
  const token = work.descriptor.dispatchToken;
3423
- const task = runConsumerDescriptor(work.descriptor, work.sourceResponse)
3907
+ const task = runConsumerDescriptor(work.descriptor, work.sourceResponse, work.admission)
3424
3908
  .then((nextResponse) => {
3425
3909
  consumerCompleted.push(nextResponse);
3426
3910
  }, (error) => {
@@ -3446,7 +3930,8 @@ export async function runBuild(featureCode, opts = {}) {
3446
3930
  for (const descriptor of descriptors) {
3447
3931
  if (consumerSeenTokens.has(descriptor.dispatchToken)) continue;
3448
3932
  const flowId = engineResponse.runId ?? engineResponse.flow_id;
3449
- const runPins = { revisionDigest: descriptor.revisionDigest, specDigest: localSpecDigest };
3933
+ const runPins = { revisionDigest: descriptor.revisionDigest, specDigest: localSpecDigest,
3934
+ profilesDigest: waveProfilesEnabled(pipelineProfiles) ? profilePreflight.profilesDigest : undefined };
3450
3935
  // Preflight a whole ready batch before launching it. This preserves the
3451
3936
  // shipped crash-before-first-bind boundary (zero issuances may execute)
3452
3937
  // while allowing the work after revision fencing to overlap.
@@ -3455,8 +3940,11 @@ export async function runBuild(featureCode, opts = {}) {
3455
3940
  await artifacts.hooks.beforeRevisionBind({ descriptor, journal: artifacts.journal });
3456
3941
  }
3457
3942
  artifacts.bindRunRevision(runPins);
3943
+ const admission = await admitConsumerWave({ descriptor, descriptors,
3944
+ localSpec, profiles: pipelineProfiles,
3945
+ artifacts, stratum, flowId });
3458
3946
  consumerSeenTokens.add(descriptor.dispatchToken);
3459
- consumerPending.push({ descriptor, sourceResponse: engineResponse });
3947
+ consumerPending.push({ descriptor, sourceResponse: engineResponse, admission });
3460
3948
  }
3461
3949
  launchPendingConsumers();
3462
3950
  }
@@ -3711,7 +4199,7 @@ export async function runBuild(featureCode, opts = {}) {
3711
4199
  // here handed the fixer an unrestricted profile; the sibling
3712
4200
  // review-repair site keys off `fix` the same way. Identity still
3713
4201
  // comes from the dispatch (`agent: fixAgent`).
3714
- profile: resolveStepProfile(context.stepProfiles, 'fix')
4202
+ profile: resolveStepProfile(effectiveProfiles, 'fix')
3715
4203
  ?? resolveStepProfile(context.stepProfiles, stepId),
3716
4204
  sandboxMode: 'workspace-write',
3717
4205
  ...(flowTag(flowId, stepId) ? { flow: flowTag(flowId, stepId) } : {}),
@@ -3816,7 +4304,7 @@ export async function runBuild(featureCode, opts = {}) {
3816
4304
  // D6/V4: apply this ordinary step's compose-side profile (e.g.
3817
4305
  // blueprint → claude::critical, review_merge → claude:orchestrator),
3818
4306
  // normalizing scoped subflow ready ids to the bare step id.
3819
- profile: resolveStepProfile(effectiveProfiles, stepId),
4307
+ profile: resolveStepProfile(context.stepProfiles, stepId),
3820
4308
  });
3821
4309
  } catch (err) {
3822
4310
  if (buildCancel.cancelled) throw err;
@@ -3894,7 +4382,7 @@ export async function runBuild(featureCode, opts = {}) {
3894
4382
  progress, streamWriter, maxDurationMs, stratum, cwd: agentCwd,
3895
4383
  reviewMode: isReviewMain,
3896
4384
  confidenceGate: confGateMain,
3897
- profile: resolveStepProfile(effectiveProfiles, stepId),
4385
+ profile: resolveStepProfile(context.stepProfiles, stepId),
3898
4386
  ...(flowTag(flowId, stepId) ? { flow: flowTag(flowId, stepId) } : {}),
3899
4387
  flowId, buildCancel,
3900
4388
  buildSignal: buildCancel.signal,
@@ -4138,6 +4626,9 @@ export async function runBuild(featureCode, opts = {}) {
4138
4626
  // Report each model dispatch before the outcome it funded. The merged
4139
4627
  // step usage remains the accumulator/build-stream shape used by existing
4140
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
+ }
4141
4632
  if (toEngineUsage(stepUsage)) {
4142
4633
  buildCostTotals.input_tokens += stepUsage.input_tokens ?? 0;
4143
4634
  buildCostTotals.output_tokens += stepUsage.output_tokens ?? 0;
@@ -4390,6 +4881,9 @@ export async function runBuild(featureCode, opts = {}) {
4390
4881
  { gateStepId, localFanoutStepId: consumerFanoutStep.id, journaledFanoutStepId: journaledFanoutId },
4391
4882
  );
4392
4883
  }
4884
+ if (pipelineProfiles._consumer?.[consumerFanoutStep.id]?.checkpoint_gate === gateStepId) {
4885
+ consumerMergeArtifacts.initializeWave({ ref: `refs/heads/compose/wave/${flowId}`, profilesDigest: profilePreflight.profilesDigest });
4886
+ }
4393
4887
  consumerMergeArtifacts.recordGateBinding({ gateStepId, fanoutStepId: consumerFanoutStep.id });
4394
4888
  }
4395
4889
 
@@ -4451,6 +4945,7 @@ export async function runBuild(featureCode, opts = {}) {
4451
4945
  await consumerMergeArtifacts.applyMerge(consumerMergeTransaction);
4452
4946
  mergeApplied = true;
4453
4947
  try {
4948
+ if (!consumerMergeArtifacts.journal.wave) {
4454
4949
  const changed = execSync(
4455
4950
  'git diff --name-only HEAD; git ls-files --others --exclude-standard',
4456
4951
  { cwd: agentCwd, encoding: 'utf8', timeout: 5000, stdio: 'pipe' },
@@ -4461,6 +4956,7 @@ export async function runBuild(featureCode, opts = {}) {
4461
4956
  context.filesChanged = [...files];
4462
4957
  context.recordFilesChanged(context.filesChanged);
4463
4958
  }
4959
+ }
4464
4960
  } catch { /* best-effort build context projection */ }
4465
4961
  } catch (error) {
4466
4962
  if (!(error instanceof ConsumerMergeDecisionError)) throw error;
@@ -4494,11 +4990,38 @@ export async function runBuild(featureCode, opts = {}) {
4494
4990
  next = await callFlowWithCancellation(stratum, 'gateResolve', flowId, buildCancel,
4495
4991
  stepId, outcome, rationale, resolvedBy, gateToken,
4496
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
+ }
4497
5000
  if (next.status === 'cancelled') buildCancel.cancel('flow_cancelled');
4498
5001
  if (buildCancel.cancelled) throw buildCancel.signal.reason;
4499
5002
  } catch (error) {
4500
- if (mergeApplied && buildCancel.cancelled) reverseCancelledMerge();
4501
- throw 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
+ }
4502
5025
  }
4503
5026
  if (consumerMergeArtifacts) {
4504
5027
  if (typeof consumerMergeArtifacts.hooks.afterGateResolve === 'function') {
@@ -4515,6 +5038,8 @@ export async function runBuild(featureCode, opts = {}) {
4515
5038
  if (outcome === 'approve' || outcome === 'kill' || isTerminalFlow(next.status)) {
4516
5039
  consumerMergeArtifacts.cleanupWorktrees(
4517
5040
  outcome === 'approve' ? 'merge gate approved and advanced' : 'run terminalized',
5041
+ consumerMergeArtifacts.journal.wave && consumerMergeTransaction
5042
+ ? { dispatchTokens: consumerMergeTransaction.acceptedDispatchTokens } : {},
4518
5043
  );
4519
5044
  }
4520
5045
  }
@@ -4663,8 +5188,22 @@ export async function runBuild(featureCode, opts = {}) {
4663
5188
  continue;
4664
5189
  }
4665
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
+
4666
5203
  // ── Policy evaluation (ITEM-23) ────────────────────────────────────
4667
- const policy = evaluatePolicy(policySettings, stepId, {
5204
+ const policy = outputDecision
5205
+ ? { mode: outputDecision.outcome ? 'skip' : 'gate', reason: outputDecision.rationale ?? outputDecision.reason }
5206
+ : evaluatePolicy(policySettings, stepId, {
4668
5207
  fromPhase: synthFromPhase,
4669
5208
  // Gate policy is keyed by the gate step id (evaluatePolicy falls back
4670
5209
  // to stepId when toPhase is absent); the synthesized approval target
@@ -4673,17 +5212,19 @@ export async function runBuild(featureCode, opts = {}) {
4673
5212
 
4674
5213
  if (policy.mode === 'skip') {
4675
5214
  // Silent pass-through — no gate record, no UI
4676
- const resolved = await resolveGateWithConsumerMerge('approve', policy.reason, 'system');
5215
+ const resolved = await resolveGateWithConsumerMerge(outputDecision?.outcome ?? 'approve', policy.reason, 'system');
4677
5216
  response = resolved.response;
4678
5217
  streamWriter.write({
4679
5218
  type: 'build_gate_resolved',
4680
- stepId, outcome: resolved.outcome, rationale: resolved.rationale, flowId, policyMode: 'skip',
5219
+ stepId, outcome: resolved.outcome, rationale: resolved.rationale, flowId, policyMode: outputDecision ? 'output' : 'skip',
4681
5220
  });
4682
5221
  // COMP-PLAN-SECTIONS T6: emit sections after plan_gate auto-approve
4683
5222
  if (resolved.outcome === 'approve') {
4684
5223
  maybeEmitSectionsAfterPlanGate(stepId, featureDir, { streamWriter, featureCode });
4685
5224
  }
4686
- 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 });
4687
5228
  syncStepHistory(dataDir, stepHistory);
4688
5229
 
4689
5230
  } else if (policy.mode === 'flag') {
@@ -4764,7 +5305,10 @@ export async function runBuild(featureCode, opts = {}) {
4764
5305
  rationale = result.rationale;
4765
5306
  }
4766
5307
 
5308
+ if (outputDecision) await reportWaveEvidence(context, 'gate_human', gateToken, { outcome, rationale, hold: outputDecision }, 'proposed');
4767
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');
4768
5312
  response = resolved.response;
4769
5313
  outcome = resolved.outcome;
4770
5314
  rationale = resolved.rationale;
@@ -5250,7 +5794,8 @@ export async function runBuild(featureCode, opts = {}) {
5250
5794
  }
5251
5795
  // Close stream writer with appropriate status (idempotent — signal handler may have already closed)
5252
5796
  if (streamWriter) {
5253
- streamWriter.close(buildStatus, buildCostTotals);
5797
+ if (suspended && !buildCancel.cancelled) streamWriter.pause();
5798
+ else streamWriter.close(buildStatus, buildCostTotals);
5254
5799
  }
5255
5800
  // §3.6: the teardown removes the listeners itself, AFTER its writes. Removing them here
5256
5801
  // while it is pending would send a second Ctrl-C to the default handler, killing the
@@ -5280,7 +5825,10 @@ export async function runBuild(featureCode, opts = {}) {
5280
5825
  } finally {
5281
5826
  try {
5282
5827
  if (!runtimeResourcesFinalized) {
5283
- if (streamWriter) streamWriter.close(buildStatus);
5828
+ if (streamWriter) {
5829
+ if (suspended && !buildCancel.cancelled) streamWriter.pause();
5830
+ else streamWriter.close(buildStatus);
5831
+ }
5284
5832
  if (signalHandler && !buildCancel.teardown) {
5285
5833
  process.removeListener('SIGINT', signalHandler.listeners.onSigint);
5286
5834
  process.removeListener('SIGTERM', signalHandler.listeners.onSigterm);
@@ -5496,6 +6044,18 @@ export function toPhaseResultOutput(shipResult) {
5496
6044
  * Returns a PhaseResult-shaped object.
5497
6045
  */
5498
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
+ }
5499
6059
  // COMP-FIX-HARD T4: bug mode stages docs/bugs/<code>/ instead of <featuresDir>/<code>/
5500
6060
  // COMP-MCP-MIGRATION-2: feature mode honors paths.features override.
5501
6061
  const featuresDir = loadFeaturesDir(cwd);
@@ -5802,6 +6362,25 @@ export async function executeShipStep(featureCode, agentCwd, cwd, context, descr
5802
6362
  if (filesChanged.length === 0 && sha) filesChanged = stagedFiles;
5803
6363
  context.recordFilesChanged?.(filesChanged, { authoritativeShip: true });
5804
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
+
5805
6384
  // COMP-COMPLETION-GATE slice 2: the ship step no longer completes the feature.
5806
6385
  //
5807
6386
  // It used to call recordCompletion here, catch ANY failure, and still return
@@ -5812,34 +6391,14 @@ export async function executeShipStep(featureCode, agentCwd, cwd, context, descr
5812
6391
  //
5813
6392
  // Ship now collects evidence and stops. Exactly one completion happens, at
5814
6393
  // terminalization, through the gate, after health. See §2.3, §2.3c.
5815
- context.recordCompletionEvidence?.({
5816
- commitSha: sha,
5817
- filesChanged,
5818
- notes: shortDesc,
5819
- builtVia,
5820
- testsAttested,
5821
- });
6394
+ context.recordCompletionEvidence?.(completionEvidence);
5822
6395
 
5823
6396
  // COMP-PATHS-EXTERNAL D6a: if ROADMAP / the feature folder resolved into a
5824
6397
  // DIFFERENT git repo, they were written but not committed here — tell the
5825
6398
  // user to commit them there (v1 does not auto-commit other repos).
5826
6399
  noticeExternalArtifacts(cwd, featureCode, gitToplevel(agentCwd));
5827
6400
 
5828
- return {
5829
- phase: 'ship',
5830
- artifact: sha ?? '',
5831
- outcome: 'complete',
5832
- summary: sha
5833
- ? `Committed ${sha.slice(0, 8)}: ${commitMsg} (${stagedFiles.length} files)`
5834
- : `Committed: ${commitMsg} (${stagedFiles.length} files)`,
5835
- commit: sha,
5836
- filesChanged,
5837
- testsAttested,
5838
- // COMP-MODEL-AB: thread structured test counts into the step result so the
5839
- // main loop can persist them to build-history.jsonl for metrics consumers.
5840
- // Only present when testSummary.parsed=true (framework detected + output parsed).
5841
- ...(testSummary.parsed ? { test_count: testSummary.test_count, pass_rate: testSummary.pass_rate } : {}),
5842
- };
6401
+ return result;
5843
6402
 
5844
6403
  } catch (err) {
5845
6404
  return {