@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/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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
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;
|
package/lib/model-pricing.js
CHANGED
|
@@ -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)
|
|
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
|
+
}
|
package/lib/result-normalizer.js
CHANGED
|
@@ -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":
|
|
156
|
-
* ts/tests/mcp/contracts-grammar.test.ts.
|
|
157
|
-
export const REQUIRED_STRATUM_SURFACE =
|
|
158
|
-
export const REQUIRED_STRATUM_RANGE = '>=0.5.
|
|
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.
|