@dzhechkov/harness-core 0.3.146 → 0.3.147

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.
@@ -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