@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/flow-state.js CHANGED
@@ -35,3 +35,41 @@ export function readFlowRound(flowId) {
35
35
  return Number.isInteger(r) && r >= 0 ? r : 0;
36
36
  } catch { return 0; }
37
37
  }
38
+
39
+ /** Strict persisted snapshot: callers must hold on unreadable or mismatched evidence. */
40
+ export function readFlowSnapshot(flowId, { revisionDigest, gateStepId, gateToken } = {}) {
41
+ const refuse = message => { throw Object.assign(new Error(message), { code: 'WAVE_COST_UNVERIFIED' }); };
42
+ if (typeof flowId !== 'string' || !/^[\w-]+$/.test(flowId)) refuse('Invalid flow identity');
43
+ let state;
44
+ try {
45
+ const root = process.env.STRATUM_STATE_ROOT || join(homedir(), '.stratum', 'ts', 'flows');
46
+ state = JSON.parse(readFileSync(join(root, `${flowId}.json`), 'utf8'));
47
+ } catch (error) { refuse(`Cannot read persisted flow: ${error.message}`); }
48
+ if (state?.id !== flowId || !revisionDigest || state.revisionDigest !== revisionDigest) refuse('Flow revision/identity differs');
49
+ if (gateStepId && (state.steps?.[gateStepId]?.status !== 'waiting_gate'
50
+ || state.steps[gateStepId].gateToken !== gateToken)) refuse('Persisted gate token differs');
51
+ return state;
52
+ }
53
+
54
+ export function readFlowSpend(flowId, options, pending = []) {
55
+ const snapshot = readFlowSnapshot(flowId, options);
56
+ const fail = message => { throw Object.assign(new Error(message), { code: 'WAVE_COST_UNVERIFIED' }); };
57
+ if (pending.some(p => p.state !== 'acknowledged')) fail('Unacknowledged usage receipts');
58
+ if (!Array.isArray(snapshot.receipts)) fail('Missing receipt spine');
59
+ const ids = new Set();
60
+ let spent = 0;
61
+ for (const receipt of snapshot.receipts) {
62
+ if (!receipt.dispatchId || ids.has(receipt.dispatchId)) fail('Invalid/duplicate receipt identity');
63
+ ids.add(receipt.dispatchId);
64
+ const usd = receipt.amount?.usd;
65
+ if (receipt.detail?.costUnknown) fail('Model call has no attributed cost');
66
+ if (usd === undefined) {
67
+ if (receipt.amount?.tokens > 0 || receipt.amount?.ms > 0) fail('Paid call cost missing');
68
+ continue;
69
+ }
70
+ if (!Number.isFinite(usd) || usd < 0 || !['reported', 'estimated'].includes(receipt.usdSource)) fail('Unattributed USD');
71
+ spent += usd;
72
+ }
73
+ for (const p of pending) if (!ids.has(p.dispatchId)) fail('Acknowledged receipt absent from snapshot');
74
+ return { spent, input: snapshot.input };
75
+ }
package/lib/gsd.js CHANGED
@@ -26,7 +26,7 @@ import { validateBoundaryMap } from './boundary-map.js';
26
26
  import { enrichTaskGraph } from './gsd-decompose-enrich.js';
27
27
  import { buildTaskDescription } from './gsd-prompt.js';
28
28
  import { writeAll, validate as validateTaskResult, read as readBlackboard } from './gsd-blackboard.js';
