@dzhechkov/harness-core 0.3.145 → 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.
Files changed (43) hide show
  1. package/.dz-manifest.json +82 -34
  2. package/README.md +3 -1
  3. package/dist/compounding.d.ts +27 -0
  4. package/dist/compounding.d.ts.map +1 -1
  5. package/dist/compounding.js +29 -0
  6. package/dist/compounding.js.map +1 -1
  7. package/dist/cost-ledger.d.ts +318 -0
  8. package/dist/cost-ledger.d.ts.map +1 -0
  9. package/dist/cost-ledger.js +871 -0
  10. package/dist/cost-ledger.js.map +1 -0
  11. package/dist/cost-scoring.d.ts +9 -0
  12. package/dist/cost-scoring.d.ts.map +1 -1
  13. package/dist/cost-scoring.js +18 -0
  14. package/dist/cost-scoring.js.map +1 -1
  15. package/dist/event-chain.d.ts +302 -0
  16. package/dist/event-chain.d.ts.map +1 -0
  17. package/dist/event-chain.js +663 -0
  18. package/dist/event-chain.js.map +1 -0
  19. package/dist/index.d.ts +9 -3
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +6 -2
  22. package/dist/index.js.map +1 -1
  23. package/dist/operations.d.ts.map +1 -1
  24. package/dist/operations.js +26 -0
  25. package/dist/operations.js.map +1 -1
  26. package/dist/recall-usage.d.ts +52 -4
  27. package/dist/recall-usage.d.ts.map +1 -1
  28. package/dist/recall-usage.js +106 -21
  29. package/dist/recall-usage.js.map +1 -1
  30. package/dist/usage.d.ts +32 -0
  31. package/dist/usage.d.ts.map +1 -1
  32. package/dist/usage.js +59 -10
  33. package/dist/usage.js.map +1 -1
  34. package/package.json +3 -3
  35. package/sbom.json +153 -33
  36. package/src/compounding.ts +61 -0
  37. package/src/cost-ledger.ts +1105 -0
  38. package/src/cost-scoring.ts +17 -0
  39. package/src/event-chain.ts +870 -0
  40. package/src/index.ts +86 -0
  41. package/src/operations.ts +25 -0
  42. package/src/recall-usage.ts +142 -24
  43. package/src/usage.ts +74 -12
package/src/index.ts CHANGED
@@ -156,11 +156,13 @@ export {
156
156
  RECALL_USAGE_LOG_MAX_BYTES,
157
157
  RECALL_USAGE_COMPACT_TARGET_BYTES,
158
158
  formatRecallUsageRecord,
159
+ buildRecallUsageRecord,
159
160
  parseRecallUsageLog,
160
161
  aggregateRecallUsage,
161
162
  buildRecallUsageReport,
162
163
  shouldCompactRecallUsageLogSize,
163
164
  compactRecallUsageLog,
165
+ compactRecallUsageLogChecked,
164
166
  } from './recall-usage.js';
