@dzhechkov/harness-core 0.3.146 → 0.3.148
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/.dz-manifest.json +66 -18
- package/README.md +2 -0
- package/dist/cost-ledger.d.ts +318 -0
- package/dist/cost-ledger.d.ts.map +1 -0
- package/dist/cost-ledger.js +871 -0
- package/dist/cost-ledger.js.map +1 -0
- package/dist/cost-scoring.d.ts +9 -0
- package/dist/cost-scoring.d.ts.map +1 -1
- package/dist/cost-scoring.js +18 -0
- package/dist/cost-scoring.js.map +1 -1
- package/dist/feature-adr-checkpoints.d.ts +113 -0
- package/dist/feature-adr-checkpoints.d.ts.map +1 -0
- package/dist/feature-adr-checkpoints.js +184 -0
- package/dist/feature-adr-checkpoints.js.map +1 -0
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/usage.d.ts +32 -0
- package/dist/usage.d.ts.map +1 -1
- package/dist/usage.js +59 -10
- package/dist/usage.js.map +1 -1
- package/package.json +4 -4
- package/sbom.json +137 -17
- package/src/cost-ledger.ts +1105 -0
- package/src/cost-scoring.ts +17 -0
- package/src/feature-adr-checkpoints.ts +221 -0
- package/src/index.ts +55 -0
- package/src/usage.ts +74 -12
|
@@ -0,0 +1,871 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-stage cost ledger with a reconciliation invariant for feature-adr runs
|
|
3
|
+
* (feature `cost-ledger`, ADR-001/ADR-002/ADR-003).
|
|
4
|
+
*
|
|
5
|
+
* A feature-adr run reports ONE number. The recorded run `wf_0576bd7d-797` has
|
|
6
|
+
* `totalTokens: 623290` — the "623k subagent tokens" figure in project memory. That number cannot
|
|
7
|
+
* be attributed to a stage, so "where the budget burns" is a feeling. feature-adr ALREADY labels
|
|
8
|
+
* every stage via `stageLabel()` and the harness ALREADY persists those labels next to per-agent
|
|
9
|
+
* transcripts; nothing joined labels to spend. This module is that join.
|
|
10
|
+
*
|
|
11
|
+
* ## What this is
|
|
12
|
+
*
|
|
13
|
+
* A POST-HOC DERIVER (ADR-001). It reads what is already on disk —
|
|
14
|
+
* `<session>/workflows/wf_<runId>.json` for the stage labels and
|
|
15
|
+
* `<session>/subagents/workflows/<runId>/agent-<agentId>.jsonl` for the spend — and never edits
|
|
16
|
+
* `.claude/workflows/feature-adr.js`. A killed run is still derivable, which a stage-boundary
|
|
17
|
+
* writer could not manage; 5 of 29 recorded runs on this machine are killed.
|
|
18
|
+
*
|
|
19
|
+
* ## The invariant (ADR-002 — the load-bearing half)
|
|
20
|
+
*
|
|
21
|
+
* The obvious run total, the record's own `totalTokens`, is EXACTLY `Σ workflowProgress[].tokens`
|
|
22
|
+
* in 29 of 29 recorded runs. Reconciling against it can never fail: a vacuous gate that would print
|
|
23
|
+
* BALANCED forever and be believed. So the right-hand side comes from the run's transcript
|
|
24
|
+
* DIRECTORY LISTING — a source independent of the record — and both sides run the SAME estimator
|
|
25
|
+
* (`weightedTokensOf`, shared with `dz usage`):
|
|
26
|
+
*
|
|
27
|
+
* ```
|
|
28
|
+
* accountedTokens + unaccountedTokens === runTotalTokens
|
|
29
|
+
* accountedTokens + doubleAttributedTokens === stageTokensSum
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* Raw integer equality, no epsilon: rounding happens exactly once, per sample, at extraction.
|
|
33
|
+
* {@link verifyCostLedgerReport} re-derives both identities from the emitted report — the writer
|
|
34
|
+
* clamps, the verifier enforces raw equality (the `event-chain.ts` house pattern). A mismatch is a
|
|
35
|
+
* NAMED defect from {@link COST_LEDGER_DEFECT_KINDS}, never a rounding remainder.
|
|
36
|
+
*
|
|
37
|
+
* ## What this is NOT — read {@link COST_LEDGER_SCOPE} before describing it to anyone
|
|
38
|
+
*
|
|
39
|
+
* The totals are LOCAL TRANSCRIPT ESTIMATES. No billing API is consulted. The invariant therefore
|
|
40
|
+
* catches ATTRIBUTION errors — a double-counted stage, a stage missing from the ledger — and says
|
|
41
|
+
* NOTHING about whether the prices are right. The USD column is a secondary figure derived from a
|
|
42
|
+
* static table that has no `claude-fable` entry, so it falls back to sonnet-class pricing for the
|
|
43
|
+
* default model of every recorded run; the fallback is REPORTED, per ADR-003, not hidden.
|
|
44
|
+
*
|
|
45
|
+
* The ADR-158 reference implementation this feature is grounded in quotes a ~50.5% figure. That
|
|
46
|
+
* number is SYNTHETIC, belongs to their document, and is never a measurement of this repo.
|
|
47
|
+
*
|
|
48
|
+
* @packageDocumentation
|
|
49
|
+
*/
|
|
50
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
51
|
+
import { dirname, join } from 'node:path';
|
|
52
|
+
import { hasKnownPricing, usageCost } from './cost-scoring.js';
|
|
53
|
+
import { claudeProjectsRoot, rawTokenMixOf, weightedTokensOf } from './usage.js';
|
|
54
|
+
// ── Scope + vocabulary ──────────────────────────────────────
|
|
55
|
+
/** The one sentence that states what the ledger is and is not. Printed by EVERY surface (ADR-003). */
|
|
56
|
+
export const COST_LEDGER_SCOPE = 'local transcript ESTIMATES, not billed amounts — the reconciliation invariant catches ATTRIBUTION ' +
|
|
57
|
+
'errors (double-counted or missing stages), NOT pricing errors';
|
|
58
|
+
/**
|
|
59
|
+
* The defect vocabulary, as data. Deliberately absent: any name implying these are BILLED amounts —
|
|
60
|
+
* that name would assert exactly the promise {@link COST_LEDGER_SCOPE} refuses. A test pins this
|
|
61
|
+
* list so the vocabulary cannot quietly grow such a name.
|
|
62
|
+
*/
|
|
63
|
+
export const COST_LEDGER_DEFECT_KINDS = [
|
|
64
|
+
'Unaccounted',
|
|
65
|
+
'DoubleAttributed',
|
|
66
|
+
'ForeignSample',
|
|
67
|
+
'MissingStageTranscript',
|
|
68
|
+
'MalformedRecord',
|
|
69
|
+
'TruncatedListing',
|
|
70
|
+
];
|
|
71
|
+
export const COST_LEDGER_VERDICTS = [
|
|
72
|
+
'BALANCED',
|
|
73
|
+
'DEFECT',
|
|
74
|
+
'INSUFFICIENT_DATA',
|
|
75
|
+
];
|
|
76
|
+
/**
|
|
77
|
+
* Default reconciliation tolerance, as a FRACTION of the run total. Zero, because the arithmetic is
|
|
78
|
+
* exact integer — there is no rounding remainder for a tolerance to absorb, so any remainder is a
|
|
79
|
+
* defect. A caller may raise it to tolerate small orphans; its value is always printed.
|
|
80
|
+
*/
|
|
81
|
+
export const DEFAULT_COST_LEDGER_EPSILON = 0;
|
|
82
|
+
/** Guard against a pathological run directory degrading into a hang. */
|
|
83
|
+
const MAX_RUN_TRANSCRIPT_FILES = 2_000;
|
|
84
|
+
/** `--run` / `--slug` become path segments; only these shapes are ever joined onto a root. */
|
|
85
|
+
const RUN_ID_PATTERN = /^[A-Za-z0-9_.-]{1,128}$/;
|
|
86
|
+
const SLUG_PATTERN = /^[A-Za-z0-9_.-]{1,128}$/;
|
|
87
|
+
// ── Small clamped helpers ───────────────────────────────────
|
|
88
|
+
function finiteNonNegative(v) {
|
|
89
|
+
return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : null;
|
|
90
|
+
}
|
|
91
|
+
function nonEmptyString(v) {
|
|
92
|
+
return typeof v === 'string' && v.length > 0 ? v : null;
|
|
93
|
+
}
|
|
94
|
+
function isRecord(v) {
|
|
95
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
96
|
+
}
|
|
97
|
+
function isoOrNull(ms) {
|
|
98
|
+
if (ms === null || !Number.isFinite(ms) || Math.abs(ms) > 8.64e15)
|
|
99
|
+
return null;
|
|
100
|
+
try {
|
|
101
|
+
return new Date(ms).toISOString();
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// ── PURE: transcript sample extraction ──────────────────────
|
|
108
|
+
/**
|
|
109
|
+
* Extract deduped, weighted usage samples from ONE transcript's text. Pure and never-throw — a
|
|
110
|
+
* corrupt line is skipped, exactly as `computeUsage` does.
|
|
111
|
+
*
|
|
112
|
+
* `weighted` is `Math.round(weightedTokensOf(...))`: the SINGLE rounding point of the feature, so
|
|
113
|
+
* every sum downstream is exact integer arithmetic and the reconciliation identity is raw equality
|
|
114
|
+
* rather than a float comparison (ADR-002).
|
|
115
|
+
*/
|
|
116
|
+
export function extractCostSamples(text) {
|
|
117
|
+
const out = [];
|
|
118
|
+
if (typeof text !== 'string' || text.length === 0)
|
|
119
|
+
return out;
|
|
120
|
+
const seen = new Set();
|
|
121
|
+
for (const line of text.split('\n')) {
|
|
122
|
+
if (line.length === 0)
|
|
123
|
+
continue;
|
|
124
|
+
if (line.indexOf('usage') === -1)
|
|
125
|
+
continue; // cheap pre-filter before the parse
|
|
126
|
+
let rec;
|
|
127
|
+
try {
|
|
128
|
+
rec = JSON.parse(line);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
continue; // corrupt line — skip, never throw
|
|
132
|
+
}
|
|
133
|
+
if (!isRecord(rec))
|
|
134
|
+
continue;
|
|
135
|
+
const message = isRecord(rec['message']) ? rec['message'] : {};
|
|
136
|
+
const usage = isRecord(message['usage']) ? message['usage'] : null;
|
|
137
|
+
if (usage === null)
|
|
138
|
+
continue;
|
|
139
|
+
const weighted = Math.round(weightedTokensOf(usage));
|
|
140
|
+
if (!Number.isFinite(weighted) || weighted <= 0)
|
|
141
|
+
continue;
|
|
142
|
+
const mix = rawTokenMixOf(usage);
|
|
143
|
+
const tsRaw = rec['timestamp'];
|
|
144
|
+
let ts = null;
|
|
145
|
+
if (typeof tsRaw === 'number' && Number.isFinite(tsRaw))
|
|
146
|
+
ts = tsRaw;
|
|
147
|
+
else if (typeof tsRaw === 'string') {
|
|
148
|
+
const parsed = Date.parse(tsRaw);
|
|
149
|
+
ts = Number.isFinite(parsed) ? parsed : null;
|
|
150
|
+
}
|
|
151
|
+
const model = nonEmptyString(message['model']) ?? nonEmptyString(rec['model']);
|
|
152
|
+
const id = nonEmptyString(message['id']) ?? '';
|
|
153
|
+
const reqId = nonEmptyString(rec['requestId']) ?? '';
|
|
154
|
+
// With no ids, fall back to a CONTENT key including the raw vector + model: `{input:50}` and
|
|
155
|
+
// `{output:10}` both weigh 50, so a total-only key would silently merge distinct records.
|
|
156
|
+
// The WEIGHTED value is part of the anon key (Codex QE MED): two calls with identical raw
|
|
157
|
+
// totals but different cache-TTL classes weigh differently (125 vs 200) — a key blind to the
|
|
158
|
+
// weight would merge them and the ledger could stay BALANCED with a call missing.
|
|
159
|
+
const key = id !== '' || reqId !== ''
|
|
160
|
+
? id + ':' + reqId
|
|
161
|
+
: `anon:${String(ts)}:${mix.input}:${mix.cacheWrite}:${mix.cacheRead}:${mix.output}:${model ?? ''}:${weighted}`;
|
|
162
|
+
if (seen.has(key))
|
|
163
|
+
continue;
|
|
164
|
+
seen.add(key);
|
|
165
|
+
out.push({ key, ts, weighted, ...mix, model });
|
|
166
|
+
}
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
// ── PURE: run-record parsing ────────────────────────────────
|
|
170
|
+
/**
|
|
171
|
+
* Parse a `wf_<runId>.json` object into a {@link WorkflowRunRecord}. Pure and never-throw; every
|
|
172
|
+
* number is clamped and every unusable field is RECORDED in `malformed` rather than dropped, so it
|
|
173
|
+
* can surface as a `MalformedRecord` defect (the vocabulary refuses silent ignores).
|
|
174
|
+
*
|
|
175
|
+
* `args` is stored as a JSON STRING in the recorded runs on this machine and as an object in
|
|
176
|
+
* others; both shapes are accepted.
|
|
177
|
+
*/
|
|
178
|
+
export function parseWorkflowRunRecord(raw) {
|
|
179
|
+
if (!isRecord(raw))
|
|
180
|
+
return null;
|
|
181
|
+
const runId = nonEmptyString(raw['runId']);
|
|
182
|
+
if (runId === null)
|
|
183
|
+
return null;
|
|
184
|
+
const malformed = [];
|
|
185
|
+
let slug = null;
|
|
186
|
+
const args = raw['args'];
|
|
187
|
+
if (isRecord(args)) {
|
|
188
|
+
slug = nonEmptyString(args['slug']);
|
|
189
|
+
}
|
|
190
|
+
else if (typeof args === 'string' && args.length > 0) {
|
|
191
|
+
try {
|
|
192
|
+
const parsed = JSON.parse(args);
|
|
193
|
+
if (isRecord(parsed))
|
|
194
|
+
slug = nonEmptyString(parsed['slug']);
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
const m = /"slug"\s*:\s*"([^"]+)"/.exec(args);
|
|
198
|
+
slug = m ? (m[1] ?? null) : null;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const stages = [];
|
|
202
|
+
const progress = raw['workflowProgress'];
|
|
203
|
+
if (progress !== undefined && !Array.isArray(progress)) {
|
|
204
|
+
malformed.push('workflowProgress is not an array');
|
|
205
|
+
}
|
|
206
|
+
if (Array.isArray(progress)) {
|
|
207
|
+
for (let i = 0; i < progress.length; i += 1) {
|
|
208
|
+
const e = progress[i];
|
|
209
|
+
if (!isRecord(e) || e['type'] !== 'workflow_agent')
|
|
210
|
+
continue;
|
|
211
|
+
const label = nonEmptyString(e['label']);
|
|
212
|
+
const agentId = nonEmptyString(e['agentId']);
|
|
213
|
+
if (label === null || agentId === null) {
|
|
214
|
+
malformed.push(`workflowProgress[${i}]: workflow_agent without ${label === null ? 'label' : 'agentId'}`);
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
stages.push({
|
|
218
|
+
label,
|
|
219
|
+
agentId,
|
|
220
|
+
model: nonEmptyString(e['model']) ?? 'unknown',
|
|
221
|
+
phase: nonEmptyString(e['phaseTitle']),
|
|
222
|
+
startedAtMs: finiteNonNegative(e['startedAt']),
|
|
223
|
+
durationMs: finiteNonNegative(e['durationMs']),
|
|
224
|
+
state: nonEmptyString(e['state']),
|
|
225
|
+
recordTokens: finiteNonNegative(e['tokens']),
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return {
|
|
230
|
+
runId,
|
|
231
|
+
workflowName: nonEmptyString(raw['workflowName']),
|
|
232
|
+
slug,
|
|
233
|
+
status: nonEmptyString(raw['status']),
|
|
234
|
+
startedAtMs: finiteNonNegative(raw['startTime']),
|
|
235
|
+
durationMs: finiteNonNegative(raw['durationMs']),
|
|
236
|
+
recordTotalTokens: finiteNonNegative(raw['totalTokens']),
|
|
237
|
+
stages,
|
|
238
|
+
malformed,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Build the report and evaluate the invariant. PURE — no filesystem, no clock. Every number that
|
|
243
|
+
* enters is clamped here (the writer clamps; {@link verifyCostLedgerReport} enforces raw equality).
|
|
244
|
+
*/
|
|
245
|
+
export function buildCostLedger(input) {
|
|
246
|
+
const { record } = input;
|
|
247
|
+
const epsilonRaw = input.epsilon;
|
|
248
|
+
const epsilon = typeof epsilonRaw === 'number' && Number.isFinite(epsilonRaw) && epsilonRaw >= 0 && epsilonRaw <= 1
|
|
249
|
+
? epsilonRaw
|
|
250
|
+
: DEFAULT_COST_LEDGER_EPSILON;
|
|
251
|
+
const defects = [];
|
|
252
|
+
for (const m of record.malformed)
|
|
253
|
+
defects.push({ kind: 'MalformedRecord', detail: m });
|
|
254
|
+
// A capped listing means the right-hand side is PARTIAL — BALANCED must be impossible on it.
|
|
255
|
+
if (input.transcriptListingTruncated === true) {
|
|
256
|
+
defects.push({
|
|
257
|
+
kind: 'TruncatedListing',
|
|
258
|
+
detail: `transcript listing hit the ${MAX_RUN_TRANSCRIPT_FILES}-file cap — the run total is incomplete, no verdict may rest on it`,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
// Every sample number is CLAMPED here (Codex QE MED): the contract says the writer clamps, and a
|
|
262
|
+
// negative/non-finite `weighted` sliding through would make negative totals read BALANCED.
|
|
263
|
+
const clampSample = (s) => {
|
|
264
|
+
const n = (v) => (Number.isFinite(v) && v >= 0 ? Math.floor(v) : 0);
|
|
265
|
+
return { ...s, weighted: n(s.weighted), input: n(s.input), cacheWrite: n(s.cacheWrite), cacheRead: n(s.cacheRead), output: n(s.output) };
|
|
266
|
+
};
|
|
267
|
+
input = {
|
|
268
|
+
...input,
|
|
269
|
+
runSamples: input.runSamples.map(clampSample),
|
|
270
|
+
stageSamples: input.stageSamples.map((e) => ({ agentId: e.agentId, samples: e.samples.map(clampSample) })),
|
|
271
|
+
};
|
|
272
|
+
// RIGHT — the run's universe, deduped by sample key.
|
|
273
|
+
const universe = new Map();
|
|
274
|
+
for (const s of input.runSamples)
|
|
275
|
+
if (!universe.has(s.key))
|
|
276
|
+
universe.set(s.key, s);
|
|
277
|
+
let runTotalTokens = 0;
|
|
278
|
+
for (const s of universe.values())
|
|
279
|
+
runTotalTokens += s.weighted;
|
|
280
|
+
// LEFT — per stage, joined agentId → label. Several agents may share one label.
|
|
281
|
+
const byAgent = new Map();
|
|
282
|
+
for (const e of input.stageSamples)
|
|
283
|
+
if (!byAgent.has(e.agentId))
|
|
284
|
+
byAgent.set(e.agentId, e.samples);
|
|
285
|
+
const buckets = new Map();
|
|
286
|
+
const keyOwners = new Map();
|
|
287
|
+
const foreign = [];
|
|
288
|
+
const conflicting = [];
|
|
289
|
+
const missingTranscript = [];
|
|
290
|
+
let stageTokensSum = 0;
|
|
291
|
+
for (const stage of record.stages) {
|
|
292
|
+
const samples = byAgent.get(stage.agentId) ?? [];
|
|
293
|
+
if (samples.length === 0)
|
|
294
|
+
missingTranscript.push(`${stage.label} (${stage.agentId})`);
|
|
295
|
+
let b = buckets.get(stage.label);
|
|
296
|
+
if (b === undefined) {
|
|
297
|
+
b = {
|
|
298
|
+
stage: stage.label,
|
|
299
|
+
phase: stage.phase,
|
|
300
|
+
models: new Set(),
|
|
301
|
+
agentIds: [],
|
|
302
|
+
claims: [],
|
|
303
|
+
sum: 0,
|
|
304
|
+
startedAtMs: null,
|
|
305
|
+
endedAtMs: null,
|
|
306
|
+
costUsd: 0,
|
|
307
|
+
pricingKnown: true,
|
|
308
|
+
};
|
|
309
|
+
buckets.set(stage.label, b);
|
|
310
|
+
}
|
|
311
|
+
b.models.add(stage.model);
|
|
312
|
+
b.agentIds.push(stage.agentId);
|
|
313
|
+
if (stage.startedAtMs !== null) {
|
|
314
|
+
b.startedAtMs = b.startedAtMs === null ? stage.startedAtMs : Math.min(b.startedAtMs, stage.startedAtMs);
|
|
315
|
+
const end = stage.durationMs === null ? stage.startedAtMs : stage.startedAtMs + stage.durationMs;
|
|
316
|
+
b.endedAtMs = b.endedAtMs === null ? end : Math.max(b.endedAtMs, end);
|
|
317
|
+
}
|
|
318
|
+
if (!hasKnownPricing(stage.model))
|
|
319
|
+
b.pricingKnown = false;
|
|
320
|
+
// Price per AGENT, using that agent's own model, then aggregate — a `mixed` label must not be
|
|
321
|
+
// priced at one arbitrary model's rate.
|
|
322
|
+
let mix = { promptTokens: 0, cachedInputTokens: 0, cacheCreationTokens: 0, completionTokens: 0 };
|
|
323
|
+
for (const s of samples) {
|
|
324
|
+
if (!universe.has(s.key)) {
|
|
325
|
+
foreign.push(s.key);
|
|
326
|
+
continue; // NEVER add a sample outside the run's universe — it would break the identity
|
|
327
|
+
}
|
|
328
|
+
const canonical = universe.get(s.key);
|
|
329
|
+
if (canonical !== undefined && canonical.weighted !== s.weighted)
|
|
330
|
+
conflicting.push(s.key);
|
|
331
|
+
stageTokensSum += s.weighted;
|
|
332
|
+
b.sum += s.weighted;
|
|
333
|
+
b.claims.push(s);
|
|
334
|
+
let owners = keyOwners.get(s.key);
|
|
335
|
+
if (owners === undefined) {
|
|
336
|
+
owners = new Set();
|
|
337
|
+
keyOwners.set(s.key, owners);
|
|
338
|
+
}
|
|
339
|
+
owners.add(stage.label);
|
|
340
|
+
mix = {
|
|
341
|
+
promptTokens: mix.promptTokens + s.input,
|
|
342
|
+
cachedInputTokens: mix.cachedInputTokens + s.cacheRead,
|
|
343
|
+
cacheCreationTokens: mix.cacheCreationTokens + s.cacheWrite,
|
|
344
|
+
completionTokens: mix.completionTokens + s.output,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
const cost = usageCost(mix, stage.model);
|
|
348
|
+
b.costUsd += Number.isFinite(cost) && cost > 0 ? cost : 0;
|
|
349
|
+
}
|
|
350
|
+
// accountedTokens — the DEDUPED union of stage-claimed samples, so a double-claim inflates
|
|
351
|
+
// `stageTokensSum` without inflating this. That difference IS `doubleAttributedTokens`.
|
|
352
|
+
let accountedTokens = 0;
|
|
353
|
+
for (const key of keyOwners.keys()) {
|
|
354
|
+
const s = universe.get(key);
|
|
355
|
+
if (s !== undefined)
|
|
356
|
+
accountedTokens += s.weighted;
|
|
357
|
+
}
|
|
358
|
+
const unaccountedTokens = runTotalTokens - accountedTokens;
|
|
359
|
+
const doubleAttributedTokens = stageTokensSum - accountedTokens;
|
|
360
|
+
const rows = [];
|
|
361
|
+
for (const b of buckets.values()) {
|
|
362
|
+
let tokensIn = 0;
|
|
363
|
+
let tokensCacheWrite = 0;
|
|
364
|
+
let tokensCacheRead = 0;
|
|
365
|
+
let tokensOut = 0;
|
|
366
|
+
// Sum over CLAIMS, not over deduped keys. `weightedTokens` must equal this bucket's
|
|
367
|
+
// contribution to `stageTokensSum` (the verifier asserts Σ rows === stageTokensSum), so the raw
|
|
368
|
+
// columns and `calls` have to count the same way — otherwise a double-attributed run shows a
|
|
369
|
+
// weighted total its own in/out columns contradict.
|
|
370
|
+
for (const s of b.claims) {
|
|
371
|
+
tokensIn += s.input;
|
|
372
|
+
tokensCacheWrite += s.cacheWrite;
|
|
373
|
+
tokensCacheRead += s.cacheRead;
|
|
374
|
+
tokensOut += s.output;
|
|
375
|
+
}
|
|
376
|
+
const models = [...b.models].sort();
|
|
377
|
+
rows.push({
|
|
378
|
+
runId: record.runId,
|
|
379
|
+
slug: record.slug,
|
|
380
|
+
stage: b.stage,
|
|
381
|
+
phase: b.phase,
|
|
382
|
+
model: models.length === 1 ? (models[0] ?? 'unknown') : 'mixed',
|
|
383
|
+
agentIds: b.agentIds,
|
|
384
|
+
tokensIn,
|
|
385
|
+
tokensCacheWrite,
|
|
386
|
+
tokensCacheRead,
|
|
387
|
+
tokensOut,
|
|
388
|
+
weightedTokens: b.sum,
|
|
389
|
+
costUsd: b.costUsd,
|
|
390
|
+
pricingKnown: b.pricingKnown,
|
|
391
|
+
startedTs: isoOrNull(b.startedAtMs),
|
|
392
|
+
endedTs: isoOrNull(b.endedAtMs),
|
|
393
|
+
calls: b.claims.length,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
rows.sort((a, z) => z.weightedTokens - a.weightedTokens || a.stage.localeCompare(z.stage));
|
|
397
|
+
// ── named defects ──
|
|
398
|
+
const orphans = (input.orphanAgentIds ?? []).filter((x) => typeof x === 'string' && x.length > 0);
|
|
399
|
+
if (unaccountedTokens > Math.floor(epsilon * runTotalTokens)) {
|
|
400
|
+
defects.push({
|
|
401
|
+
kind: 'Unaccounted',
|
|
402
|
+
detail: orphans.length > 0
|
|
403
|
+
? `${orphans.length} agent transcript(s) in the run directory have no workflowProgress entry`
|
|
404
|
+
: 'run spend is attributed to no stage',
|
|
405
|
+
tokens: unaccountedTokens,
|
|
406
|
+
...(orphans.length > 0 ? { subjects: orphans } : {}),
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
const doubleClaimed = [...keyOwners.entries()].filter(([, owners]) => owners.size > 1);
|
|
410
|
+
if (doubleAttributedTokens > 0 || doubleClaimed.length > 0) {
|
|
411
|
+
const stagesInvolved = new Set();
|
|
412
|
+
for (const [, owners] of doubleClaimed)
|
|
413
|
+
for (const o of owners)
|
|
414
|
+
stagesInvolved.add(o);
|
|
415
|
+
defects.push({
|
|
416
|
+
kind: 'DoubleAttributed',
|
|
417
|
+
detail: `${doubleClaimed.length} usage sample(s) claimed by more than one stage`,
|
|
418
|
+
tokens: doubleAttributedTokens,
|
|
419
|
+
subjects: [...stagesInvolved].sort(),
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
if (foreign.length > 0) {
|
|
423
|
+
defects.push({
|
|
424
|
+
kind: 'ForeignSample',
|
|
425
|
+
detail: `${foreign.length} stage sample(s) absent from the run's transcript directory`,
|
|
426
|
+
subjects: foreign.slice(0, 10),
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
if (conflicting.length > 0) {
|
|
430
|
+
defects.push({
|
|
431
|
+
kind: 'MalformedRecord',
|
|
432
|
+
detail: `${conflicting.length} sample(s) extracted to different token values in two files — the extractor is not deterministic`,
|
|
433
|
+
subjects: conflicting.slice(0, 10),
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
if (missingTranscript.length > 0) {
|
|
437
|
+
defects.push({
|
|
438
|
+
kind: 'MissingStageTranscript',
|
|
439
|
+
detail: `${missingTranscript.length} stage(s) in the run record have no usage samples`,
|
|
440
|
+
subjects: missingTranscript,
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
const identityHolds = accountedTokens + unaccountedTokens === runTotalTokens &&
|
|
444
|
+
accountedTokens + doubleAttributedTokens === stageTokensSum;
|
|
445
|
+
if (!identityHolds) {
|
|
446
|
+
defects.push({
|
|
447
|
+
kind: 'MalformedRecord',
|
|
448
|
+
detail: `reconciliation identity broken: accounted ${accountedTokens} + unaccounted ${unaccountedTokens} ` +
|
|
449
|
+
`!= total ${runTotalTokens}, or + double ${doubleAttributedTokens} != stageSum ${stageTokensSum}`,
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
// INSUFFICIENT_DATA is NOT success (ADR-003): no samples means nothing was measured, and a
|
|
453
|
+
// "0 === 0, so it balances" shortcut would let an absent transcript store read as a clean run.
|
|
454
|
+
const verdict = runTotalTokens === 0 && stageTokensSum === 0
|
|
455
|
+
? 'INSUFFICIENT_DATA'
|
|
456
|
+
: defects.length > 0
|
|
457
|
+
? 'DEFECT'
|
|
458
|
+
: 'BALANCED';
|
|
459
|
+
let totalCostUsd = 0;
|
|
460
|
+
for (const r of rows)
|
|
461
|
+
totalCostUsd += r.costUsd;
|
|
462
|
+
const fallbackModels = [...new Set(record.stages.filter((s) => !hasKnownPricing(s.model)).map((s) => s.model))].sort();
|
|
463
|
+
return {
|
|
464
|
+
runId: record.runId,
|
|
465
|
+
slug: record.slug,
|
|
466
|
+
workflowName: record.workflowName,
|
|
467
|
+
status: record.status,
|
|
468
|
+
startedTs: isoOrNull(record.startedAtMs),
|
|
469
|
+
rows,
|
|
470
|
+
reconciliation: {
|
|
471
|
+
runTotalTokens,
|
|
472
|
+
accountedTokens,
|
|
473
|
+
stageTokensSum,
|
|
474
|
+
unaccountedTokens,
|
|
475
|
+
doubleAttributedTokens,
|
|
476
|
+
epsilon,
|
|
477
|
+
identityHolds,
|
|
478
|
+
verdict,
|
|
479
|
+
defects,
|
|
480
|
+
},
|
|
481
|
+
recordTotalTokens: record.recordTotalTokens,
|
|
482
|
+
totalCostUsd,
|
|
483
|
+
pricingFallbackModels: fallbackModels,
|
|
484
|
+
estimated: true,
|
|
485
|
+
scope: COST_LEDGER_SCOPE,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Re-derive both identities from an EMITTED report — the verifier half of the house pattern. It
|
|
490
|
+
* trusts nothing the builder computed except the numbers it printed, so a future writer bug shows
|
|
491
|
+
* up as a `MalformedRecord` finding instead of a plausible table.
|
|
492
|
+
*/
|
|
493
|
+
export function verifyCostLedgerReport(report) {
|
|
494
|
+
const out = [];
|
|
495
|
+
const r = report.reconciliation;
|
|
496
|
+
const nums = [r.runTotalTokens, r.accountedTokens, r.stageTokensSum, r.unaccountedTokens, r.doubleAttributedTokens];
|
|
497
|
+
if (nums.some((n) => !Number.isFinite(n))) {
|
|
498
|
+
out.push({ kind: 'MalformedRecord', detail: 'reconciliation carries a non-finite number' });
|
|
499
|
+
return out;
|
|
500
|
+
}
|
|
501
|
+
if (r.accountedTokens + r.unaccountedTokens !== r.runTotalTokens) {
|
|
502
|
+
out.push({
|
|
503
|
+
kind: 'MalformedRecord',
|
|
504
|
+
detail: `accounted ${r.accountedTokens} + unaccounted ${r.unaccountedTokens} !== runTotal ${r.runTotalTokens}`,
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
if (r.accountedTokens + r.doubleAttributedTokens !== r.stageTokensSum) {
|
|
508
|
+
out.push({
|
|
509
|
+
kind: 'MalformedRecord',
|
|
510
|
+
detail: `accounted ${r.accountedTokens} + double ${r.doubleAttributedTokens} !== stageSum ${r.stageTokensSum}`,
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
let rowSum = 0;
|
|
514
|
+
for (const row of report.rows)
|
|
515
|
+
rowSum += row.weightedTokens;
|
|
516
|
+
if (rowSum !== r.stageTokensSum) {
|
|
517
|
+
out.push({ kind: 'MalformedRecord', detail: `Σ rows ${rowSum} !== stageSum ${r.stageTokensSum}` });
|
|
518
|
+
}
|
|
519
|
+
if (!COST_LEDGER_VERDICTS.includes(r.verdict)) {
|
|
520
|
+
out.push({ kind: 'MalformedRecord', detail: `unknown verdict ${String(r.verdict)}` });
|
|
521
|
+
}
|
|
522
|
+
return out;
|
|
523
|
+
}
|
|
524
|
+
// ── PURE: FR-8 feed-forward reader ──────────────────────────
|
|
525
|
+
/**
|
|
526
|
+
* Aggregate per-stage cost across runs, for a future auto-cost router that today chooses models
|
|
527
|
+
* from a STATIC assumptions table.
|
|
528
|
+
*
|
|
529
|
+
* **WIRING INTO ROUTING IS OUT OF SCOPE for this feature** — this returns data and nothing consumes
|
|
530
|
+
* it yet. That is deliberate: an ESTIMATED number must not drive an expensive routing decision
|
|
531
|
+
* until it has been calibrated. Rows from runs whose verdict is not `BALANCED` are EXCLUDED, so a
|
|
532
|
+
* run with a known attribution defect can never quietly become a routing input.
|
|
533
|
+
*/
|
|
534
|
+
export function stageCostAggregates(reports) {
|
|
535
|
+
const acc = new Map();
|
|
536
|
+
for (const report of reports) {
|
|
537
|
+
if (report.reconciliation.verdict !== 'BALANCED')
|
|
538
|
+
continue;
|
|
539
|
+
for (const row of report.rows) {
|
|
540
|
+
// JSON-tuple key (Codex QE LOW): NUL is a LEGAL JSON-string character, so even a NUL join
|
|
541
|
+
// can collide when labels themselves contain NUL — the same delimiter-ambiguity class the
|
|
542
|
+
// guard-promotion digest fixed. Unambiguous serialization beats a cleverer separator.
|
|
543
|
+
const key = JSON.stringify([row.stage, row.model]);
|
|
544
|
+
let a = acc.get(key);
|
|
545
|
+
if (a === undefined) {
|
|
546
|
+
a = { stage: row.stage, model: row.model, total: 0, cost: 0, runs: new Set() };
|
|
547
|
+
acc.set(key, a);
|
|
548
|
+
}
|
|
549
|
+
a.total += row.weightedTokens;
|
|
550
|
+
a.cost += row.costUsd;
|
|
551
|
+
a.runs.add(row.runId);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
const out = [];
|
|
555
|
+
for (const a of acc.values()) {
|
|
556
|
+
const runs = a.runs.size;
|
|
557
|
+
out.push({
|
|
558
|
+
stage: a.stage,
|
|
559
|
+
model: a.model,
|
|
560
|
+
avgTokens: runs > 0 ? Math.round(a.total / runs) : 0,
|
|
561
|
+
runs,
|
|
562
|
+
totalTokens: a.total,
|
|
563
|
+
avgCostUsd: runs > 0 ? a.cost / runs : 0,
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
out.sort((x, y) => y.avgTokens - x.avgTokens || x.stage.localeCompare(y.stage));
|
|
567
|
+
return out;
|
|
568
|
+
}
|
|
569
|
+
// ── PURE: rendering + serialization ─────────────────────────
|
|
570
|
+
function fmt(n) {
|
|
571
|
+
if (!Number.isFinite(n))
|
|
572
|
+
return '?';
|
|
573
|
+
return Math.round(n).toLocaleString('en-US');
|
|
574
|
+
}
|
|
575
|
+
function usd(n) {
|
|
576
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
577
|
+
return '$0.00';
|
|
578
|
+
return '$' + n.toFixed(n < 1 ? 4 : 2);
|
|
579
|
+
}
|
|
580
|
+
function pad(s, width) {
|
|
581
|
+
return s.length >= width ? s : s + ' '.repeat(width - s.length);
|
|
582
|
+
}
|
|
583
|
+
function padLeft(s, width) {
|
|
584
|
+
return s.length >= width ? s : ' '.repeat(width - s.length) + s;
|
|
585
|
+
}
|
|
586
|
+
/** Human table + reconciliation line + verdict + the honest-scope note (ADR-003). */
|
|
587
|
+
export function renderCostLedger(report) {
|
|
588
|
+
const lines = [];
|
|
589
|
+
const head = [
|
|
590
|
+
`run ${report.runId}`,
|
|
591
|
+
report.slug !== null ? `slug ${report.slug}` : null,
|
|
592
|
+
report.workflowName !== null ? report.workflowName : null,
|
|
593
|
+
report.status !== null ? report.status : null,
|
|
594
|
+
report.startedTs !== null ? report.startedTs : null,
|
|
595
|
+
]
|
|
596
|
+
.filter((x) => x !== null)
|
|
597
|
+
.join(' · ');
|
|
598
|
+
lines.push(`usage --by-stage: ${head}`);
|
|
599
|
+
const r = report.reconciliation;
|
|
600
|
+
if (report.rows.length === 0) {
|
|
601
|
+
lines.push('usage --by-stage: no stage rows — nothing was measured for this run');
|
|
602
|
+
}
|
|
603
|
+
else {
|
|
604
|
+
const stageW = Math.max(5, ...report.rows.map((x) => x.stage.length));
|
|
605
|
+
const modelW = Math.max(5, ...report.rows.map((x) => x.model.length));
|
|
606
|
+
lines.push(` ${pad('stage', stageW)} ${pad('model', modelW)} ${padLeft('weighted', 12)} ${padLeft('in', 9)} ${padLeft('out', 9)} ${padLeft('calls', 5)} ${padLeft('~USD', 9)}`);
|
|
607
|
+
for (const row of report.rows) {
|
|
608
|
+
lines.push(` ${pad(row.stage, stageW)} ${pad(row.model, modelW)} ${padLeft(fmt(row.weightedTokens), 12)} ${padLeft(fmt(row.tokensIn), 9)} ${padLeft(fmt(row.tokensOut), 9)} ${padLeft(String(row.calls), 5)} ${padLeft(usd(row.costUsd) + (row.pricingKnown ? '' : '*'), 9)}`);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
const pctUn = r.runTotalTokens > 0 ? (100 * r.unaccountedTokens) / r.runTotalTokens : 0;
|
|
612
|
+
lines.push(` reconciliation: accounted ${fmt(r.accountedTokens)} + unaccounted ${fmt(r.unaccountedTokens)} = run total ${fmt(r.runTotalTokens)}` +
|
|
613
|
+
` (epsilon ${(r.epsilon * 100).toFixed(2)}%, unaccounted ${pctUn.toFixed(1)}%)`);
|
|
614
|
+
if (r.doubleAttributedTokens !== 0) {
|
|
615
|
+
lines.push(` reconciliation: accounted ${fmt(r.accountedTokens)} + double-attributed ${fmt(r.doubleAttributedTokens)} = Σ stages ${fmt(r.stageTokensSum)}`);
|
|
616
|
+
}
|
|
617
|
+
lines.push(` identity: ${r.identityHolds ? 'holds (raw integer equality)' : 'BROKEN'}`);
|
|
618
|
+
lines.push(` verdict: ${r.verdict}`);
|
|
619
|
+
for (const d of r.defects) {
|
|
620
|
+
const tok = d.tokens === undefined ? '' : ` (${fmt(d.tokens)} weighted tokens)`;
|
|
621
|
+
const subj = d.subjects === undefined || d.subjects.length === 0 ? '' : ` [${d.subjects.slice(0, 6).join(', ')}${d.subjects.length > 6 ? ', …' : ''}]`;
|
|
622
|
+
lines.push(` ${d.kind}: ${d.detail}${tok}${subj}`);
|
|
623
|
+
}
|
|
624
|
+
if (report.recordTotalTokens !== null) {
|
|
625
|
+
lines.push(` note: the run record's own totalTokens is ${fmt(report.recordTotalTokens)} — a RAW unweighted cached sum of the same per-agent list, reported for traceability, NOT the invariant's right-hand side`);
|
|
626
|
+
}
|
|
627
|
+
if (report.pricingFallbackModels.length > 0) {
|
|
628
|
+
lines.push(` note: ~USD marked * uses sonnet-class FALLBACK pricing for: ${report.pricingFallbackModels.join(', ')}`);
|
|
629
|
+
}
|
|
630
|
+
lines.push(` scope: ${COST_LEDGER_SCOPE}`);
|
|
631
|
+
return lines.join('\n');
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* FR-7 serialization: one JSON object per line. The first line is a `kind: "cost-ledger-scope"`
|
|
635
|
+
* header carrying {@link COST_LEDGER_SCOPE}, so the honest scope travels with the file; the last is
|
|
636
|
+
* the reconciliation. This is a REGENERABLE REPORT, never a read-back source of truth (ADR-001).
|
|
637
|
+
*/
|
|
638
|
+
export function costLedgerJsonl(report) {
|
|
639
|
+
const lines = [];
|
|
640
|
+
lines.push(JSON.stringify({
|
|
641
|
+
kind: 'cost-ledger-scope',
|
|
642
|
+
runId: report.runId,
|
|
643
|
+
slug: report.slug,
|
|
644
|
+
estimated: true,
|
|
645
|
+
derived: true,
|
|
646
|
+
scope: COST_LEDGER_SCOPE,
|
|
647
|
+
}));
|
|
648
|
+
for (const row of report.rows)
|
|
649
|
+
lines.push(JSON.stringify({ kind: 'cost-ledger-row', ...row }));
|
|
650
|
+
lines.push(JSON.stringify({ kind: 'cost-ledger-reconciliation', ...report.reconciliation }));
|
|
651
|
+
return lines.join('\n') + '\n';
|
|
652
|
+
}
|
|
653
|
+
function safeReadJson(path) {
|
|
654
|
+
try {
|
|
655
|
+
const st = lstatSync(path);
|
|
656
|
+
if (!st.isFile())
|
|
657
|
+
return null;
|
|
658
|
+
return JSON.parse(readFileSync(path, 'utf-8'));
|
|
659
|
+
}
|
|
660
|
+
catch {
|
|
661
|
+
return null;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
function safeReadText(path) {
|
|
665
|
+
try {
|
|
666
|
+
const st = lstatSync(path);
|
|
667
|
+
if (!st.isFile())
|
|
668
|
+
return '';
|
|
669
|
+
return readFileSync(path, 'utf-8');
|
|
670
|
+
}
|
|
671
|
+
catch {
|
|
672
|
+
return '';
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
function safeListDir(path) {
|
|
676
|
+
try {
|
|
677
|
+
const st = lstatSync(path);
|
|
678
|
+
if (!st.isDirectory())
|
|
679
|
+
return [];
|
|
680
|
+
return readdirSync(path);
|
|
681
|
+
}
|
|
682
|
+
catch {
|
|
683
|
+
return [];
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* List a PROJECT directory, following a symlink at that ONE level.
|
|
688
|
+
*
|
|
689
|
+
* The asymmetry against {@link safeListDir} is deliberate and load-bearing. `usage.ts` refuses to
|
|
690
|
+
* follow symlinked project directories, and rightly — an account-wide scan that follows links can
|
|
691
|
+
* be pointed at an unbounded tree. But this repo ROAMS its own transcript store: the entry
|
|
692
|
+
* `~/.claude/projects/-home-dz-projects-2026-dz-harness-hub` is a symlink to
|
|
693
|
+
* `<repo>/roam/claude-state` (MEASURED — reproducer: `readlink` on that path). With a plain `lstat`
|
|
694
|
+
* gate the ledger found 0 of this project's 29 run records: the feature was blind to exactly the
|
|
695
|
+
* project it exists to measure.
|
|
696
|
+
*
|
|
697
|
+
* So: the project level follows one link; EVERY level below still uses `lstat` and never follows.
|
|
698
|
+
* That keeps the hazards `usage.ts` guards against — a symlinked session directory, a FIFO or a
|
|
699
|
+
* link to a huge file where a transcript should be — while making the roaming layout readable. The
|
|
700
|
+
* ledger is also per-RUN, not account-wide, so the unbounded-walk concern does not apply.
|
|
701
|
+
*/
|
|
702
|
+
function safeListProjectDir(path) {
|
|
703
|
+
try {
|
|
704
|
+
if (!statSync(path).isDirectory())
|
|
705
|
+
return [];
|
|
706
|
+
return readdirSync(path);
|
|
707
|
+
}
|
|
708
|
+
catch {
|
|
709
|
+
return [];
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* Enumerate workflow run records, newest first. NEVER throws — an unreadable tree yields `[]`.
|
|
714
|
+
* READONLY. `lstat` everywhere, so a symlinked session or run directory is never walked.
|
|
715
|
+
*/
|
|
716
|
+
export function listCostLedgerRuns(opts = {}) {
|
|
717
|
+
const root = opts.projectsRoot ?? claudeProjectsRoot();
|
|
718
|
+
const out = [];
|
|
719
|
+
if (!root || !existsSync(root))
|
|
720
|
+
return out;
|
|
721
|
+
const projectDirs = opts.projectDir !== undefined && opts.projectDir.length > 0 ? [opts.projectDir] : safeListDir(root);
|
|
722
|
+
// Two project-dir ALIASES to one transcript tree (~/.claude/projects entries are symlinks on this
|
|
723
|
+
// machine) would double-discover every run: FR-8 then derives the same run twice and halves into a
|
|
724
|
+
// 2x average (Codex QE MED). Canonicalize and visit each real tree once; runIds dedupe as a belt.
|
|
725
|
+
const seenRealProj = new Set();
|
|
726
|
+
const seenRunIds = new Set();
|
|
727
|
+
for (const proj of projectDirs) {
|
|
728
|
+
// A project dir name is data from the filesystem, but `opts.projectDir` is caller-supplied.
|
|
729
|
+
if (proj.includes('/') || proj.includes('\\') || proj === '.' || proj === '..')
|
|
730
|
+
continue;
|
|
731
|
+
const projPath = join(root, proj);
|
|
732
|
+
let realProj = projPath;
|
|
733
|
+
try {
|
|
734
|
+
realProj = realpathSync(projPath);
|
|
735
|
+
}
|
|
736
|
+
catch { /* keep the lexical path */ }
|
|
737
|
+
if (seenRealProj.has(realProj))
|
|
738
|
+
continue;
|
|
739
|
+
seenRealProj.add(realProj);
|
|
740
|
+
for (const sess of safeListProjectDir(projPath)) {
|
|
741
|
+
if (sess.endsWith('.jsonl'))
|
|
742
|
+
continue;
|
|
743
|
+
const wfDir = join(projPath, sess, 'workflows');
|
|
744
|
+
for (const f of safeListDir(wfDir)) {
|
|
745
|
+
if (!f.endsWith('.json'))
|
|
746
|
+
continue;
|
|
747
|
+
const recordPath = join(wfDir, f);
|
|
748
|
+
const parsed = parseWorkflowRunRecord(safeReadJson(recordPath));
|
|
749
|
+
if (parsed === null)
|
|
750
|
+
continue;
|
|
751
|
+
if (!RUN_ID_PATTERN.test(parsed.runId))
|
|
752
|
+
continue; // runId becomes a path segment
|
|
753
|
+
if (seenRunIds.has(parsed.runId))
|
|
754
|
+
continue; // belt to the realpath braces
|
|
755
|
+
seenRunIds.add(parsed.runId);
|
|
756
|
+
out.push({
|
|
757
|
+
runId: parsed.runId,
|
|
758
|
+
slug: parsed.slug,
|
|
759
|
+
workflowName: parsed.workflowName,
|
|
760
|
+
status: parsed.status,
|
|
761
|
+
startedAtMs: parsed.startedAtMs,
|
|
762
|
+
recordPath,
|
|
763
|
+
transcriptDir: join(projPath, sess, 'subagents', 'workflows', parsed.runId),
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
out.sort((a, b) => (b.startedAtMs ?? 0) - (a.startedAtMs ?? 0) || b.runId.localeCompare(a.runId));
|
|
769
|
+
return out;
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* Derive the ledger for ONE run. Returns `null` when no run matches — an ABSENT run is never a
|
|
773
|
+
* BALANCED empty report (ADR-003). NEVER throws; READONLY.
|
|
774
|
+
*/
|
|
775
|
+
export function deriveCostLedger(opts = {}) {
|
|
776
|
+
try {
|
|
777
|
+
if (opts.runId !== undefined && !RUN_ID_PATTERN.test(opts.runId))
|
|
778
|
+
return null;
|
|
779
|
+
if (opts.slug !== undefined && !SLUG_PATTERN.test(opts.slug))
|
|
780
|
+
return null;
|
|
781
|
+
const runs = listCostLedgerRuns(opts);
|
|
782
|
+
const ref = opts.runId !== undefined
|
|
783
|
+
? runs.find((r) => r.runId === opts.runId)
|
|
784
|
+
: opts.slug !== undefined
|
|
785
|
+
? runs.find((r) => r.slug === opts.slug)
|
|
786
|
+
: runs[0];
|
|
787
|
+
if (ref === undefined)
|
|
788
|
+
return null;
|
|
789
|
+
const record = parseWorkflowRunRecord(safeReadJson(ref.recordPath));
|
|
790
|
+
if (record === null)
|
|
791
|
+
return null;
|
|
792
|
+
const stageAgentIds = new Set(record.stages.map((s) => s.agentId));
|
|
793
|
+
const allFiles = safeListDir(ref.transcriptDir).filter((f) => f.endsWith('.jsonl'));
|
|
794
|
+
// A capped listing means the run total is built from a PARTIAL directory — BALANCED on partial
|
|
795
|
+
// evidence is the false green this feature exists to refuse (Codex QE HIGH). The cap stays (a
|
|
796
|
+
// pathological dir must not hang us) but it becomes a NAMED defect, never a silent slice.
|
|
797
|
+
const listingTruncated = allFiles.length > MAX_RUN_TRANSCRIPT_FILES;
|
|
798
|
+
const files = allFiles.slice(0, MAX_RUN_TRANSCRIPT_FILES);
|
|
799
|
+
const runSamples = [];
|
|
800
|
+
const perAgent = new Map();
|
|
801
|
+
const orphanAgentIds = [];
|
|
802
|
+
for (const f of files) {
|
|
803
|
+
const samples = extractCostSamples(safeReadText(join(ref.transcriptDir, f)));
|
|
804
|
+
runSamples.push(...samples);
|
|
805
|
+
const m = /^agent-(.+)\.jsonl$/.exec(f);
|
|
806
|
+
if (m === null)
|
|
807
|
+
continue;
|
|
808
|
+
const agentId = m[1] ?? '';
|
|
809
|
+
if (stageAgentIds.has(agentId))
|
|
810
|
+
perAgent.set(agentId, samples);
|
|
811
|
+
else if (samples.length > 0)
|
|
812
|
+
orphanAgentIds.push(agentId);
|
|
813
|
+
}
|
|
814
|
+
return buildCostLedger({
|
|
815
|
+
record,
|
|
816
|
+
stageSamples: [...perAgent.entries()].map(([agentId, samples]) => ({ agentId, samples })),
|
|
817
|
+
runSamples,
|
|
818
|
+
orphanAgentIds,
|
|
819
|
+
...(listingTruncated ? { transcriptListingTruncated: true } : {}),
|
|
820
|
+
...(opts.epsilon !== undefined ? { epsilon: opts.epsilon } : {}),
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
catch {
|
|
824
|
+
return null; // never-throw contract
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
828
|
+
* FR-8 IO wrapper: derive every run and aggregate. Runs that do not reconcile are excluded by
|
|
829
|
+
* {@link stageCostAggregates}. NEVER throws; READONLY. Still NOT wired into routing.
|
|
830
|
+
*/
|
|
831
|
+
export function deriveStageCostAggregates(opts = {}) {
|
|
832
|
+
try {
|
|
833
|
+
const maxRuns = typeof opts.maxRuns === 'number' && Number.isFinite(opts.maxRuns) && opts.maxRuns > 0
|
|
834
|
+
? Math.floor(opts.maxRuns)
|
|
835
|
+
: 200;
|
|
836
|
+
const reports = [];
|
|
837
|
+
for (const ref of listCostLedgerRuns(opts).slice(0, maxRuns)) {
|
|
838
|
+
const rep = deriveCostLedger({ ...opts, runId: ref.runId });
|
|
839
|
+
if (rep !== null)
|
|
840
|
+
reports.push(rep);
|
|
841
|
+
}
|
|
842
|
+
return stageCostAggregates(reports);
|
|
843
|
+
}
|
|
844
|
+
catch {
|
|
845
|
+
return [];
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
/**
|
|
849
|
+
* FR-7 opt-in materialization. Atomic: writes a sibling `.tmp` then `renameSync`s over the target,
|
|
850
|
+
* and removes the temp file if the rename fails, so a crash can never leave a half-written ledger.
|
|
851
|
+
* Returns `true` on success; never throws.
|
|
852
|
+
*/
|
|
853
|
+
export function writeCostLedgerJsonl(path, report) {
|
|
854
|
+
const tmp = path + '.tmp';
|
|
855
|
+
try {
|
|
856
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
857
|
+
writeFileSync(tmp, costLedgerJsonl(report), 'utf-8');
|
|
858
|
+
renameSync(tmp, path);
|
|
859
|
+
return true;
|
|
860
|
+
}
|
|
861
|
+
catch {
|
|
862
|
+
try {
|
|
863
|
+
unlinkSync(tmp);
|
|
864
|
+
}
|
|
865
|
+
catch {
|
|
866
|
+
/* nothing to clean up */
|
|
867
|
+
}
|
|
868
|
+
return false;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
//# sourceMappingURL=cost-ledger.js.map
|