29
- import { executeShipStep, toPhaseResultOutput, runConsumerIssuance, ConsumerStuckError, filesOwnedConflict, toEngineUsage, reportUsageReceipts } from './build.js';
29
+ import { executeShipStep, toPhaseResultOutput, runConsumerIssuance, ConsumerStuckError, filesOwnedConflict, toEngineUsage, reportUsageReceipts, loadPipelineProfiles, preflightPipelineProfiles, admitConsumerWave, publishConsumerCheckpoint, evaluateConfiguredGate, reportWaveEvidence, waveProfilesEnabled } from './build.js';
30
30
  import {
31
31
  ConsumerFanoutArtifacts,
32
32
  ConsumerMergeDecisionError,
@@ -144,7 +144,8 @@ export async function runGsd(featureCode, opts = {}) {
144
144
  const preMergeGate = resolvePreMergeGate(cwd, opts.preMergeGate);
145
145
 
146
146
  // 4. Load pipeline spec
147
- const specPath = join(PACKAGE_ROOT, 'pipelines', 'gsd.stratum.yaml');
147
+ const localPath = join(cwd, 'pipelines', 'gsd.stratum.yaml');
148
+ const specPath = existsSync(localPath) ? localPath : join(PACKAGE_ROOT, 'pipelines', 'gsd.stratum.yaml');
148
149
  // 4a. COMP-GSD-4: inject the stratum flow budget block from `gsd.budget.*`.
149
150
  // injectBudget is IDENTITY when nothing is configured, so an un-budgeted gsd
150
151
  // run (and plain `compose build`) is byte-identical.
@@ -152,6 +153,9 @@ export async function runGsd(featureCode, opts = {}) {
152
153
  const specYaml = injectBudget(readFileSync(specPath, 'utf-8'), budgetCfg);
153
154
  const localSpec = YAML.parse(specYaml);
154
155
  const localSpecDigest = sha256(JSON.stringify(localSpec));
156
+ const profileCheck = preflightPipelineProfiles(loadPipelineProfiles(specPath), localSpec, specPath);
157
+ const pipelineProfiles = profileCheck.normalized;
158
+ const profileDigest = waveProfilesEnabled(pipelineProfiles) ? profileCheck.profilesDigest : undefined;
155
159
 
156
160
  // 4a. COMP-GSD-4: cumulative cross-session ceiling pre-check (tokens/cost).
157
161
  // Refuse to start/resume a run that has already spent its lifetime budget —
@@ -235,7 +239,7 @@ export async function runGsd(featureCode, opts = {}) {
235
239
  stepCtx = {
236
240
  stratum, cwd, featureCode, blueprintText, gateCommands, preMergeGate,
237
241
  receiptsMode,
238
- localSpec, localSpecDigest,
242
+ localSpec, localSpecDigest, pipelineProfiles, profileDigest,
239
243
  filesChanged: [],
240
244
  stuckDetector,
241
245
  // D2(a): per-ITEM wall-clock ceiling from gsd.budget.per_task_ms, enforced
@@ -294,6 +298,11 @@ export async function runGsd(featureCode, opts = {}) {
294
298
  }, { workspaceRoot: cwd });
295
299
  const flowId = response.runId;
296
300
  stepCtx.flowId = flowId;
301
+ if (waveProfilesEnabled(pipelineProfiles)) {
302
+ stepCtx.consumerArtifacts = new ConsumerFanoutArtifacts({ runId: flowId, targetCwd: cwd,
303
+ revisionDigest: response.revisionDigest, specDigest: localSpecDigest, profilesDigest: profileDigest });
304
+ stepCtx.artifacts = stepCtx.consumerArtifacts;
305
+ }
297
306
  flushState(stepCtx, { flowId, phase: 'decompose' });
298
307
  emitPhaseOnce(stepCtx, 'decompose'); // COMP-GSD-7-EVENTLOG
299
308
 
@@ -303,11 +312,17 @@ export async function runGsd(featureCode, opts = {}) {
303
312
  response.status !== 'completed' &&
304
313
  response.status !== 'failed' &&
305
314
  response.status !== 'stuck' &&
315
+ response.status !== 'waiting_gate' &&
306
316
  response.status !== 'budget_exhausted'
307
317
  ) {
308
318
  response = await runOneStep(response, stepCtx);
309
319
  }
310
320
 
321
+ if (response.status === 'waiting_gate') {
322
+ flushState(stepCtx, { status: 'waiting_gate', gateToken: response.gateToken, reason: response.reason });
323
+ return response;
324
+ }
325
+
311
326
  if (response.status === 'stuck') {
312
327
  // Artifacts (stuck.md/json + pause.json) were written by runOneStep.
313
328
  // COMP-GSD-7-EVENTLOG: flush any completions that finished before the stuck
@@ -494,25 +509,35 @@ async function runOneStep(response, ctx) {
494
509
  targetCwd: cwd,
495
510
  revisionDigest: descriptor.revisionDigest,
496
511
  specDigest: localSpecDigest,
512
+ profilesDigest: ctx.profileDigest,
497
513
  });
498
514
  }
499
515
  ctx.consumerArtifacts.bindRunRevision({
500
516
  revisionDigest: descriptor.revisionDigest,
501
517
  specDigest: localSpecDigest,
518
+ profilesDigest: ctx.profileDigest,
502
519
  });
520
+ ctx.artifacts = ctx.consumerArtifacts;
521
+ // This audit already serves issuance recovery on legacy runs; admission
522
+ // uses it only when this step opts into wave policies.
523
+ const audit = await stratum.audit(flowId);
524
+ const configured = ctx.pipelineProfiles?.[descriptor.step]?.tier_from || ctx.pipelineProfiles?._consumer?.[descriptor.step];
525
+ const admission = configured ? await admitConsumerWave({ descriptor, descriptors: ready.filter(isConsumerDescriptor), audit,
526
+ localSpec, profiles: ctx.pipelineProfiles, artifacts: ctx.consumerArtifacts, stratum, flowId }) : null;
503
527
  // D7(a): render the exact TaskResult path from the item's task id (the
504
528
  // fanout `over` is decompose_gsd.output.tasks, indexed by itemIndex).
505
- const consumerItem = ctx.lastTaskGraph?.tasks?.[descriptor.itemIndex];
529
+ const consumerItem = descriptor.item;
506
530
  const taskResultPath = consumerItem?.id
507
531
  ? gsdTaskResultPath(featureCode, consumerItem.id)
508
532
  : undefined;
509
533
  try {
510
534
  return await runConsumerIssuance({
511
535
  descriptor,
536
+ admission,
512
537
  flowId,
513
538
  stratum,
514
539
  artifacts: ctx.consumerArtifacts,
515
- audit: await stratum.audit(flowId),
540
+ audit,
516
541
  localSpec,
517
542
  // D2(b): onUsage debits each item's agent usage into the cumulative
518
543
  // ledger. D2(a): per-item wall-clock ceiling from gsd.budget.per_task_ms.
@@ -523,12 +548,15 @@ async function runOneStep(response, ctx) {
523
548
  // by definition a GSD task, so timing.json + diffs/<id>.diff are written
524
549
  // for the milestone report. The build consumer path omits it (no marker),
525
550
  // so build-mode fanout stays byte-identical.
526
- context: { cwd, projectCwd: cwd, featureCode, flowId, receiptsMode: ctx.receiptsMode, gsd: true, gsdTaskId: consumerItem?.id, filesChanged: ctx.filesChanged, onUsage: (usage, meta) => recordTsAgentUsage(ctx, usage, meta), taskResultPath },
551
+ context: { pipelineProfiles: ctx.pipelineProfiles, artifacts: ctx.consumerArtifacts, cwd, projectCwd: cwd, featureCode, flowId, receiptsMode: ctx.receiptsMode, gsd: true, gsdTaskId: consumerItem?.id, filesChanged: ctx.filesChanged, onUsage: (usage, meta) => recordTsAgentUsage(ctx, usage, meta), taskResultPath },
527
552
  // runAndNormalize narrates via progress.debug/warn/info/toolUse — gsd
528
553
  // has no cockpit, so a COMPLETE no-op (not just stepStart/stepDone) is
529
554
  // required or the item throws "progress.debug is not a function".
530
555
  progress: NOOP_PROGRESS,
531
- streamWriter: { write() {} },
556
+ streamWriter: { write(event) {
557
+ if (event.type === 'step_model') appendGsdEvent(cwd, featureCode, 'step_model', event);
558
+ } },
559
+ profile: ctx.pipelineProfiles?.[descriptor.step]?.default ?? ctx.pipelineProfiles?.[descriptor.step],
532
560
  perItemTimeoutMs: ctx.perItemTimeoutMs,
533
561
  stuckDetector: ctx.stuckDetector,
534
562
  });
@@ -555,7 +583,7 @@ async function runOneStep(response, ctx) {
555
583
  featureCode,
556
584
  cwd,
557
585
  cwd,
558
- { cwd, featureCode, mode: 'feature', filesChanged: ctx.filesChanged ?? [] },
586
+ { ...ctx, cwd, featureCode, mode: 'feature', artifacts: ctx.consumerArtifacts, filesChanged: ctx.filesChanged ?? [] },
559
587
  '',
560
588
  null,
561
589
  );
@@ -663,46 +691,65 @@ async function runOneStep(response, ctx) {
663
691
  const gateStep = steps.find((step) => step.id === gateStepId);
664
692
  const predecessorIds = new Set(gateStep?.after ?? []);
665
693
  const fanoutStep = steps.find((step) => predecessorIds.has(step.id) && step.fanout?.dispatch === 'consumer');
666
- if (!fanoutStep) {
667
- throw new Error(`runGsd: unexpected non-consumer gate ${gateStepId} on the TS path`);
668
- }
669
- if (!ctx.consumerArtifacts) {
670
- ctx.consumerArtifacts = new ConsumerFanoutArtifacts({
671
- runId: flowId,
672
- targetCwd: cwd,
673
- revisionDigest: response.revisionDigest,
674
- specDigest: localSpecDigest,
675
- });
676
- }
677
- const artifacts = ctx.consumerArtifacts;
678
- artifacts.recordGateBinding({ gateStepId, fanoutStepId: fanoutStep.id });
679
- let transaction;
680
- let outcome = 'approve';
681
- let rationale = 'consumer fanout artifacts merged';
682
- try {
683
- transaction = artifacts.prepareMerge({
684
- gateStepId,
685
- gateToken: gateState.gateToken,
686
- fanoutStepId: fanoutStep.id,
687
- audit,
688
- });
689
- await artifacts.applyMerge(transaction);
690
- } catch (error) {
691
- if (!(error instanceof ConsumerMergeDecisionError)) throw error;
692
- outcome = gateStep?.gate?.on_revise ? 'revise' : 'kill';
693
- rationale = `${error.code}: ${error.message}`;
694
- transaction ??= artifacts.journal.mergeTransactions.find(
695
- (entry) => entry.gateToken === gateState.gateToken,
696
- );
697
- if (transaction) artifacts.restoreMergeBaseline(transaction, audit);
698
- }
699
- const next = await stratum.gateResolve(
700
- flowId, gateStepId, outcome, rationale, 'system', gateState.gateToken,
701
- );
702
- artifacts.markGateResolved(transaction, outcome);
703
- if (outcome !== 'revise') artifacts.cleanupWorktrees(`GSD merge gate ${outcome}`);
704
- if (outcome === 'approve') ctx.filesChanged = collectChangedFiles(cwd);
705
- return next;
694
+ const outputDecision = ctx.artifacts ? await evaluateConfiguredGate(ctx, { localSpec, gateStepId,
695
+ gateToken: gateState.gateToken, audit }) : null;
696
+ if (outputDecision && !outputDecision.outcome) return { status: 'waiting_gate', flowId,
697
+ gateToken: gateState.gateToken, reason: outputDecision.reason };
698
+ const resolveGateWithConsumerMerge = async (requestedOutcome, requestedRationale) => {
699
+ if (!fanoutStep && !outputDecision) throw new Error(`runGsd: unexpected non-consumer gate ${gateStepId} on the TS path`);
700
+ if (fanoutStep && !ctx.consumerArtifacts) {
701
+ ctx.consumerArtifacts = new ConsumerFanoutArtifacts({ runId: flowId, targetCwd: cwd,
702
+ revisionDigest: response.revisionDigest, specDigest: localSpecDigest, profilesDigest: ctx.profileDigest });
703
+ }
704
+ const artifacts = fanoutStep ? ctx.consumerArtifacts : null;
705
+ if (artifacts) ctx.artifacts = artifacts;
706
+ if (ctx.pipelineProfiles?._consumer?.[fanoutStep?.id]?.checkpoint_gate === gateStepId) {
707
+ artifacts.initializeWave({ ref: `refs/heads/compose/wave/${flowId}`, profilesDigest: ctx.profileDigest });
708
+ }
709
+ let transaction;
710
+ let outcome = requestedOutcome;
711
+ let rationale = requestedRationale;
712
+ if (artifacts) {
713
+ artifacts.recordGateBinding({ gateStepId, fanoutStepId: fanoutStep.id });
714
+ try {
715
+ transaction = artifacts.prepareMerge({ gateStepId, gateToken: gateState.gateToken, fanoutStepId: fanoutStep.id, audit });
716
+ if (outcome === 'approve') await artifacts.applyMerge(transaction);
717
+ else artifacts.restoreMergeBaseline(transaction, audit);
718
+ } catch (error) {
719
+ if (!(error instanceof ConsumerMergeDecisionError)) throw error;
720
+ outcome = gateStep?.gate?.on_revise ? 'revise' : 'kill';
721
+ rationale = `${error.code}: ${error.message}`;
722
+ transaction ??= artifacts.journal.mergeTransactions.find(e => e.gateToken === gateState.gateToken);
723
+ if (transaction) artifacts.restoreMergeBaseline(transaction, audit);
724
+ }
725
+ }
726
+ let next;
727
+ try {
728
+ next = await stratum.gateResolve(flowId, gateStepId, outcome, rationale, 'system', gateState.gateToken);
729
+ } catch (error) {
730
+ const current = await stratum.audit(flowId);
731
+ const ordinal = (audit.events ?? []).filter(e => e.type === 'gate_resolved' && e.stepId === gateStepId).length;
732
+ const confirmed = (current.events ?? []).filter(e => e.type === 'gate_resolved' && e.stepId === gateStepId)[ordinal]?.detail?.decision;
733
+ if (!confirmed) throw error;
734
+ outcome = confirmed;
735
+ if (artifacts && outcome === 'approve') {
736
+ artifacts.markGateResolved(transaction, outcome);
737
+ await publishConsumerCheckpoint(ctx, transaction);
738
+ }
739
+ next = await stratum.resume(flowId);
740
+ }
741
+ if (artifacts) {
742
+ artifacts.markGateResolved(transaction, outcome);
743
+ if (outcome === 'approve') await publishConsumerCheckpoint(ctx, transaction);
744
+ if (outcome !== 'revise') artifacts.cleanupWorktrees(`GSD merge gate ${outcome}`,
745
+ artifacts.journal.wave ? { dispatchTokens: transaction.acceptedDispatchTokens } : {});
746
+ if (outcome === 'approve' && !artifacts.journal.wave) ctx.filesChanged = collectChangedFiles(cwd);
747
+ }
748
+ if (outputDecision) await reportWaveEvidence(ctx, 'gate_decision', gateState.gateToken,
749
+ { ...outputDecision, outcome, rationale }, 'accepted');
750
+ return next;
751
+ };
752
+ return resolveGateWithConsumerMerge(outputDecision?.outcome ?? 'approve', outputDecision?.rationale ?? 'consumer fanout artifacts merged');
706
753
  }
707
754
 
708
755
  return response;
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * model-pricing.js — Token cost lookup and USD calculation.
3
3
  *
4
- * Prices are per-million tokens (MTok) as of 2025.
4
+ * Prices are per-million tokens (MTok) updated for COMP-FABLE-ASTRA slice 1 (2026-09).
5
5
  * Input price includes standard prompt tokens.
6
6
  * Cache write tokens (cache_creation_input_tokens) are billed at 1.25x input rate.
7
7
  * Cache read tokens (cache_read_input_tokens) are billed at 0.1x input rate.
@@ -12,6 +12,9 @@
12
12
  * Keys are matched by prefix so 'claude-sonnet-4-6' matches 'claude-sonnet-4-6-20250514' etc.
13
13
  */
14
14
  export const MODEL_PRICING = {
15
+ 'claude-fable-5-1': { inputPerMTok: 10, outputPerMTok: 50 },
16
+ 'claude-opus-5': { inputPerMTok: 5, outputPerMTok: 25 },
17
+ 'claude-sonnet-5': { inputPerMTok: 2, outputPerMTok: 10 },
15
18
  'claude-opus-4-7': { inputPerMTok: 5, outputPerMTok: 25 },
16
19
  'claude-opus-4-6': { inputPerMTok: 5, outputPerMTok: 25 },
17
20
  'claude-sonnet-4-6': { inputPerMTok: 3, outputPerMTok: 15 },
@@ -0,0 +1,81 @@
1
+ /** Pure output-gate decision. Callers supply current recorded step states. */
2
+ import { ownField, normalizeOwnedPath, validateGateConfig, validateWaveAdmission, profilesDigest } from './pipeline-profiles.js';
3
+
4
+ const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
5
+ const strings = value => Array.isArray(value) && value.every(v => typeof v === 'string');
6
+ function validateDecision(decision, review, validator, { executeProfile, executeProvider }) {
7
+ const findings = [];
8
+ const add = (code, message) => findings.push({ code, message });
9
+ const tasks = ownField(decision, validator.tasks_field ?? 'tasks');
10
+ const findingShape = f => object(f) && typeof f.severity === 'string' && strings(f.files)
11
+ && typeof f.claim === 'string' && typeof f.evidence === 'string';
12
+ const taskShape = t => object(t) && typeof t.id === 'string' && t.id.length > 0
13
+ && typeof t.description === 'string' && strings(t.files_owned) && strings(t.files_read)
14
+ && strings(t.depends_on) && (t.tier_rationale === undefined || typeof t.tier_rationale === 'string');
15
+ if (!object(decision) || !['repair', 'implement', 'complete', 'blocked'].includes(decision.action)
16
+ || typeof decision.rationale !== 'string' || typeof decision.blocking !== 'boolean'
17
+ || !Number.isInteger(decision.open_count) || decision.open_count < 0
18
+ || !Array.isArray(decision.open_findings) || !decision.open_findings.every(findingShape)
19
+ || !Array.isArray(decision.addressed_findings) || !decision.addressed_findings.every(findingShape)
20
+ || !Array.isArray(tasks) || !tasks.every(taskShape)) {
21
+ add('WAVE_DECISION_SHAPE', 'Invalid WaveDecision/task/finding shape');
22
+ return findings;
23
+ }
24
+ if (decision.open_count !== decision.open_findings.length) add('WAVE_OPEN_COUNT_MISMATCH', 'open_count differs from open_findings length');
25
+ if (typeof review?.blocking !== 'boolean' || review.blocking !== decision.blocking) add('WAVE_BLOCKING_MISMATCH', 'blocking differs from recorded review');
26
+ if (decision.action === 'complete' && (decision.open_count !== 0 || decision.blocking)) add('WAVE_COMPLETE_WITH_OPEN_FINDINGS', 'Complete requires no open findings and no blocking');
27
+ if (decision.action === 'blocked' && decision.open_count === 0) add('WAVE_BLOCKED_WITHOUT_FINDINGS', 'Blocked requires open findings');
28
+ if (['repair', 'implement'].includes(decision.action)) {
29
+ if (tasks.length < 1 || tasks.length > 6) add('WAVE_REPAIR_EMPTY', 'Implementation/repair requires 1–6 tasks');
30
+ findings.push(...validateWaveAdmission(executeProfile, tasks,
31
+ { provider: executeProvider, ownership: true, independent: true }).findings);
32
+ }
33
+ if (decision.action === 'repair') {
34
+ try {
35
+ const files = new Set(decision.open_findings.flatMap(f => f.files.map(normalizeOwnedPath)));
36
+ for (const task of tasks) if (!task.files_owned.some(file => files.has(normalizeOwnedPath(file)))) {
37
+ add('WAVE_REPAIR_UNOWNED_FINDING', `Repair task ${task.id} owns no open finding file`);
38
+ }
39
+ } catch (error) { add('WAVE_DECISION_SHAPE', error.message); }
40
+ }
41
+ return findings;
42
+ }
43
+ /**
44
+ * Resolve only from current recorded source, waiting gate and configured review states.
45
+ * executeProfile (the configured execute entry) and executeProvider are required.
46
+ * reviewOutput is ignored; validators always use the configured review step's recorded output.
47
+ */
48
+ export function decideGateFromOutput(config, stepOutputs, { gateStepId, gateToken, reviewOutput, ceiling, executeProfile, executeProvider } = {}) {
49
+ const hold = (reason, extra = {}) => ({ outcome: null, reason, ...extra });
50
+ try { validateGateConfig(config); }
51
+ catch (error) { return hold('GATE_CONFIG_INVALID', { findings: [{ code: 'GATE_CONFIG_INVALID', message: error.message }] }); }
52
+ if (!executeProfile || typeof executeProvider !== 'string' || !executeProvider.trim()) return hold('GATE_CONFIG_INVALID');
53
+ if (ceiling !== undefined) {
54
+ if (!Number.isFinite(ceiling.spent) || ceiling.spent < 0 || !Number.isFinite(ceiling.ceiling) || ceiling.ceiling <= 0) return hold('COST_CEILING_INVALID');
55
+ if (ceiling.spent > ceiling.ceiling) return hold('COST_CEILING_BREACHED', { breach: { spent: ceiling.spent, ceiling: ceiling.ceiling } });
56
+ }
57
+ const sourceState = stepOutputs?.[config.decide_from.step];
58
+ if (!object(sourceState) || sourceState.status !== 'succeeded' || !object(sourceState.output)) return hold('GATE_SOURCE_MISSING');
59
+ const gate = stepOutputs?.[gateStepId];
60
+ if (!object(gate) || gate.status !== 'waiting_gate' || typeof gateToken !== 'string' || !gateToken
61
+ || gate.gateToken !== gateToken || !Number.isInteger(gate.epoch) || gate.epoch < 0
62
+ || sourceState.epoch !== gate.epoch) return hold('GATE_SOURCE_STALE');
63
+ const output = sourceState.output;
64
+ const action = ownField(output, config.decide_from.field);
65
+ const outcome = ['approve', 'revise', 'kill'].find(key => config.decide_from[key].includes(action));
66
+ if (!outcome) return hold('GATE_ACTION_UNKNOWN');
67
+ const findings = [];
68
+ for (const validator of config.validators ?? []) {
69
+ const reviewState = stepOutputs?.[validator.review_step];
70
+ const review = reviewState?.status === 'succeeded' ? reviewState.output : undefined;
71
+ if (reviewState && reviewState.epoch !== gate.epoch) return hold('GATE_SOURCE_STALE');
72
+ findings.push(...validateDecision(output, review, validator, { executeProfile, executeProvider }));
73
+ }
74
+ if (findings.length) return hold('GATE_VALIDATION_FAILED', { findings });
75
+ return {
76
+ outcome, rationale: output.rationale ?? `Mapped ${String(action)} to ${outcome}`,
77
+ source: { step: config.decide_from.step, field: config.decide_from.field, action, gateStepId, gateToken,
78
+ epoch: sourceState.epoch, acceptedDispatchToken: sourceState.acceptedDispatchToken,
79
+ outputDigest: profilesDigest(output), output: structuredClone(output) },
80
+ };
81
+ }
@@ -0,0 +1,200 @@
1
+ /** Compose-owned sidecar schema and whole-wave admission. No dispatch or I/O. */
2
+ import { createHash } from 'node:crypto';
3
+ import { posix } from 'node:path';
4
+ import YAML from 'yaml';
5
+ import { validateAgentString, resolveAgentConfig } from './agent-string.js';
6
+
7
+ export class PipelineProfileError extends Error {
8
+ constructor(code, message) { super(message); this.name = 'PipelineProfileError'; this.code = code; }
9
+ }
10
+ const fail = (message, code = 'PIPELINE_PROFILE_INVALID') => { throw new PipelineProfileError(code, message); };
11
+ const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
12
+ const keys = (value, allowed) => {
13
+ if (!object(value) || Object.keys(value).some(key => !allowed.includes(key))) fail(`Invalid configuration fields: ${JSON.stringify(value)}`);
14
+ };
15
+ export function ownField(value, path) {
16
+ if (typeof path !== 'string' || !/^[A-Za-z_][\w]*(\.[A-Za-z_][\w]*)*$/.test(path)
17
+ || path.split('.').some(key => ['__proto__', 'prototype', 'constructor'].includes(key))) fail('Invalid field path');
18
+ for (const key of path.split('.')) {
19
+ if (!object(value) || !Object.hasOwn(value, key)) return undefined;
20
+ value = value[key];
21
+ }
22
+ return value;
23
+ }
24
+ export function normalizeOwnedPath(path) {
25
+ if (typeof path !== 'string' || !path.length || /^[\\/]|^[A-Za-z]:/.test(path)
26
+ || /[\0*?\[\]{}]/.test(path)) fail('Ownership requires literal repository-relative file paths', 'WAVE_OWNERSHIP_INVALID');
27
+ const parts = path.replaceAll('\\', '/').split('/');
28
+ if (parts.includes('..') || parts.some(part => part.toLowerCase() === '.git')) fail(`Unsafe ownership path: ${path}`, 'WAVE_OWNERSHIP_INVALID');
29
+ const normalized = posix.normalize(parts.join('/'));
30
+ if (normalized === '.' || normalized.endsWith('/')) fail(`Not a file path: ${path}`, 'WAVE_OWNERSHIP_INVALID');
31
+ return normalized;
32
+ }
33
+ function agent(profile, provider) {
34
+ if (typeof profile !== 'string' || !profile.trim() || profile.split(':').length > 3) fail('Profile must be a non-empty agent string');
35
+ validateAgentString(profile);
36
+ const resolved = resolveAgentConfig(profile);
37
+ if (provider && resolved.provider !== provider) fail(`Profile provider ${resolved.provider} differs from stage ${provider}`);
38
+ return { ...resolved, profile };
39
+ }
40
+ export function validateGateConfig(entry) {
41
+ keys(entry, ['decide_from', 'validators']);
42
+ keys(entry.decide_from, ['step', 'field', 'approve', 'revise', 'kill']);
43
+ const mapping = entry.decide_from;
44
+ if (typeof mapping.step !== 'string' || !mapping.step) fail('decide_from.step is required');
45
+ ownField({}, mapping.field);
46
+ const seen = new Set();
47
+ for (const outcome of ['approve', 'revise', 'kill']) {
48
+ if (!Array.isArray(mapping[outcome])) fail(`${outcome} must be a value array`);
49
+ for (const value of mapping[outcome]) {
50
+ if (typeof value !== 'string' || !value || seen.has(value)) fail('Gate values must be nonempty, disjoint strings');
51
+ seen.add(value);
52
+ }
53
+ }
54
+ if (entry.validators !== undefined && !Array.isArray(entry.validators)) fail('validators must be an array');
55
+ for (const validator of entry.validators ?? []) {
56
+ keys(validator, ['name', 'review_step', 'tasks_field']);
57
+ if (validator.name !== 'WaveDecision' || typeof validator.review_step !== 'string' || !validator.review_step) fail('Unknown validator or missing review_step');
58
+ ownField({}, validator.tasks_field ?? 'tasks');
59
+ }
60
+ return entry;
61
+ }
62
+ function ancestor(steps, from, gate, visited = new Set()) {
63
+ if (visited.has(gate)) return false;
64
+ visited.add(gate);
65
+ return (steps.find(step => step.id === gate)?.after ?? []).some(id => id === from || ancestor(steps, from, id, visited));
66
+ }
67
+ export function normalizePipelineProfiles(raw, spec) {
68
+ if (!object(raw)) fail('Profiles must be an object');
69
+ const parsed = typeof spec === 'string' ? YAML.parse(spec) : spec;
70
+ const flows = Object.values(parsed?.flows ?? {}).filter(flow => Array.isArray(flow?.steps));
71
+ const steps = flows.flatMap(flow => flow.steps);
72
+ const normalized = structuredClone(raw);
73
+ for (const [id, entry] of Object.entries(raw)) {
74
+ if (id.startsWith('_')) continue;
75
+ const matches = steps.filter(step => step.id === id);
76
+ if (!matches.length) fail(`Step ${id} not found in spec`);
77
+ for (const step of matches) {
78
+ if (object(entry) && Object.hasOwn(entry, 'decide_from')) {
79
+ validateGateConfig(entry);
80
+ if (!step.gate || step.agent || step.fanout || id === 'review_gate') fail(`Step ${id} is not an available output gate`);
81
+ const flowSteps = flows.find(flow => flow.steps.includes(step)).steps;
82
+ for (const source of [entry.decide_from.step, ...(entry.validators ?? []).map(v => v.review_step)]) {
83
+ if (!ancestor(flowSteps, source, id)) fail(`Source ${source} must be an ancestor of ${id}`);
84
+ }
85
+ continue;
86
+ }
87
+ if (step.gate && !step.agent && !step.fanout) fail(`Gate ${id} requires decide_from`);
88
+ if (object(entry)) {
89
+ keys(entry, ['default', 'tier_from']);
90
+ if (entry.tier_from !== undefined && (entry.tier_from !== 'item.tier' || step.fanout?.dispatch !== 'consumer')) fail('tier_from requires a consumer fanout and item.tier');
91
+ } else if (typeof entry !== 'string') fail(`Invalid profile for ${id}`);
92
+ for (const stage of step.fanout?.steps ?? [step]) {
93
+ const resolved = agent(typeof entry === 'string' ? entry : entry.default, stage.agent ?? 'claude');
94
+ if (step.fanout?.dispatch === 'engine' && (resolved.tier || resolved.template)) fail('Engine dispatch cannot apply Compose templates or tiers');
95
+ if (entry.tier_from) for (const tier of ['critical', 'standard', 'fast']) resolveConsumerProfile(entry, { tier }, resolved.provider);
96
+ }
97
+ }
98
+ }
99
+ if (raw._consumer !== undefined) {
100
+ if (!object(raw._consumer)) fail('_consumer must be an object');
101
+ for (const [id, policy] of Object.entries(raw._consumer)) {
102
+ keys(policy, ['ownership', 'independent', 'checkpoint_gate']);
103
+ if (policy.ownership !== undefined && policy.ownership !== 'item.files_owned') fail('ownership must be item.files_owned');
104
+ if (policy.independent !== undefined && typeof policy.independent !== 'boolean') fail('independent must be boolean');
105
+ const matches = steps.filter(step => step.id === id);
106
+ if (!matches.length) fail(`Consumer ${id} not found`);
107
+ for (const step of matches) {
108
+ if (step.fanout?.dispatch !== 'consumer') fail(`${id} is not consumer-dispatched`);
109
+ if ((policy.ownership || policy.checkpoint_gate) && step.fanout.isolation !== 'worktree') fail('Ownership/checkpoints require worktree isolation');
110
+ if (policy.checkpoint_gate !== undefined) {
111
+ const flow = flows.find(flow => flow.steps.includes(step));
112
+ const gate = flow.steps.find(s => s.id === policy.checkpoint_gate);
113
+ if (!gate?.gate || gate.after?.length !== 1 || gate.after[0] !== id || gate.when) fail('checkpoint_gate must be the direct unconditional merge gate');
114
+ }
115
+ }
116
+ }
117
+ }
118
+ if (raw._costCeiling !== undefined) {
119
+ const c = raw._costCeiling;
120
+ keys(c, ['input', 'default', 'gates']);
121
+ if (typeof c.input !== 'string' || !/^[A-Za-z_]\w*$/.test(c.input)
122
+ || !Number.isFinite(c.default) || c.default <= 0 || !Array.isArray(c.gates) || !c.gates.length
123
+ || c.gates.some(id => !steps.some(s => s.id === id && s.gate))) fail('Invalid _costCeiling');
124
+ }
125
+ return normalized;
126
+ }
127
+ /** Replacing a default must never erase the per-item routing policy. Re-preflight the result. */
128
+ export function mergeRuntimeProfiles(normalized, runtime = {}) {
129
+ const result = structuredClone(normalized);
130
+ for (const [id, override] of Object.entries(runtime)) {
131
+ if (id.startsWith('_') || object(result[id]) && result[id].decide_from) fail(`Runtime override is not an agent profile: ${id}`);
132
+ const previous = result[id];
133
+ if (object(override)) {
134
+ keys(override, ['default', 'tier_from']);
135
+ if (override.tier_from !== undefined && override.tier_from !== previous?.tier_from) fail('Runtime overrides cannot change tier_from');
136
+ } else if (typeof override !== 'string') fail('Runtime override must be an agent profile');
137
+ result[id] = object(previous) ? { ...previous, default: typeof override === 'string' ? override : override.default } : structuredClone(override);
138
+ agent(typeof result[id] === 'string' ? result[id] : result[id].default);
139
+ }
140
+ return result;
141
+ }
142
+ export function resolveConsumerProfile(entry, item, provider) {
143
+ const resolved = agent(typeof entry === 'string' ? entry : entry?.default, provider);
144
+ if (!entry?.tier_from) return resolved;
145
+ if (entry.tier_from !== 'item.tier') fail('Unsupported tier_from');
146
+ const tier = ownField({ item }, entry.tier_from);
147
+ if (tier === undefined && !Object.hasOwn(item ?? {}, 'tier')) return resolved;
148
+ if (!['critical', 'standard', 'fast'].includes(tier)) fail(`Unknown item tier: ${String(tier)}`, 'WAVE_TIER_INVALID');
149
+ return agent(`${resolved.provider}:${resolved.template ?? ''}:${tier}`, provider);
150
+ }
151
+ export function validateWaveAdmission(entry, items, opts = {}) {
152
+ const findings = [];
153
+ const profiles = [];
154
+ const owners = new Map();
155
+ const add = (code, itemIndex, message) => findings.push({ code, itemIndex, message, severity: 'error' });
156
+ if (!Array.isArray(items)) return { ok: false, findings: [{ code: 'WAVE_INPUT_INVALID', message: 'Recorded wave input must be an array' }] };
157
+ items.forEach((item, itemIndex) => {
158
+ try { profiles.push(resolveConsumerProfile(entry, item, opts.provider)); }
159
+ catch (error) { add(error.code ?? 'WAVE_TIER_INVALID', itemIndex, error.message); }
160
+ if (opts.independent && (!Array.isArray(item?.depends_on) || item.depends_on.length)) add('WAVE_DEPENDENCIES_NOT_EMPTY', itemIndex, 'Independent tasks require empty depends_on');
161
+ if (opts.ownership || Object.hasOwn(item ?? {}, 'files_owned')) {
162
+ try {
163
+ if (!Array.isArray(item?.files_owned)) fail('files_owned is required', 'WAVE_OWNERSHIP_INVALID');
164
+ for (const file of item.files_owned.map(normalizeOwnedPath)) {
165
+ if (owners.has(file) && owners.get(file) !== itemIndex) add('WAVE_OWNERSHIP_CONFLICT', itemIndex, `Multiple tasks own ${file}`);
166
+ owners.set(file, itemIndex);
167
+ }
168
+ } catch (error) { add(error.code, itemIndex, error.message); }
169
+ }
170
+ });
171
+ return { ok: findings.length === 0, findings, profiles };
172
+ }
173
+ export function profilesDigest(normalized) {
174
+ const canonical = value => Array.isArray(value) ? value.map(canonical) : object(value)
175
+ ? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value;
176
+ return createHash('sha256').update(JSON.stringify(canonical(normalized))).digest('hex');
177
+ }
178
+ /** Dispatch 2 replaces the string-only wrapper with this, after resolving spec inputs. */
179
+ export function preflightPipelineProfiles(raw, spec, runtime = {}) {
180
+ const normalized = normalizePipelineProfiles(mergeRuntimeProfiles(normalizePipelineProfiles(raw, spec), runtime), spec);
181
+ const resolved = {};
182
+ const overrides = {};
183
+ for (const [id, entry] of Object.entries(normalized)) {
184
+ if (!id.startsWith('_') && !entry?.decide_from) {
185
+ resolved[id] = resolveConsumerProfile(entry, {});
186
+ if (entry.tier_from) overrides[id] = ['critical', 'standard', 'fast'].map(tier => resolveConsumerProfile(entry, { tier }));
187
+ }
188
+ }
189
+ const parsed = typeof spec === 'string' ? YAML.parse(spec) : spec;
190
+ for (const step of Object.values(parsed?.flows ?? {}).flatMap(flow => flow?.steps ?? [])) {
191
+ if (Object.hasOwn(normalized, step.id)) continue;
192
+ const stages = step.fanout?.steps ?? (step.agent ? [step] : []);
193
+ for (const [index, stage] of stages.entries()) {
194
+ const resolution = agent(stage.agent ?? 'claude');
195
+ if (step.fanout?.dispatch === 'engine' && (resolution.template || resolution.tier)) fail('Engine dispatch cannot apply Compose templates or tiers');
196
+ resolved[stages.length === 1 ? step.id : `${step.id}/${index}`] = resolution;
197
+ }
198
+ }
199
+ return { ok: true, normalized, resolved, profilesDigest: profilesDigest({ normalized, resolved, overrides }) };
200
+ }
@@ -696,6 +696,19 @@ export async function runAndNormalize(_connectorIgnored, prompt, stepDispatch, o
696
696
  if (!usageTotals.model && runResult.telemetry?.model) usageTotals.model = runResult.telemetry.model;
697
697
  }
698
698
 
699
+ // The connector's final result is authoritative for cost: when the streamed
700
+ // step_usage events carried no dollar value (stratum's codex event omits
701
+ // cost_usd when the turn reports none; older servers hardcoded 0) but the
702
+ // result reports one WITH provenance, adopt it instead of filing the call as
703
+ // free/estimated. Found by the COMP-FABLE-ASTRA wave golden: every codex
704
+ // worker reached the cost gate as costUnknown.
705
+ if (usageFromEvents && !(usageTotals.cost_usd > 0)
706
+ && typeof runUsage?.usd === 'number' && Number.isFinite(runUsage.usd) && runUsage.usd > 0
707
+ && ['reported', 'estimated'].includes(runResult?.usdSource)) {
708
+ usageTotals.cost_usd = runUsage.usd;
709
+ primaryUsdSource = runResult.usdSource;
710
+ }
711
+
699
712
  const usages = [];
700
713
  const primaryFromEvents = usageFromEvents || primaryUsdSource !== null;
701
714
  if (primaryFromEvents) {
@@ -152,10 +152,10 @@ export function resolveStepProfile(profiles, stepId) {
152
152
  }
153
153
 
154
154
  /** The stratum MCP surface version compose's request vocabulary requires.
155
- * Pinned in stratum at ts/contracts/mcp-surface.json ("surface": 19) and asserted by
156
- * ts/tests/mcp/contracts-grammar.test.ts. Bump this and the package floor together. */
157
- export const REQUIRED_STRATUM_SURFACE = 19;
158
- export const REQUIRED_STRATUM_RANGE = '>=0.5.0';
155
+ * Pinned in stratum at ts/contracts/mcp-surface.json ("surface": 20, receipt.detail)
156
+ * and asserted by ts/tests/mcp/contracts-grammar.test.ts. Package versions bump at release. */
157
+ export const REQUIRED_STRATUM_SURFACE = 20;
158
+ export const REQUIRED_STRATUM_RANGE = '>=0.5.2';
159
159
 
160
160
  /**
161
161
  * Build the TS `stratum_agent_run` request from an agent string + compose-side
package/lib/team-flag.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * side effects that prevent clean import in test files).
6
6
  */
7
7
 
8
- export const KNOWN_TEAMS = ['review', 'research', 'feature'];
8
+ export const KNOWN_TEAMS = ['review', 'research', 'feature', 'fable-astra'];
9
9
 
10
10
  /**
11
11
  * Parse and validate the --team flag from CLI args.