165
167
  export type {
166
168
  RecallUsageReadRecord,
@@ -171,7 +173,53 @@ export type {
171
173
  RecallPatternUsageRef,
172
174
  RecallUsagePatternRow,
173
175
  RecallUsageReport,
176
+ RecallUsageRecordInput,
177
+ CompactRecallUsageOptions,
178
+ CompactRecallUsageResult,
179
+ CompactRecallUsageStatus,
174
180
  } from './recall-usage.js';
181
+ export {
182
+ EVENT_CHAIN_SCOPE,
183
+ EVENT_CHAIN_GENESIS_HASH,
184
+ EVENT_CHAIN_TAIL_BYTES,
185
+ EVENT_CHAIN_FIELD_OVERHEAD_BYTES,
186
+ EVENT_CHAIN_LEDGER_KIND,
187
+ EVENT_CHAIN_DEFECT_KINDS,
188
+ fnv1a32,
189
+ chainHashOf,
190
+ chainLinesOf,
191
+ lastChainLine,
192
+ readTailInfo,
193
+ appendChainedLines,
194
+ EMPTY_LOG_TAIL,
195
+ nextChainFields,
196
+ withChainFields,
197
+ chainRecordLines,
198
+ chainRewrite,
199
+ defaultEventWeight,
200
+ eventWeightOfText,
201
+ verifyEventChain,
202
+ verifyEventChainText,
203
+ renderEventChainVerification,
204
+ rewriteSnapshot,
205
+ rewriteSnapshotUnchanged,
206
+ guardedRewrite,
207
+ DEFAULT_REWRITE_ATTEMPTS,
208
+ } from './event-chain.js';
209
+ export type {
210
+ ChainFields,
211
+ LogTail,
212
+ EventChainLedger,
213
+ RewriteSnapshot,
214
+ GuardedRewriteIo,
215
+ GuardedRewriteResult,
216
+ GuardedRewriteStatus,
217
+ RewriteProposal,
218
+ EventChainDefect,
219
+ EventChainDefectKind,
220
+ EventChainVerification,
221
+ VerifyEventChainOptions,
222
+ } from './event-chain.js';
175
223
  export { decideProvenance, environmentCanMintProvenance, publishArgv, discoverPackages, publishPackages, bumpPatch, compareVersions, findUnpackagedSkills, orderByDependencies, syncReadmeVersion } from './publish.js';
176
224
  export { fetchAllDownloads } from './downloads.js';
177
225
  export type { PackageDownloads, DownloadsReport } from './downloads.js';
@@ -215,6 +263,7 @@ export type { RiskScore, RiskThresholds } from './risk-scoring.js';
215
263
  export {
216
264
  MODEL_PRICES,
217
265
  pricingFor,
266
+ hasKnownPricing,
218
267
  normalizeUsage,
219
268
  usageCost,
220
269
  invocationCost,
@@ -294,6 +343,43 @@ export {
294
343
  readUsageLimits,
295
344
  weeklyWindowFor,
296
345
  } from './usage.js';
346
+ export { claudeProjectsRoot, rawTokenMixOf, weightedTokensOf } from './usage.js';
347
+ export type { RawTokenMix } from './usage.js';
348
+ // Per-stage cost ledger + reconciliation invariant (feature cost-ledger, ADR-001/002/003).
349
+ export {
350
+ COST_LEDGER_SCOPE,
351
+ COST_LEDGER_DEFECT_KINDS,
352
+ COST_LEDGER_VERDICTS,
353
+ DEFAULT_COST_LEDGER_EPSILON,
354
+ extractCostSamples,
355
+ parseWorkflowRunRecord,
356
+ buildCostLedger,
357
+ verifyCostLedgerReport,
358
+ stageCostAggregates,
359
+ renderCostLedger,
360
+ costLedgerJsonl,
361
+ listCostLedgerRuns,
362
+ deriveCostLedger,
363
+ deriveStageCostAggregates,
364
+ writeCostLedgerJsonl,
365
+ } from './cost-ledger.js';
366
+ export type {
367
+ CostLedgerDefect,
368
+ CostLedgerDefectKind,
369
+ CostLedgerIoOptions,
370
+ CostLedgerReconciliation,
371
+ CostLedgerReport,
372
+ CostLedgerRow,
373
+ CostLedgerRunRef,
374
+ CostLedgerSample,
375
+ CostLedgerVerdict,
376
+ BuildCostLedgerInput,
377
+ DeriveCostLedgerOptions,
378
+ StageCostAggregate,
379
+ StageSampleSet,
380
+ WorkflowRunRecord,
381
+ WorkflowStageEntry,
382
+ } from './cost-ledger.js';
297
383
  export type {
298
384
  ClaudeUsageModel,
299
385
  UsageCalibrationChange,
package/src/operations.ts CHANGED
@@ -788,6 +788,31 @@ export async function runDoctor(options: { projectRoot: string }): Promise<Docto
788
788
  } catch { /* either side absent — covered by other checks */ }
789
789
  }
790
790
 
791
+ // 8b. EVIDENCE-CHAIN INTEGRITY (feature event-chain, ADR-001). `.dz/recall-usage.jsonl` and
792
+ // `.dz/guard-audit.jsonl` are what `dz compounding` and `dz guard promote` decide on; a rewrite
793
+ // that loses or duplicates a record there is a wrong verdict with no symptom. Deliberately OUTSIDE
794
+ // the agentdb-writer branch — the evidence base exists whether or not that writer is deployed.
795
+ // Silent when a log is absent or has never been chained: an unchained file is legal (FR-5), not a
796
+ // fault, and reporting it would train the reader to ignore this line.
797
+ try {
798
+ const { verifyEventChainText, EVENT_CHAIN_SCOPE } = await import('./event-chain.js');
799
+ for (const rel of ['recall-usage.jsonl', 'guard-audit.jsonl']) {
800
+ const p = join(root, '.dz', rel);
801
+ if (!existsSync(p)) continue;
802
+ const v = verifyEventChainText(readFileSync(p, 'utf-8'));
803
+ if (v.chained === 0 || v.ok) continue;
804
+ checks.push({
805
+ name: `evidence chain (.dz/${rel})`,
806
+ ok: false,
807
+ detail:
808
+ `${v.defects.length} defect(s): ${v.defects.slice(0, 3).map((d) => `${d.kind}@L${d.line}`).join(', ')}` +
809
+ ` — learning verdicts computed from this log are unsafe. Scope: ${EVENT_CHAIN_SCOPE}`,
810
+ });
811
+ }
812
+ } catch {
813
+ /* doctor never throws on a diagnostic */
814
+ }
815
+
791
816
  // 9. Vector-tier mirror divergence (dz-rvf-vector-bridge FR-2/ADR R4). INFORMATIONAL, never an
792
817
  // error exit: lexical is the source of truth and `dz consolidate` backfills the mirror. Per
793
818
  // Constraint 7 (QR-10) the line reports BOTH counts so a diverged mirror is not misdiagnosed
@@ -6,9 +6,22 @@
6
6
  * into aggregate JSONL rows when it crosses a bounded size. It deliberately knows nothing about the
7
7
  * filesystem; callers own reads/writes so the hook and statusline can keep their never-block rules.
8
8
  *
9
+ * Compaction RE-CHAINS everything it writes and records what it measured in its input, so that a
10
+ * rewrite which counts an event twice fails `verifyEventChain` instead of producing a well-formed
11
+ * lie — the 2 → 4 → 6 defect below is the reason (see `event-chain.ts`, ADR-002).
12
+ *
9
13
  * @packageDocumentation
10
14
  */
11
15
 
16
+ import {
17
+ EVENT_CHAIN_FIELD_OVERHEAD_BYTES,
18
+ EVENT_CHAIN_LEDGER_KIND,
19
+ chainRewrite,
20
+ defaultEventWeight,
21
+ verifyEventChainText,
22
+ type EventChainDefect,
23
+ } from './event-chain.js';
24
+
12
25
  export const RECALL_USAGE_LOG_RELATIVE = '.dz/recall-usage.jsonl';
13
26
  export const RECALL_USAGE_LOG_MAX_BYTES = 1_048_576;
14
27
  export const RECALL_USAGE_COMPACT_TARGET_BYTES = Math.floor(RECALL_USAGE_LOG_MAX_BYTES * 0.75);
@@ -104,7 +117,7 @@ interface Acc {
104
117
  totalScore: number;
105
118
  }
106
119
 
107
- export function formatRecallUsageRecord(input: {
120
+ export interface RecallUsageRecordInput {
108
121
  readonly dzId?: unknown;
109
122
  readonly score?: unknown;
110
123
  readonly ts?: unknown;
@@ -112,8 +125,18 @@ export function formatRecallUsageRecord(input: {
112
125
  readonly runId?: unknown;
113
126
  readonly eventId?: unknown;
114
127
  readonly queryTruncated?: unknown;
115
- }): string | undefined {
116
- const rec = normalizeReadRecord(input);
128
+ }
129
+
130
+ /**
131
+ * The normalized RECORD, before serialization — the writer needs the object so it can hang the
132
+ * event-chain fields off it (`seq`/`prevHash`, ADR-001) instead of string-splicing a finished line.
133
+ */
134
+ export function buildRecallUsageRecord(input: RecallUsageRecordInput): RecallUsageReadRecord | undefined {
135
+ return normalizeReadRecord(input);
136
+ }
137
+
138
+ export function formatRecallUsageRecord(input: RecallUsageRecordInput): string | undefined {
139
+ const rec = buildRecallUsageRecord(input);
117
140
  return rec === undefined ? undefined : `${JSON.stringify(rec)}\n`;
118
141
  }
119
142
 
@@ -125,6 +148,10 @@ export function parseRecallUsageLog(text: string): ParsedRecallUsageLog {
125
148
  if (trimmed === '') continue;
126
149
  try {
127
150
  const parsed = JSON.parse(trimmed) as unknown;
151
+ // The compaction ledger (ADR-002) is chain bookkeeping, not a usage record. Counting it as an
152
+ // invalid line would make `invalidLines` — a health number the report prints — lie by one per
153
+ // compaction generation.
154
+ if (isRecord(parsed) && parsed['kind'] === EVENT_CHAIN_LEDGER_KIND) continue;
128
155
  const record = normalizeRecord(parsed);
129
156
  if (record === undefined) {
130
157
  invalidLines += 1;
@@ -213,10 +240,68 @@ export function shouldCompactRecallUsageLogSize(
213
240
  return Number.isFinite(sizeBytes) && sizeBytes > validMax(maxBytes);
214
241
  }
215
242
 
216
- export function compactRecallUsageLog(
243
+ export interface CompactRecallUsageOptions {
244
+ readonly maxBytes?: number;
245
+ readonly targetBytes?: number;
246
+ readonly compactedAt?: string;
247
+ /**
248
+ * Compact even when the input's chain is already defective. OFF by default and never set by any
249
+ * automatic caller — see {@link compactRecallUsageLogChecked} for why.
250
+ */
251
+ readonly force?: boolean;
252
+ }
253
+
254
+ export type CompactRecallUsageStatus = 'compacted' | 'refused-dirty' | 'too-large';
255
+
256
+ export interface CompactRecallUsageResult {
257
+ readonly status: CompactRecallUsageStatus;
258
+ /** Empty unless `status === 'compacted'`. */
259
+ readonly text: string;
260
+ /** The input defects that caused a refusal. */
261
+ readonly defects: readonly EventChainDefect[];
262
+ }
263
+
264
+ /**
265
+ * Compaction with its verdict attached.
266
+ *
267
+ * AM-2 (Codex QE HIGH-2) — A REWRITER MUST NOT LAUNDER. Compaction parses the input, drops what it
268
+ * cannot read and re-chains from genesis, so a file carrying a `BrokenLink` or a `DoubleCounted`
269
+ * came out the other side verifying `ok: true`. The strongest evidence check in the system was
270
+ * being erased by the routine that runs automatically at a size threshold — corruption converted
271
+ * into a clean chain, with no record that it ever existed.
272
+ *
273
+ * So: the input is VERIFIED FIRST, and a defective chained region REFUSES. The pre-chain prefix is
274
+ * legal and never blocks anything (FR-5); only real defects do.
275
+ *
276
+ * ACCEPTED CONSEQUENCE, stated because it is the cost: a log that stays defective stops being
277
+ * compacted and grows past its cap. That is the right way round — the size cap is a convenience,
278
+ * the evidence is the product — and it is not silent: `dz doctor` and `dz compounding` both report
279
+ * the chain defect, and the caller logs the refusal.
280
+ */
281
+ export function compactRecallUsageLogChecked(
217
282
  text: string,
218
- opts: { readonly maxBytes?: number; readonly targetBytes?: number; readonly compactedAt?: string } = {},
219
- ): string {
283
+ opts: CompactRecallUsageOptions = {},
284
+ ): CompactRecallUsageResult {
285
+ if (opts.force !== true) {
286
+ const v = verifyEventChainText(typeof text === 'string' ? text : '');
287
+ if (!v.ok) return { status: 'refused-dirty', text: '', defects: v.defects };
288
+ }
289
+ const out = compactVerifiedRecallUsageLog(text, opts);
290
+ return out === ''
291
+ ? { status: 'too-large', text: '', defects: [] }
292
+ : { status: 'compacted', text: out, defects: [] };
293
+ }
294
+
295
+ /**
296
+ * Back-compatible wrapper: the compacted text, or `''` when the rewrite is REFUSED (a defective
297
+ * input) or cannot fit. Callers that need to tell those apart use
298
+ * {@link compactRecallUsageLogChecked}.
299
+ */
300
+ export function compactRecallUsageLog(text: string, opts: CompactRecallUsageOptions = {}): string {
301
+ return compactRecallUsageLogChecked(text, opts).text;
302
+ }
303
+
304
+ function compactVerifiedRecallUsageLog(text: string, opts: CompactRecallUsageOptions): string {
220
305
  const maxBytes = validMax(opts.maxBytes ?? RECALL_USAGE_LOG_MAX_BYTES);
221
306
  const targetBytes = validTarget(opts.targetBytes ?? Math.floor(maxBytes * 0.75), maxBytes);
222
307
  const compactedAt = validTs(opts.compactedAt) ? opts.compactedAt : new Date(0).toISOString();
@@ -233,23 +318,57 @@ export function compactRecallUsageLog(
233
318
  const toAggregate = parsed.filter((r) => !retained.has(r as RecallUsageReadRecord));
234
319
  const stats = aggregateRecallUsage(toAggregate);
235
320
  // Replay rows are budgeted FIRST: they are irreplaceable (the corpus), aggregates are re-derivable.
236
- const lines = [...replayRows.map((r) => JSON.stringify(r)), ...stats.map((s) => aggregateLine(s, compactedAt))];
237
- let out = joinLines(lines);
238
- if (byteLength(out) <= maxBytes) return out;
239
-
240
- const kept: string[] = [];
241
- let used = 0;
242
- for (const line of lines) {
243
- const cost = byteLength(`${line}\n`);
244
- if (kept.length > 0 && used + cost > targetBytes) continue;
245
- if (cost > maxBytes) continue;
246
- if (used + cost <= maxBytes) {
247
- kept.push(line);
248
- used += cost;
321
+ const candidates: RecallUsageRecord[] = [...replayRows, ...stats.map((s) => aggregateRecord(s, compactedAt))];
322
+
323
+ // The event weight of the INPUT, measured before aggregation. It must come from the input — a
324
+ // total derived from the output could never disagree with it (ADR-002), which is exactly how a
325
+ // double-counting rewrite went unseen until a human re-read the code.
326
+ let sourceEvents = 0;
327
+ for (const rec of parsed) sourceEvents += defaultEventWeight(rec as unknown as Record<string, unknown>);
328
+
329
+ // SELECT, then chain (ADR-002 decision 5): trimming a chain after building it punches holes in it.
330
+ // Each candidate is charged its own bytes plus a fixed allowance for the chain fields it will get.
331
+ let selected = candidates;
332
+ if (byteLength(joinLines(candidates.map(serializeWithChainAllowance))) > maxBytes) {
333
+ const kept: RecallUsageRecord[] = [];
334
+ let used = 0;
335
+ for (const rec of candidates) {
336
+ const cost = byteLength(`${serializeWithChainAllowance(rec)}\n`);
337
+ if (kept.length > 0 && used + cost > targetBytes) continue;
338
+ if (cost > maxBytes) continue;
339
+ if (used + cost <= maxBytes) {
340
+ kept.push(rec);
341
+ used += cost;
342
+ }
249
343
  }
344
+ selected = kept;
345
+ }
346
+
347
+ // Final shrink: drop from the TAIL and re-chain, so the survivors are always a valid chain.
348
+ for (;;) {
349
+ const out = joinLines(chainRewrite(selected, { sourceEvents, droppedEvents: droppedEvents(sourceEvents, selected), compactedAt }));
350
+ if (byteLength(out) <= maxBytes) return out;
351
+ if (selected.length === 0) return '';
352
+ selected = selected.slice(0, -1);
250
353
  }
251
- out = joinLines(kept);
252
- return byteLength(out) <= maxBytes ? out : '';
354
+ }
355
+
356
+ /**
357
+ * What the byte budget discarded — so a deliberate trim is never mistaken for lost records.
358
+ *
359
+ * The `Math.max(0, …)` is LOAD-BEARING, not defensive tidiness: it is what stops a rewrite that
360
+ * emits MORE events than it read from explaining its own inflation away with a negative "dropped"
361
+ * figure. Over-accounting therefore always reaches {@link verifyEventChain} as `DoubleCounted`.
362
+ */
363
+ function droppedEvents(sourceEvents: number, selected: readonly RecallUsageRecord[]): number {
364
+ let accounted = 0;
365
+ for (const rec of selected) accounted += defaultEventWeight(rec as unknown as Record<string, unknown>);
366
+ return Math.max(0, sourceEvents - accounted);
367
+ }
368
+
369
+ /** A record's serialized size plus the allowance for the chain fields it will carry. */
370
+ function serializeWithChainAllowance(rec: RecallUsageRecord): string {
371
+ return JSON.stringify(rec) + ' '.repeat(EVENT_CHAIN_FIELD_OVERHEAD_BYTES);
253
372
  }
254
373
 
255
374
  function normalizeRecord(value: unknown): RecallUsageRecord | undefined {
@@ -356,8 +475,8 @@ function mergeAggregate(byId: Map<string, Acc>, rec: RecallUsageAggregateRecord)
356
475
  }
357
476
  }
358
477
 
359
- function aggregateLine(stat: RecallUsageStat, compactedAt: string): string {
360
- const rec: RecallUsageAggregateRecord = {
478
+ function aggregateRecord(stat: RecallUsageStat, compactedAt: string): RecallUsageAggregateRecord {
479
+ return {
361
480
  kind: 'aggregate',
362
481
  dzId: stat.dzId,
363
482
  reads: stat.reads,
@@ -367,7 +486,6 @@ function aggregateLine(stat: RecallUsageStat, compactedAt: string): string {
367
486
  totalScore: stat.avgScore * stat.reads,
368
487
  compactedAt,
369
488
  };
370
- return JSON.stringify(rec);
371
489
  }
372
490
 
373
491
  function patternRef(p: RecallPatternUsageRef): RecallPatternUsageRef {
package/src/usage.ts CHANGED
@@ -45,6 +45,77 @@ const MTIME_SLACK_MS = HOUR_MS;
45
45
  */
46
46
  export const TOKEN_WEIGHTS = { input: 1, cacheWrite: 1.25, cacheWrite1h: 2, cacheRead: 0.1, output: 5 } as const;
47
47
 
48
+ /** The four raw token buckets of one `message.usage` record, after clamping. */
49
+ export interface RawTokenMix {
50
+ readonly input: number;
51
+ /** Cache-CREATION tokens, already priced at their TTL rate inside {@link weightedTokensOf}. */
52
+ readonly cacheWrite: number;
53
+ readonly cacheRead: number;
54
+ readonly output: number;
55
+ }
56
+
57
+ function positiveFinite(v: unknown): number {
58
+ return typeof v === 'number' && isFinite(v) && v > 0 ? v : 0;
59
+ }
60
+
61
+ /**
62
+ * The raw token buckets of an Anthropic `message.usage` object, clamped to finite non-negatives.
63
+ * `cacheWrite` prefers the TTL breakdown (`cache_creation.ephemeral_*`) and falls back to the flat
64
+ * `cache_creation_input_tokens` — reading only the flat field scored a nested-only record as ZERO.
65
+ */
66
+ export function rawTokenMixOf(usage: unknown): RawTokenMix {
67
+ if (typeof usage !== 'object' || usage === null) {
68
+ return { input: 0, cacheWrite: 0, cacheRead: 0, output: 0 };
69
+ }
70
+ const u = usage as Record<string, unknown>;
71
+ const cc = (typeof u['cache_creation'] === 'object' && u['cache_creation'] !== null
72
+ ? (u['cache_creation'] as Record<string, unknown>)
73
+ : {}) as Record<string, unknown>;
74
+ const c5 = positiveFinite(cc['ephemeral_5m_input_tokens']);
75
+ const c1h = positiveFinite(cc['ephemeral_1h_input_tokens']);
76
+ const cacheWrite = c5 + c1h > 0 ? c5 + c1h : positiveFinite(u['cache_creation_input_tokens']);
77
+ return {
78
+ input: positiveFinite(u['input_tokens']),
79
+ cacheWrite,
80
+ cacheRead: positiveFinite(u['cache_read_input_tokens']),
81
+ output: positiveFinite(u['output_tokens']),
82
+ };
83
+ }
84
+
85
+ /**
86
+ * THE estimator — cost-weighted "input-equivalent" tokens for one `message.usage` object.
87
+ *
88
+ * A flat token sum is 89-99.7% `cache_read` on this machine, which tracks CONVERSATION LENGTH
89
+ * rather than work done. {@link TOKEN_WEIGHTS} are the published per-token price ratios relative to
90
+ * base input, so the result tracks consumption instead of context size.
91
+ *
92
+ * Extracted verbatim from `computeUsage`'s per-sample arithmetic so that `dz usage` and the
93
+ * per-stage cost ledger measure the same quantity (feature `cost-ledger`, ADR-002 — the invariant
94
+ * only means something if both sides use ONE estimator). The return value is UNROUNDED; callers
95
+ * that need exact integer identities round once at their own extraction point.
96
+ */
97
+ export function weightedTokensOf(usage: unknown): number {
98
+ if (typeof usage !== 'object' || usage === null) return 0;
99
+ const mix = rawTokenMixOf(usage);
100
+ const u = usage as Record<string, unknown>;
101
+ const cc =
102
+ typeof u['cache_creation'] === 'object' && u['cache_creation'] !== null
103
+ ? (u['cache_creation'] as Record<string, unknown>)
104
+ : {};
105
+ const c5 = positiveFinite(cc['ephemeral_5m_input_tokens']);
106
+ const c1h = positiveFinite(cc['ephemeral_1h_input_tokens']);
107
+ const cacheWriteCost =
108
+ c5 + c1h > 0
109
+ ? c5 * TOKEN_WEIGHTS.cacheWrite + c1h * TOKEN_WEIGHTS.cacheWrite1h
110
+ : mix.cacheWrite * TOKEN_WEIGHTS.cacheWrite;
111
+ return (
112
+ mix.input * TOKEN_WEIGHTS.input +
113
+ cacheWriteCost +
114
+ mix.cacheRead * TOKEN_WEIGHTS.cacheRead +
115
+ mix.output * TOKEN_WEIGHTS.output
116
+ );
117
+ }
118
+
48
119
  export const CLAUDE_USAGE_MODELS = ['fable', 'opus', 'sonnet', 'haiku'] as const;
49
120
  export type ClaudeUsageModel = (typeof CLAUDE_USAGE_MODELS)[number];
50
121
 
@@ -230,7 +301,7 @@ export function normalizeClaudeUsageModelKey(raw: unknown): ClaudeUsageModel | n
230
301
  * The `~/.claude/projects` root (the account-wide transcript store). Overridable via
231
302
  * `DZ_CLAUDE_PROJECTS_ROOT` — used by tests to point at a temp tree. Never throws.
232
303
  */
233
- function claudeProjectsRoot(): string {
304
+ export function claudeProjectsRoot(): string {
234
305
  const override = process.env['DZ_CLAUDE_PROJECTS_ROOT'];
235
306
  if (typeof override === 'string' && override.length > 0) return override;
236
307
  return join(homedir(), '.claude', 'projects');
@@ -414,17 +485,8 @@ function extractSamples(path: string, scanCutoff: number, into: Sample[], seen:
414
485
  // tokens": a quantity that tracks consumption instead of context size.
415
486
  // Prefer the TTL breakdown when present (5m 1.25x / 1h 2x); fall back to the flat field at the
416
487
  // 5m rate. Reading only the flat field scored a nested-only record as ZERO.
417
- const c5 = n(usage.cache_creation?.ephemeral_5m_input_tokens);
418
- const c1h = n(usage.cache_creation?.ephemeral_1h_input_tokens);
419
- const cacheWriteCost =
420
- c5 + c1h > 0
421
- ? c5 * TOKEN_WEIGHTS.cacheWrite + c1h * TOKEN_WEIGHTS.cacheWrite1h
422
- : n(usage.cache_creation_input_tokens) * TOKEN_WEIGHTS.cacheWrite;
423
- const tokens =
424
- n(usage.input_tokens) * TOKEN_WEIGHTS.input +
425
- cacheWriteCost +
426
- n(usage.cache_read_input_tokens) * TOKEN_WEIGHTS.cacheRead +
427
- n(usage.output_tokens) * TOKEN_WEIGHTS.output;
488
+ // ONE estimator, shared with the per-stage cost ledger (feature `cost-ledger`, ADR-002).
489
+ const tokens = weightedTokensOf(usage);
428
490
  if (tokens <= 0) continue;
429
491
  // Dedup: streamed assistant messages repeat their usage object across chunks.
430
492
  const id = typeof rec.message?.id === 'string' ? rec.message.id : '';