@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/README.md +14 -0
- package/bin/compose.js +24 -3
- package/lib/agent-string.js +9 -4
- package/lib/build-stream-writer.js +6 -0
- package/lib/build.js +643 -84
- package/lib/consumer-fanout.js +403 -16
- package/lib/experiment-pricing.js +5 -1
- package/lib/flow-state.js +38 -0
- package/lib/gsd.js +95 -48
- package/lib/model-pricing.js +4 -1
- package/lib/output-gate.js +81 -0
- package/lib/pipeline-profiles.js +200 -0
- package/lib/result-normalizer.js +13 -0
- package/lib/stratum-mcp-client.js +4 -4
- package/lib/team-flag.js +1 -1
- package/lib/wave-checkpoint.js +100 -0
- package/package.json +2 -2
- package/presets/team-fable-astra.profiles.json +18 -0
- package/presets/team-fable-astra.stratum.yaml +236 -0
- package/server/model-tiers.js +14 -6
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.
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
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?.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
3413
|
-
?? resolveStepProfile(
|
|
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(
|
|
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(
|
|
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(
|
|
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 (
|
|
4501
|
-
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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)
|
|
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 {
|