@bglocation/tune-context 2.3.1 → 2.5.0

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,1046 @@
1
+ #!/usr/bin/env node
2
+ // prompt-metrics — Claude Code transcripts → one JSONL row per prompt: time,
3
+ // tokens (split by cache class), model/effort, tools, delegations. The raw
4
+ // components EPIC-009 prices in TASK-086 and compares per arm in TASK-089.
5
+ // See tasks/TASK-085.
6
+ //
7
+ // Why the transcript and not a hook: in the "tune-context off" arm our hooks do
8
+ // not run, so hook instrumentation cannot measure the reference arm — by
9
+ // construction. The transcript is written by Claude Code itself, so it is the
10
+ // only source that works in BOTH arms. It also already exists for past sessions,
11
+ // which is why this task runs first in the epic: baseline before any switch.
12
+ //
13
+ // Honest scope (the CLI prints the same facts to stderr on every run):
14
+ // • `list_cost_usd` (eval/pricing.mjs, TASK-086) is API LIST PRICE for these
15
+ // tokens — NOT a bill. A Pro/Max subscription charges its flat fee and this
16
+ // figure is what the same work would have cost through the API. A model
17
+ // missing from the price table yields null and an `unpriced` count, never a
18
+ // zero.
19
+ // • Server-side tool use is billed per request, not per token, so it is not
20
+ // inside list_cost_usd. Web search costs $10/1000; web FETCH is free. Both
21
+ // counts ride on the row. Zero on all data measured so far.
22
+ // • Subagent turns are NOT in the parent transcript. Measured 2026-07-30:
23
+ // 29 transcripts, 21 `Agent` tool calls, ZERO isSidechain entries. But the
24
+ // RESULT of an `Agent` call carries its own `usage` and `resolvedModel`, so
25
+ // the delegation is measurable FROM BELOW — see applyDelegation(). The row
26
+ // therefore reports two figures that are never added together: the parent
27
+ // session's cost, and `delegations[].list_cost_usd`, a lower bound on what
28
+ // the subagents cost (COST_EVALUATION_PROCEDURE §11a). `agent_spawns` and
29
+ // `sidechain_turns` are still counted, so the size of the remaining blind
30
+ // spot stays visible (EPIC-009, error class 3).
31
+ // • `<synthetic>` assistant entries are generated by Claude Code, not billed
32
+ // by the API. They are excluded from tokens/turns and counted separately —
33
+ // an unbillable entry, not TASK-086's "unknown model".
34
+ //
35
+ // The `usage` reading here deliberately does NOT share code with
36
+ // hooks/context-reminder.mjs, which also parses it. That hook answers "how big
37
+ // is the context right now" and sums the three input classes into one number;
38
+ // this file must keep them apart, because they are priced at 1x / 0.1x /
39
+ // 1.25x-2x. One helper serving both would have to pick a side. (A hook also
40
+ // cannot import from eval/ — only hooks/ is installed.)
41
+ import { createReadStream, existsSync, readdirSync, statSync } from 'node:fs';
42
+ import { createInterface } from 'node:readline';
43
+ import { join } from 'node:path';
44
+ import { parseArgs } from 'node:util';
45
+ import { claudeBaseDir } from '../hooks/config-dir.mjs';
46
+ import { experimentPaths, readLedger } from '../cli/experiment.mjs';
47
+ import { annotateRows, formatArmSummary } from './arm.mjs';
48
+ import { formatCostLines, formatUsd, priceRow, summarizeCost, webSearchCost } from './pricing.mjs';
49
+
50
+ // Tool names that spawn a subagent. `Agent` is what this Claude Code writes
51
+ // (measured); `Task` is the name the same tool carried in earlier versions.
52
+ // Both are counted so a version change cannot silently zero the delegation
53
+ // count — the one number that lets TASK-089 say "uncaptured" instead of nothing.
54
+ const AGENT_TOOLS = new Set(['Agent', 'Task']);
55
+
56
+ // Model string Claude Code puts on entries it generated itself (interrupts,
57
+ // local notices). Never an API call, never a cost.
58
+ const SYNTHETIC_MODEL = '<synthetic>';
59
+
60
+ const USAGE = `
61
+ prompt-metrics — per-prompt time/token/tool metrics from Claude Code transcripts.
62
+
63
+ Usage:
64
+ prompt-metrics [--transcript <path>]... [--dir <path>]
65
+
66
+ Options:
67
+ --transcript <path> A single .jsonl transcript. Repeatable.
68
+ --dir <path> Directory of transcripts, scanned one level deep. Used
69
+ together with --transcript, the two are unioned.
70
+ (default when neither is given: <config dir>/projects,
71
+ where <config dir> is $CLAUDE_CONFIG_DIR if set,
72
+ else ~/.claude)
73
+ -h, --help Show this help
74
+
75
+ Writes JSONL to stdout, one row per prompt. A run summary — including how many
76
+ entries could NOT be attributed to a prompt, and how many rows carry a model the
77
+ price table does not know — goes to stderr, so stdout stays a clean data channel.
78
+
79
+ Rows from a branch shaped exp/<task>/<arm> (the paired measurement, TASK-088)
80
+ also carry:
81
+ exp_task, exp_arm the arm, resolved — '' when it could not be resolved
82
+ exp_arm_source branch+ledger | branch | none | conflict:<why>
83
+ exp_ledger_arm what the TASK-087 ledger said, whenever it resolved —
84
+ on agreement too, so a confirmation can be audited
85
+ pass_order 1 for the arm run first in that task, 2 for the second
86
+ The branch says which arm was INTENDED, the ledger which was SET; a disagreement
87
+ leaves the arm unresolved rather than picking a winner.
88
+
89
+ These five fields are added here, in the CLI. Rows obtained through the module
90
+ (aggregate, collectFiles) carry the measurement only — call annotateRows from
91
+ eval/arm.mjs to add the arm.
92
+
93
+ Each row also carries delegations[]: one record per Agent call that returned its
94
+ own usage, priced with the same table. That figure is a LOWER BOUND on what the
95
+ subagents cost and is never added to the row's own cost.
96
+ `.trim();
97
+
98
+ /** Parse one transcript line. Returns null for blanks, junk, or non-objects. */
99
+ export function parseEntry(line) {
100
+ const t = typeof line === 'string' ? line.trim() : '';
101
+ if (t === '') return null;
102
+ try {
103
+ const o = JSON.parse(t);
104
+ return o && typeof o === 'object' && !Array.isArray(o) ? o : null;
105
+ } catch {
106
+ return null;
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Every entry of a transcript, malformed lines dropped. Pure over text — the
112
+ * I/O-free seam tests use. Real runs go through readLines()/collectFiles()
113
+ * instead, which never hold the whole file (see readLines).
114
+ */
115
+ export function parseEntries(text) {
116
+ const out = [];
117
+ for (const line of text.split('\n')) {
118
+ const e = parseEntry(line);
119
+ if (e !== null) out.push(e);
120
+ }
121
+ return out;
122
+ }
123
+
124
+ function msOf(entry) {
125
+ const t = Date.parse(entry?.timestamp);
126
+ return Number.isNaN(t) ? null : t;
127
+ }
128
+
129
+ function isoOrNull(ms) {
130
+ return ms === null ? null : new Date(ms).toISOString();
131
+ }
132
+
133
+ /** Elapsed seconds, 1ms precision; null when either end is unknown. */
134
+ function seconds(fromMs, toMs) {
135
+ if (fromMs === null || toMs === null) return null;
136
+ return Math.round(toMs - fromMs) / 1000;
137
+ }
138
+
139
+ // A prompt may span several models/efforts/tiers (measured: one session carried
140
+ // claude-opus-5, claude-sonnet-5 and <synthetic>; effort took high/xhigh/max).
141
+ // The row keeps a single scalar for the common case and the literal string
142
+ // 'mixed' otherwise — never an average, never a silent first-wins. The full
143
+ // distinct set rides alongside (`models`, `efforts`) so 'mixed' is decodable
144
+ // and by_model below still prices the row exactly.
145
+ function collapse(set) {
146
+ if (set.size === 0) return null;
147
+ if (set.size === 1) return [...set][0];
148
+ return 'mixed';
149
+ }
150
+
151
+ function emptyTokens() {
152
+ return {
153
+ input_tokens: 0,
154
+ cache_read: 0,
155
+ cache_write_5m: 0,
156
+ cache_write_1h: 0,
157
+ // Cache-creation tokens the entry did NOT break down by TTL. Kept separate
158
+ // rather than folded into 5m: the write multipliers differ (1.25× vs 2×),
159
+ // so guessing a TTL here would quietly invent up to 60% of a cost line.
160
+ // Zero on every entry measured so far; if it ever isn't, TASK-086 sees it.
161
+ cache_write_unsplit: 0,
162
+ output_tokens: 0,
163
+ };
164
+ }
165
+
166
+ function addUsage(into, usage) {
167
+ const created = usage.cache_creation_input_tokens || 0;
168
+ const m5 = usage.cache_creation?.ephemeral_5m_input_tokens || 0;
169
+ const h1 = usage.cache_creation?.ephemeral_1h_input_tokens || 0;
170
+ into.input_tokens += usage.input_tokens || 0;
171
+ into.cache_read += usage.cache_read_input_tokens || 0;
172
+ into.cache_write_5m += m5;
173
+ into.cache_write_1h += h1;
174
+ into.cache_write_unsplit += Math.max(0, created - m5 - h1);
175
+ into.output_tokens += usage.output_tokens || 0;
176
+ }
177
+
178
+ /** The `tool_use` blocks an assistant entry carries, in block order. One reader
179
+ * for the row path and the unattributed bucket both: what counts as a tool call
180
+ * must not quietly become two slightly different answers. */
181
+ function toolUseBlocks(entry) {
182
+ const content = Array.isArray(entry.message?.content) ? entry.message.content : [];
183
+ return content.filter((block) => block?.type === 'tool_use');
184
+ }
185
+
186
+ /** Server-side tool requests carried by a usage block, as plain counts.
187
+ *
188
+ * Deliberately NOT folded into addUsage(): every field that function touches is
189
+ * a token class priced PER TOKEN, and these are billed PER REQUEST, which is
190
+ * why they sit outside list_cost_by_class on a row. addUsage also feeds the
191
+ * usage.iterations comparison (where a request count would be compared against
192
+ * token counts) and the per-model buckets published as `by_model` — a field
193
+ * added there would surface in a row's token breakdown, which its own test
194
+ * pins to exactly the five priced classes plus the unsplit one. So the shape of
195
+ * `usage.server_tool_use` gets ONE reader instead, and the two accumulators
196
+ * that need it keep their own field names. */
197
+ function serverToolRequests(usage) {
198
+ const server = usage?.server_tool_use;
199
+ return {
200
+ search: server?.web_search_requests || 0,
201
+ fetch: server?.web_fetch_requests || 0,
202
+ };
203
+ }
204
+
205
+ function newPrompt({ promptId, session }) {
206
+ return {
207
+ promptId,
208
+ session,
209
+ cwd: null,
210
+ gitBranch: null,
211
+ gitBranchChanged: false,
212
+ ccVersion: null,
213
+ tsSubmit: null,
214
+ tsFirstAssistant: null,
215
+ tsEnd: null,
216
+ lastTs: null,
217
+ maxGap: 0,
218
+ models: new Set(),
219
+ efforts: new Set(),
220
+ speeds: new Set(),
221
+ tiers: new Set(),
222
+ // Data residency: `us` costs 1.1x across every token class (Claude 4.6+).
223
+ geos: new Set(),
224
+ apiTurns: 0,
225
+ syntheticTurns: 0,
226
+ // No flat token totals here on purpose: the row's input_tokens/cache_*/
227
+ // output_tokens are DERIVED from byModel in toRow(). Two accumulators fed
228
+ // from the same usage block are two things that can drift, and the drift
229
+ // would show up as a cost that does not match its own breakdown.
230
+ byModel: new Map(),
231
+ // Turns whose `usage.iterations` says more was spent than the top-level
232
+ // `usage` reports — see usageUnderstated(). Known underestimation, counted
233
+ // rather than corrected (TASK-086).
234
+ multiAttemptTurns: 0,
235
+ understatedTurns: 0,
236
+ // Split because they are billed differently: web search is $10/1000, web
237
+ // fetch is free. Counting them together priced free requests.
238
+ webSearchRequests: 0,
239
+ webFetchRequests: 0,
240
+ tools: new Map(),
241
+ toolCalls: 0,
242
+ agentSpawns: 0,
243
+ sidechainTurns: 0,
244
+ // Keyed by agentId, because the same delegation result is written to
245
+ // SEVERAL transcript files (measured: 8 entries for 4 delegations). A list
246
+ // here would double the only figure we have for subagent cost.
247
+ delegations: new Map(),
248
+ delegationDuplicates: 0,
249
+ delegationsUnidentified: 0,
250
+ promptChars: null,
251
+ };
252
+ }
253
+
254
+ /** Sum over whatever classes emptyTokens() defines — a new class cannot slip
255
+ * out of this total by being forgotten in a hand-written list. */
256
+ function totalTokens(t) {
257
+ return Object.values(t).reduce((a, b) => a + b, 0);
258
+ }
259
+
260
+ /**
261
+ * `usage.iterations` is a per-attempt breakdown that appears when a request was
262
+ * retried or fell back to another model. It is NOT summed with the top-level
263
+ * `usage`, which describes only the attempt that answered — adding both counts
264
+ * the same tokens twice. `usage` is the source, full stop; addUsage() reads
265
+ * named fields only, so it cannot drift into iterations by accident.
266
+ *
267
+ * That rule has a price: when the attempts spent more than the top level
268
+ * reports, the row understates. So the condition is measured rather than
269
+ * assumed — if the iterations add up to more than the top level, the turn is
270
+ * counted here and the run says so.
271
+ *
272
+ * Measured over 29 transcripts (16 931 usage blocks carrying `iterations`):
273
+ * 0 blocks with more than one attempt, but 3 where the top-level usage is all
274
+ * zeros while the single iteration carries real tokens (228k cache reads in one
275
+ * case). TASK-086 assumed this counter would stay at zero; it does not.
276
+ */
277
+ function usageUnderstated(usage) {
278
+ const its = Array.isArray(usage.iterations) ? usage.iterations : [];
279
+ if (its.length === 0) return false;
280
+ const top = emptyTokens();
281
+ addUsage(top, usage);
282
+ const attempts = emptyTokens();
283
+ for (const it of its) addUsage(attempts, it);
284
+ return totalTokens(attempts) > totalTokens(top);
285
+ }
286
+
287
+ /** Identity fields, from the first entry of the turn that carries each one. */
288
+ function applyIdentity(p, entry) {
289
+ if (p.cwd === null && typeof entry.cwd === 'string') p.cwd = entry.cwd;
290
+ if (p.ccVersion === null && typeof entry.version === 'string') p.ccVersion = entry.version;
291
+ // gitBranch is the ARM marker in the paired measurement (TASK-088), not
292
+ // decoration, so a change of branch inside one turn is flagged rather than
293
+ // averaged away — 19 of 495 turns in the live run.
294
+ if (typeof entry.gitBranch === 'string') {
295
+ if (p.gitBranch === null) p.gitBranch = entry.gitBranch;
296
+ else if (p.gitBranch !== entry.gitBranch) p.gitBranchChanged = true;
297
+ }
298
+ }
299
+
300
+ /** Turn boundaries and the longest silence inside them. */
301
+ function applyTiming(p, entry, isAssistant) {
302
+ const ts = msOf(entry);
303
+ if (ts === null) return;
304
+ if (p.tsSubmit === null || ts < p.tsSubmit) p.tsSubmit = ts;
305
+ if (p.tsEnd === null || ts > p.tsEnd) p.tsEnd = ts;
306
+ if (isAssistant && (p.tsFirstAssistant === null || ts < p.tsFirstAssistant)) p.tsFirstAssistant = ts;
307
+ // Largest silence between two consecutive entries of this turn. Same rule as
308
+ // the queued_s/work_s split, applied INSIDE the turn: a tool call waiting on
309
+ // a permission prompt is the user's time, not the tool's, and it lands in
310
+ // work_s indistinguishably. Not hypothetical — the live run over 29
311
+ // transcripts has a turn with work_s = 158999 (44 h). Measured here, judged
312
+ // in TASK-089: this file reports, it does not filter.
313
+ if (p.lastTs !== null && ts > p.lastTs) {
314
+ p.maxGap = Math.max(p.maxGap, ts - p.lastTs);
315
+ p.lastTs = ts;
316
+ } else if (p.lastTs === null) {
317
+ p.lastTs = ts;
318
+ }
319
+ }
320
+
321
+ /**
322
+ * prompt_chars: what the USER sent. The first non-meta user entry that is not a
323
+ * tool result — later user entries in the group ARE tool results (934 of 1012
324
+ * in the measured session) and are not the prompt.
325
+ */
326
+ function applyUserEntry(p, entry) {
327
+ if (p.promptChars !== null || entry.isMeta === true) return;
328
+ const content = entry.message?.content;
329
+ if (typeof content === 'string') p.promptChars = content.length;
330
+ else if (Array.isArray(content) && !content.some((b) => b?.type === 'tool_result')) {
331
+ p.promptChars = content.reduce((n, b) => n + (b?.type === 'text' ? (b.text?.length ?? 0) : 0), 0);
332
+ }
333
+ }
334
+
335
+ /**
336
+ * The subagent's own bill, off the `Agent` tool RESULT.
337
+ *
338
+ * A delegation leaves no turns in the parent transcript (0 `isSidechain`
339
+ * entries over the whole of `~/.claude/projects`), but the result entry carries
340
+ * `toolUseResult.usage` — the same structure as `message.usage`, cache split
341
+ * included — plus `resolvedModel`. So it is priced with the SAME `priceRow`
342
+ * that prices the parent turn: TASK-089 criterion 8 asks for no new pricing
343
+ * logic, and a second code path to dollars is a second set of prices.
344
+ *
345
+ * Three rules this obeys, all from COST_EVALUATION_PROCEDURE §11a:
346
+ *
347
+ * • **Deduplicated by `agentId`.** The result entry is written twice (8
348
+ * entries = 4 delegations, measured) — and, measured 2026-08-02, WITHIN one
349
+ * file rather than across files as first assumed, sometimes under a
350
+ * different `promptId`, which puts one delegation on two rows. The entry
351
+ * dedup in addEntry() removes the byte-identical copies; this keeps the
352
+ * guarantee for the ones it cannot see, i.e. copies with distinct uuids and
353
+ * rows collected from more than one file.
354
+ * • **A lower bound, not a cost.** `cache_read` grows LINEARLY with the
355
+ * subagent's tool-call count (13 → 37.6k, 19 → 59.5k, 26 → 76.5k), which is
356
+ * what one turn reading the whole accumulated context looks like — not a
357
+ * sum over turns, which would grow quadratically. So this is most likely
358
+ * the subagent's LAST turn. It rides on its own field and is never added to
359
+ * the row's cost.
360
+ * • **A result with no `agentId` is dropped, not guessed.** It cannot be
361
+ * deduplicated, and a double-counted lower bound is worse than a smaller
362
+ * one that is honest. Counted, so the omission is visible.
363
+ */
364
+ function applyDelegation(p, entry, tsMs) {
365
+ const result = entry.toolUseResult;
366
+ if (!result || typeof result !== 'object' || Array.isArray(result)) return;
367
+ const usage = result.usage;
368
+ if (!usage || typeof usage !== 'object') return;
369
+
370
+ const id = typeof result.agentId === 'string' ? result.agentId : '';
371
+ if (id === '') {
372
+ p.delegationsUnidentified++;
373
+ return;
374
+ }
375
+ if (p.delegations.has(id)) {
376
+ p.delegationDuplicates++;
377
+ return;
378
+ }
379
+
380
+ const tokens = emptyTokens();
381
+ addUsage(tokens, usage);
382
+ const model = typeof result.resolvedModel === 'string' ? result.resolvedModel : '';
383
+ // Shaped as a one-model row so priceRow answers about it unchanged — including
384
+ // the refusals: an unknown model, an unsplit cache write or a tier we cannot
385
+ // price yields `list_cost_usd: null` here for exactly the same reasons as on a
386
+ // parent turn, and lands in the same unpriced counter downstream.
387
+ const cost = priceRow(
388
+ {
389
+ by_model: { [model]: tokens },
390
+ speed: typeof usage.speed === 'string' ? usage.speed : null,
391
+ service_tier: usage.service_tier,
392
+ inference_geo: usage.inference_geo,
393
+ },
394
+ { at: tsMs },
395
+ );
396
+
397
+ p.delegations.set(id, {
398
+ agent_id: id,
399
+ agent_type: typeof result.agentType === 'string' ? result.agentType : '',
400
+ model,
401
+ status: typeof result.status === 'string' ? result.status : '',
402
+ duration_ms: Number.isFinite(result.totalDurationMs) ? result.totalDurationMs : null,
403
+ // The subagent's own tool calls — the quantity `cache_read` tracks linearly,
404
+ // i.e. the evidence for reading the figure above as a lower bound.
405
+ tool_calls_total: Number.isFinite(result.totalToolUseCount) ? result.totalToolUseCount : null,
406
+ ...tokens,
407
+ web_search_requests: usage.server_tool_use?.web_search_requests || 0,
408
+ ...cost,
409
+ });
410
+ }
411
+
412
+ /** Tools, delegations, model/effort, and the per-model token split. */
413
+ function applyAssistantEntry(p, entry, { usage, model, synthetic, repeatOfResponse }) {
414
+ for (const block of toolUseBlocks(entry)) {
415
+ const name = typeof block.name === 'string' ? block.name : '(unknown)';
416
+ p.tools.set(name, (p.tools.get(name) ?? 0) + 1);
417
+ p.toolCalls++;
418
+ if (AGENT_TOOLS.has(name)) p.agentSpawns++;
419
+ }
420
+
421
+ // Counted, then dropped from every billing figure: Claude Code wrote it, the
422
+ // API did not bill it.
423
+ if (synthetic) {
424
+ p.syntheticTurns++;
425
+ return;
426
+ }
427
+
428
+ // `usage` describes the RESPONSE, not the entry. One API response reaches the
429
+ // transcript as several entries — one per content block (thinking / text /
430
+ // each tool_use) — and every one of them carries an identical copy of the same
431
+ // usage block. The uuid dedup in addEntry() cannot see this class: those uuids
432
+ // differ by definition. Measured over 19 transcripts: 11 295 entries for 5 954
433
+ // responses = 1.90x on api_turns, and 2.02x on the dollar figure, because the
434
+ // per-class inflation is not flat (cache-read 1.82x, output 2.17x) — a
435
+ // many-block response is not a scaled copy of a one-block response.
436
+ //
437
+ // Not a constant that cancels in a ratio of two arms: the factor IS the number
438
+ // of content blocks per turn, i.e. how many tools the model reached for, which
439
+ // is the variable the doctrine sets out to move. Per-session spread on one
440
+ // project: 1.66x to 2.24x (error class 1 — a figure that prints like a fact).
441
+ //
442
+ // The tool_use loop above runs BEFORE this guard on purpose: tool_use blocks
443
+ // are NOT duplicated across a response's entries (6 403 blocks, 6 403 distinct
444
+ // ids, 0 repeats), so skipping the whole entry would drop ~2/3 of all tool
445
+ // calls and gut the adoption half of the analysis.
446
+ if (repeatOfResponse) return;
447
+
448
+ p.apiTurns++;
449
+ if (model) p.models.add(model);
450
+ if (typeof entry.effort === 'string') p.efforts.add(entry.effort);
451
+ if (!usage) return;
452
+ if (typeof usage.speed === 'string') p.speeds.add(usage.speed);
453
+ if (typeof usage.service_tier === 'string') p.tiers.add(usage.service_tier);
454
+ if (typeof usage.inference_geo === 'string') p.geos.add(usage.inference_geo);
455
+ // Per-model split, so a turn that switched models is still priceable exactly
456
+ // instead of being charged at one model's rate (error class 4). The row's
457
+ // flat totals are folded back out of this in toRow() — one source, no drift.
458
+ const key = model ?? '(unknown)';
459
+ if (!p.byModel.has(key)) p.byModel.set(key, { api_turns: 0, ...emptyTokens() });
460
+ const bucket = p.byModel.get(key);
461
+ bucket.api_turns++;
462
+ addUsage(bucket, usage);
463
+
464
+ if (Array.isArray(usage.iterations) && usage.iterations.length > 1) p.multiAttemptTurns++;
465
+ if (usageUnderstated(usage)) p.understatedTurns++;
466
+ // Billed per request, not per token, so they sit outside list_cost_by_class.
467
+ // Counted so the gap announces itself instead of hiding in a clean total.
468
+ const server = serverToolRequests(usage);
469
+ p.webSearchRequests += server.search;
470
+ p.webFetchRequests += server.fetch;
471
+ }
472
+
473
+ /**
474
+ * Streaming aggregator. One accumulator per turn, fed entry by entry in file
475
+ * order, so the caller never has to materialize a transcript (criterion 4).
476
+ *
477
+ * Attribution: `promptId` sits on `user` entries only — including the tool
478
+ * RESULTS, which come back as `user` entries, so it groups the whole turn.
479
+ * `assistant` entries carry no promptId and are resolved by walking `parentUuid`
480
+ * to the nearest ancestor that has one (2517/2517 in the measured session).
481
+ * The walk is O(1) per entry because every uuid seen is memoized with the group
482
+ * it resolved to, and parents always precede children in the file.
483
+ *
484
+ * Entries of ANY type are memoized, not just user/assistant: a `system` or
485
+ * `attachment` entry can sit in the middle of a chain, and skipping it would
486
+ * break the link for everything below it.
487
+ *
488
+ * An entry that resolves to nothing is counted into `unattributed` — with its
489
+ * tokens — and reported. Silently dropping it is the failure this file exists
490
+ * to avoid (criterion 2).
491
+ */
492
+ export function createCollector() {
493
+ // Keyed by (session, promptId), never by promptId alone. Nothing documents
494
+ // promptId as globally unique — it is unique across the 29 local transcripts,
495
+ // which is a property of that sample, not of the format. If two sessions ever
496
+ // shared one, keying on it alone would merge their rows, and since the arm is
497
+ // a property of the SESSION (EPIC-009, error class 6) that merge would blend
498
+ // two arms into a single row and look like ordinary data.
499
+ const prompts = new Map(); // key -> accumulator, in first-seen order
500
+ const groups = new Map(); // key -> {key, promptId, session}, shared by memoized uuids
501
+ let uuidGroup = new Map(); // uuid -> group | null; reset per file (see beginFile)
502
+ const unattributed = {
503
+ entries: 0,
504
+ turns: 0,
505
+ // Counted for the same reason the rows count them: tool_calls is an
506
+ // ADOPTION metric — how often the model reached for a tool, i.e. the very
507
+ // variable the doctrine sets out to move — so an entry that lands outside
508
+ // every row must still show up in that denominator. `web_search_requests`
509
+ // is a different kind of thing: it is MONEY ($10/1000, billed per request),
510
+ // and money that counting the entry's tokens does not cover, because no
511
+ // token class prices it (TASK-093).
512
+ tool_calls: 0,
513
+ // A strict subset of tool_calls, tracked separately for the same reason the
514
+ // rows track it: agent_spawns is one half of how big the subagent blind spot
515
+ // is (error class 3), and a spawn nobody counted is a whole delegation whose
516
+ // cost we do not merely under-report — we never learn it happened.
517
+ agent_spawns: 0,
518
+ web_search_requests: 0,
519
+ web_fetch_requests: 0,
520
+ ...emptyTokens(),
521
+ };
522
+ // Identity of every API response already billed. NOT reset in beginFile, and
523
+ // deliberately unlike uuidGroup: a uuid chain cannot cross a file, but nothing
524
+ // promises the same about a response id, and a global set costs one string per
525
+ // response. Measured across the 19 guidetrackee transcripts: 0 of 5 954 ids
526
+ // appear in more than one file, so today the scope changes no figure — it only
527
+ // removes a way for a future transcript layout to reopen the bug silently.
528
+ const seenResponses = new Set();
529
+ const totals = { malformed: 0, entries: 0, duplicates: 0, responseRepeats: 0, responsesUnidentified: 0, files: 0 };
530
+
531
+ function groupFor(entry) {
532
+ // Direct: `user` entries (prompt AND tool results) carry promptId.
533
+ const promptId = entry.promptId;
534
+ if (typeof promptId === 'string' && promptId !== '') {
535
+ const session = typeof entry.sessionId === 'string' ? entry.sessionId : null;
536
+ const key = `${session ?? ''}\u0000${promptId}`;
537
+ let group = groups.get(key);
538
+ if (!group) {
539
+ group = { key, promptId, session };
540
+ groups.set(key, group);
541
+ }
542
+ return group;
543
+ }
544
+ // Inherited: nearest ancestor with one. Ancestors are already memoized, and
545
+ // a memoized `null` correctly propagates "unattributable" down the chain.
546
+ const parent = entry.parentUuid;
547
+ return typeof parent === 'string' ? (uuidGroup.get(parent) ?? null) : null;
548
+ }
549
+
550
+ function addEntry(entry) {
551
+ totals.entries++;
552
+
553
+ // A transcript repeats entries VERBATIM, and not rarely: 11 296 of 36 463
554
+ // local entries (31%) carry a uuid already written earlier IN THE SAME FILE,
555
+ // 6 739 of them assistant turns with a usage block. The copies are identical
556
+ // apart from a cosmetic `slug` — same tokens, same model, same timestamp —
557
+ // so summing both charged 45% of all cache-read tokens and 42% of all output
558
+ // tokens twice. That is not a rounding error, it is the epic's whole effect
559
+ // size several times over (error class 1: a figure that prints like a fact).
560
+ //
561
+ // Scoped to the file on purpose: across the 34 local transcripts ZERO uuids
562
+ // are shared between files, so the index that already exists per file
563
+ // (uuidGroup, dropped in beginFile) is exactly the right one to ask. First
564
+ // occurrence wins — file order is event order, and a later copy carrying a
565
+ // different promptId is the copy, not the event.
566
+ //
567
+ // Counted, never silent: a run over a transcript that suddenly stops
568
+ // repeating itself must show that too.
569
+ if (typeof entry.uuid === 'string' && uuidGroup.has(entry.uuid)) {
570
+ totals.duplicates++;
571
+ return;
572
+ }
573
+
574
+ const group = groupFor(entry);
575
+ // Memoize BEFORE any type filtering: unknown types are links in the chain
576
+ // even when they carry no metric of their own.
577
+ if (typeof entry.uuid === 'string') uuidGroup.set(entry.uuid, group);
578
+
579
+ const type = entry.type;
580
+ const isAssistant = type === 'assistant';
581
+ // Everything else in a live transcript — queue-operation, ai-title,
582
+ // file-history-snapshot/delta, last-prompt, attachment, system, mode —
583
+ // carries no per-prompt metric. Skipped, never fatal.
584
+ if (type !== 'user' && !isAssistant) return;
585
+
586
+ const usage = entry.message?.usage;
587
+ const model = entry.message?.model ?? null;
588
+ const synthetic = isAssistant && model === SYNTHETIC_MODEL;
589
+
590
+ // Decided here, not inside applyAssistantEntry, because the unattributed
591
+ // branch below bills its own turns and tokens and never reaches that
592
+ // function — the same response split across blocks would inflate the one
593
+ // counter whose job is to report what fell outside the rows.
594
+ //
595
+ // `message.id` is the identity the API gives a response; `requestId` is the
596
+ // fallback for an entry that lacks it. Measured: the two are 1:1 (no id maps
597
+ // to two of the other, in either direction) and no entry has neither, so the
598
+ // fallback is belt-and-braces rather than load-bearing. An entry with NO id
599
+ // at all is counted rather than assumed unique — an unidentifiable response
600
+ // is not silently a new one — and reported via `responsesUnidentified`.
601
+ //
602
+ // Synthetic turns never enter the set: they are excluded from every billing
603
+ // figure anyway, and 8 of them carry a message.id (0 colliding with a real
604
+ // response) that would otherwise poison it.
605
+ let repeatOfResponse = false;
606
+ if (isAssistant && !synthetic) {
607
+ // Non-empty strings only, the same test groupFor() applies to promptId.
608
+ // `??` alone would accept `id: ''`, and an empty id shared by every entry
609
+ // in a file would collapse the whole file into ONE response — the bill
610
+ // would fall to near zero and read as a clean result. Under-counting is
611
+ // the worse direction: an over-count announces itself as implausible.
612
+ const id = entry.message?.id;
613
+ const req = entry.requestId;
614
+ const responseId =
615
+ (typeof id === 'string' && id !== '' && id) || (typeof req === 'string' && req !== '' && req) || null;
616
+ if (responseId === null) totals.responsesUnidentified++;
617
+ else if (seenResponses.has(responseId)) {
618
+ repeatOfResponse = true;
619
+ totals.responseRepeats++;
620
+ } else seenResponses.add(responseId);
621
+ }
622
+
623
+ if (group === null) {
624
+ unattributed.entries++;
625
+ // Per ENTRY, above the repeat guard and outside the synthetic test —
626
+ // exactly where the row path counts them, and for its reasons: tool_use
627
+ // blocks are NOT duplicated across the entries of one response
628
+ // (TASK-092), and a synthetic turn's tool call still happened even though
629
+ // nobody billed for it.
630
+ if (isAssistant) {
631
+ for (const block of toolUseBlocks(entry)) {
632
+ unattributed.tool_calls++;
633
+ if (AGENT_TOOLS.has(block.name)) unattributed.agent_spawns++;
634
+ }
635
+ }
636
+ if (isAssistant && !synthetic && !repeatOfResponse) {
637
+ unattributed.turns++;
638
+ if (usage) {
639
+ addUsage(unattributed, usage);
640
+ // Under the guard, the mirror image of the tool calls above:
641
+ // server_tool_use is part of `usage`, so it IS copied onto every entry
642
+ // of a split response, and counting it per entry would re-open
643
+ // TASK-092 in the one place we just closed it.
644
+ const server = serverToolRequests(usage);
645
+ unattributed.web_search_requests += server.search;
646
+ unattributed.web_fetch_requests += server.fetch;
647
+ }
648
+ }
649
+ return;
650
+ }
651
+
652
+ // Created here, not in groupFor(): an entry of an unknown type must never
653
+ // conjure a row of zeros just by carrying a promptId.
654
+ let p = prompts.get(group.key);
655
+ if (!p) {
656
+ p = newPrompt(group);
657
+ prompts.set(group.key, p);
658
+ }
659
+
660
+ applyIdentity(p, entry);
661
+ applyTiming(p, entry, isAssistant);
662
+ // Guarded like api_turns, and for the same reason: this is a TURN counter —
663
+ // it is the figure that says how big the subagent blind spot is (error class
664
+ // 3), paired with agent_spawns. A subagent response splits across content
665
+ // blocks like any other, and being tool-heavy it splits MORE, so counting it
666
+ // per entry would inflate the very number that measures what we cannot see.
667
+ // Zero isSidechain entries in all data measured so far, so this moves no
668
+ // current figure — it stops the bug from arriving with the first one.
669
+ if (entry.isSidechain === true && isAssistant && !repeatOfResponse) p.sidechainTurns++;
670
+ if (isAssistant) applyAssistantEntry(p, entry, { usage, model, synthetic, repeatOfResponse });
671
+ else {
672
+ applyUserEntry(p, entry);
673
+ // Tool results come back as `user` entries; the delegation's bill rides on
674
+ // the one belonging to an `Agent` call, priced at the moment it happened.
675
+ applyDelegation(p, entry, msOf(entry));
676
+ }
677
+ }
678
+
679
+ function addLine(line) {
680
+ const entry = parseEntry(line);
681
+ // A truncated or corrupted line costs exactly that line (criterion 3): a
682
+ // transcript is appended to live, so the last line can be half-written at
683
+ // any moment. Counted, so a run over garbage doesn't look like a clean run.
684
+ if (entry === null) {
685
+ if (typeof line === 'string' && line.trim() !== '') totals.malformed++;
686
+ return;
687
+ }
688
+ addEntry(entry);
689
+ }
690
+
691
+ function toRow(p) {
692
+ const queued_s = seconds(p.tsSubmit, p.tsFirstAssistant);
693
+ const work_s = seconds(p.tsFirstAssistant, p.tsEnd);
694
+ // Flat totals are the fold of the per-model split, not a second running
695
+ // sum: EPIC-009 criterion 2 forbids printing a token figure that disagrees
696
+ // with its own breakdown, and the cheapest way to guarantee that is to have
697
+ // only one number to disagree with.
698
+ const tokens = emptyTokens();
699
+ for (const bucket of p.byModel.values()) {
700
+ for (const field of Object.keys(tokens)) tokens[field] += bucket[field];
701
+ }
702
+ const row = {
703
+ promptId: p.promptId,
704
+ session: p.session,
705
+ cwd: p.cwd,
706
+ // Always written, empty string included: an absent arm marker is data
707
+ // (TASK-088), and a row that omits the field would be indistinguishable
708
+ // from a row the parser failed on.
709
+ gitBranch: p.gitBranch ?? '',
710
+ gitBranchChanged: p.gitBranchChanged,
711
+ cc_version: p.ccVersion,
712
+ ts_submit: isoOrNull(p.tsSubmit),
713
+ ts_first_assistant: isoOrNull(p.tsFirstAssistant),
714
+ ts_end: isoOrNull(p.tsEnd),
715
+ // Split deliberately. A single duration measured from submission swallows
716
+ // queue wait — 84 `enqueue` events in one measured session — and that is
717
+ // the user's time, not the tool's. TASK-089 compares work_s.
718
+ queued_s,
719
+ work_s,
720
+ max_gap_s: p.maxGap / 1000,
721
+ model: collapse(p.models),
722
+ models: [...p.models].sort(),
723
+ effort: collapse(p.efforts),
724
+ efforts: [...p.efforts].sort(),
725
+ speed: collapse(p.speeds),
726
+ service_tier: collapse(p.tiers),
727
+ inference_geo: collapse(p.geos),
728
+ api_turns: p.apiTurns,
729
+ synthetic_turns: p.syntheticTurns,
730
+ ...tokens,
731
+ by_model: Object.fromEntries([...p.byModel.entries()].sort((a, b) => a[0].localeCompare(b[0]))),
732
+ tools: Object.fromEntries([...p.tools.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))),
733
+ tool_calls_total: p.toolCalls,
734
+ agent_spawns: p.agentSpawns,
735
+ sidechain_turns: p.sidechainTurns,
736
+ // Deliberately NOT folded into the row's tokens or cost: what the
737
+ // subagents cost is a lower bound (see applyDelegation), and a lower bound
738
+ // added into a total stops being labelled as one. Sorted so two runs over
739
+ // the same transcripts produce byte-identical rows.
740
+ delegations: [...p.delegations.values()].sort((a, b) => a.agent_id.localeCompare(b.agent_id)),
741
+ delegation_duplicates_dropped: p.delegationDuplicates,
742
+ delegations_unidentified: p.delegationsUnidentified,
743
+ multi_attempt_turns: p.multiAttemptTurns,
744
+ understated_turns: p.understatedTurns,
745
+ web_search_requests: p.webSearchRequests,
746
+ web_fetch_requests: p.webFetchRequests,
747
+ prompt_chars: p.promptChars ?? 0,
748
+ };
749
+ // Priced from the row that was just built, so the dollars can only ever be
750
+ // a function of the published components — nothing is billed that the row
751
+ // does not also show (EPIC-009 criterion 2). Priced at the prompt's own
752
+ // timestamp, so re-running this next year yields the same money.
753
+ return { ...row, ...priceRow(row) };
754
+ }
755
+
756
+ return {
757
+ addLine,
758
+ addEntry,
759
+ /**
760
+ * Start of a new transcript. Drops the uuid index, because a parentUuid
761
+ * chain never crosses a file — so keeping it would only let one file's uuid
762
+ * capture another file's entry, and would grow the index across a whole
763
+ * scan (17 403 uuids over the 29 local transcripts) for no gain. The
764
+ * per-turn accumulators survive: rows accumulate across files by design.
765
+ */
766
+ beginFile() {
767
+ totals.files++;
768
+ uuidGroup = new Map();
769
+ },
770
+ result() {
771
+ const rows = [...prompts.values()].map(toRow);
772
+ const sum = (field) => rows.reduce((n, r) => n + r[field], 0);
773
+ const tokens = emptyTokens();
774
+ for (const row of rows) for (const field of Object.keys(tokens)) tokens[field] += row[field];
775
+ return {
776
+ rows,
777
+ // Reported on every run, never inferred from `rows` being short.
778
+ unattributed,
779
+ tokens,
780
+ cost: summarizeCost(rows),
781
+ // Rolled up with the SAME summarizer as the rows — a delegation record
782
+ // carries the cost fields summarizeCost reads, so the lower bound is
783
+ // built by the code that already knows how to refuse an unpriced row.
784
+ // Kept beside `cost`, never inside it (TASK-089 criterion 8b).
785
+ delegationCost: summarizeCost(rows.flatMap((r) => r.delegations)),
786
+ totals: {
787
+ ...totals,
788
+ prompts: rows.length,
789
+ agent_spawns: sum('agent_spawns'),
790
+ delegations_measured: rows.reduce((n, r) => n + r.delegations.length, 0),
791
+ delegation_duplicates_dropped: sum('delegation_duplicates_dropped'),
792
+ delegations_unidentified: sum('delegations_unidentified'),
793
+ sidechain_turns: sum('sidechain_turns'),
794
+ synthetic_turns: sum('synthetic_turns'),
795
+ multi_attempt_turns: sum('multi_attempt_turns'),
796
+ understated_turns: sum('understated_turns'),
797
+ web_search_requests: sum('web_search_requests'),
798
+ web_fetch_requests: sum('web_fetch_requests'),
799
+ },
800
+ };
801
+ },
802
+ };
803
+ }
804
+
805
+ /** Convenience wrapper for tests and small inputs: entries in, result out. */
806
+ export function aggregate(entries) {
807
+ const c = createCollector();
808
+ for (const entry of entries) c.addEntry(entry);
809
+ return c.result();
810
+ }
811
+
812
+ export function formatRow(row) {
813
+ return JSON.stringify(row);
814
+ }
815
+
816
+ /**
817
+ * Human-readable run summary. Goes to stderr so stdout stays parseable JSONL.
818
+ * Names the two things that are NOT in the numbers — unattributed entries and
819
+ * the uncaptured cost of delegation — because a summary that only reports what
820
+ * it measured is how a blind spot becomes a clean-looking result.
821
+ */
822
+ export function formatSummary({ totals, unattributed, cost, tokens, delegationCost }) {
823
+ const L = [];
824
+ L.push(
825
+ `prompt-metrics: ${totals.prompts} prompt(s) from ${totals.entries} entries` +
826
+ ` in ${totals.files} transcript(s)` +
827
+ (totals.malformed > 0 ? `; ${totals.malformed} malformed line(s) skipped` : ''),
828
+ );
829
+ if (totals.duplicates > 0) {
830
+ // Loud, because the figure below is ~1.8x smaller than it used to be and a
831
+ // reader comparing two runs of this tool deserves to know why.
832
+ L.push(
833
+ ` repeated entries skipped: ${totals.duplicates} (same uuid, already seen in this file)` +
834
+ ' — counting them charged the same tokens twice',
835
+ );
836
+ }
837
+ if (totals.responseRepeats > 0) {
838
+ // Its own line, never folded into the uuid count above: the two are
839
+ // different defects with different fixes, and this one halves the bill. A
840
+ // reader comparing this run against a pre-TASK-092 run must be able to see
841
+ // which of the two moved the number.
842
+ L.push(
843
+ ` extra entries of the same response: ${totals.responseRepeats}` +
844
+ ' (same message.id — one API response split across content blocks; usage counted once)',
845
+ );
846
+ }
847
+ if (totals.responsesUnidentified > 0) {
848
+ // Counted, not assumed unique: an entry with neither id could be a fresh
849
+ // response or the fourth copy of one already billed. We charge it (an
850
+ // unknown response is never priced at zero) and say so.
851
+ L.push(
852
+ ` assistant entries with no message.id and no requestId: ${totals.responsesUnidentified}` +
853
+ ' — billed as distinct responses; may double-count if they are split blocks',
854
+ );
855
+ }
856
+ L.push(
857
+ ` unattributed entries: ${unattributed.entries}` +
858
+ ` (${unattributed.turns} API turn(s), ${unattributed.output_tokens} output tokens) — not in any row`,
859
+ );
860
+ // Its own line, and only the parts that are non-zero — a clean run stays
861
+ // clean, and so does a run whose only gap is one of the three. Split from the
862
+ // line above because the halves are different kinds of gap: the tool calls are
863
+ // missing from an adoption DENOMINATOR, the searches are missing DOLLARS, and
864
+ // dollars that appear in no cost figure this tool prints, here or below.
865
+ const outside = [];
866
+ if (unattributed.tool_calls > 0) {
867
+ outside.push(
868
+ `${unattributed.tool_calls} tool call(s)` +
869
+ (unattributed.agent_spawns > 0
870
+ ? `, ${unattributed.agent_spawns} of them subagent spawn(s) whose cost is UNCAPTURED (class 3)`
871
+ : ''),
872
+ );
873
+ }
874
+ if (unattributed.web_search_requests > 0) {
875
+ outside.push(
876
+ `${unattributed.web_search_requests} web search(es) =` +
877
+ ` ${formatUsd(webSearchCost(unattributed.web_search_requests))} NOT in any cost figure below`,
878
+ );
879
+ }
880
+ if (unattributed.web_fetch_requests > 0) outside.push(`${unattributed.web_fetch_requests} web fetch(es) (free)`);
881
+ if (outside.length > 0) L.push(` also outside every row: ${outside.join(', ')}`);
882
+ L.push(` subagent spawns: ${totals.agent_spawns}; sidechain turns in transcript: ${totals.sidechain_turns}`);
883
+ if (totals.delegations_measured > 0) {
884
+ // Two numbers, never one: what we could price, and how far the spawns
885
+ // outrun it. A spawn with no result entry is cost we have NO figure for.
886
+ const missing = Math.max(0, totals.agent_spawns - totals.delegations_measured);
887
+ L.push(
888
+ ` delegations priced from their own usage: ${totals.delegations_measured}` +
889
+ ` (${totals.delegation_duplicates_dropped} duplicate entr(ies) dropped` +
890
+ `, ${totals.delegations_unidentified} without an agentId, not counted)`,
891
+ );
892
+ if (delegationCost) {
893
+ L.push(
894
+ ` subagent LOWER BOUND: ${formatUsd(delegationCost.list_cost_usd)} over` +
895
+ ` ${delegationCost.priced_rows} of ${delegationCost.total_rows} delegation(s)` +
896
+ ' — separate from the figure above, NEVER added to it (procedure §11a)',
897
+ );
898
+ }
899
+ if (missing > 0) {
900
+ L.push(` ${missing} spawn(s) left no priced result — their cost is UNCAPTURED, not zero (EPIC-009 class 3)`);
901
+ }
902
+ } else if (totals.agent_spawns > 0) {
903
+ L.push(' subagent cost is UNCAPTURED, not zero (EPIC-009 class 3)');
904
+ }
905
+ if (totals.synthetic_turns > 0) {
906
+ L.push(` <synthetic> turns excluded from tokens/turns: ${totals.synthetic_turns}`);
907
+ }
908
+ if (cost) L.push(...formatCostLines(cost, tokens));
909
+ if (totals.understated_turns > 0 || totals.multi_attempt_turns > 0) {
910
+ L.push(
911
+ ` turns where usage.iterations exceeds the reported usage: ${totals.understated_turns}` +
912
+ ` (${totals.multi_attempt_turns} with >1 attempt) — cost above is a known UNDERestimate`,
913
+ );
914
+ }
915
+ return L.join('\n');
916
+ }
917
+
918
+ /**
919
+ * Lines of a file, without ever holding more than a buffer of it. Transcripts
920
+ * reach tens of MB (16.6MB locally), and a tool whose own footprint grows with
921
+ * session length is the cost curve this project exists to fight — the same rule
922
+ * the context-reminder hook follows with readTail(). That hook needs only the
923
+ * tail; here every line matters, so it is a line stream rather than a slice.
924
+ */
925
+ export async function* readLines(path) {
926
+ const rl = createInterface({ input: createReadStream(path, 'utf8'), crlfDelay: Infinity });
927
+ try {
928
+ for await (const line of rl) yield line;
929
+ } finally {
930
+ rl.close();
931
+ }
932
+ }
933
+
934
+ export async function collectFiles(paths, collector = createCollector()) {
935
+ for (const path of paths) {
936
+ collector.beginFile();
937
+ for await (const line of readLines(path)) collector.addLine(line);
938
+ }
939
+ return collector;
940
+ }
941
+
942
+ /** Transcripts directly in `dir` or in its immediate subdirectories, sorted. */
943
+ export function findTranscripts(dir) {
944
+ if (!existsSync(dir)) return [];
945
+ const out = [];
946
+ const visit = (base, depth) => {
947
+ let names;
948
+ try {
949
+ names = readdirSync(base);
950
+ } catch {
951
+ return; // unreadable dir — skip, never fatal
952
+ }
953
+ for (const name of names.sort()) {
954
+ const full = join(base, name);
955
+ let st;
956
+ try {
957
+ st = statSync(full);
958
+ } catch {
959
+ continue;
960
+ }
961
+ if (st.isDirectory()) {
962
+ if (depth > 0) visit(full, depth - 1);
963
+ } else if (name.endsWith('.jsonl')) {
964
+ out.push(full);
965
+ }
966
+ }
967
+ };
968
+ visit(dir, 1);
969
+ return out;
970
+ }
971
+
972
+ function defaultDir() {
973
+ return join(claudeBaseDir(), 'projects');
974
+ }
975
+
976
+ async function main() {
977
+ let paths;
978
+ try {
979
+ const { values } = parseArgs({
980
+ args: process.argv.slice(2),
981
+ options: {
982
+ transcript: { type: 'string', multiple: true },
983
+ dir: { type: 'string' },
984
+ help: { type: 'boolean', short: 'h', default: false },
985
+ },
986
+ strict: true,
987
+ });
988
+ if (values.help) {
989
+ process.stdout.write(`${USAGE}\n`);
990
+ return;
991
+ }
992
+ // Both flags together are a UNION, deduplicated, not a precedence rule:
993
+ // silently ignoring one of two flags the user passed is how a run measures
994
+ // something other than what was asked for.
995
+ const explicit = values.transcript ?? [];
996
+ const scanned = values.dir !== undefined || explicit.length === 0 ? findTranscripts(values.dir ?? defaultDir()) : [];
997
+ paths = [...new Set([...explicit, ...scanned])];
998
+ const missing = paths.filter((p) => !existsSync(p));
999
+ if (missing.length > 0) {
1000
+ // Same shape as adoption-report's missing-log case: say where it looked,
1001
+ // change nothing else, exit 0. Note goes to stderr because stdout here is
1002
+ // a data channel, not a report.
1003
+ for (const p of missing) process.stderr.write(`prompt-metrics: no transcript at ${p}\n`);
1004
+ paths = paths.filter((p) => existsSync(p));
1005
+ }
1006
+ if (paths.length === 0) {
1007
+ process.stderr.write(
1008
+ `prompt-metrics: no transcripts found in ${values.dir ?? defaultDir()}.\n` +
1009
+ '(Claude Code writes them to <config dir>/projects/<slug>/<session>.jsonl.)\n',
1010
+ );
1011
+ return;
1012
+ }
1013
+ } catch (e) {
1014
+ process.stderr.write(`prompt-metrics: ${e.message}\n`);
1015
+ process.exitCode = 1;
1016
+ return;
1017
+ }
1018
+
1019
+ // Piping into `head` closes stdout early; that is a normal end, not a crash.
1020
+ process.stdout.on('error', (e) => {
1021
+ if (e.code !== 'EPIPE') throw e;
1022
+ });
1023
+
1024
+ const collector = await collectFiles(paths);
1025
+ const result = collector.result();
1026
+
1027
+ // The arm is added here, not in the collector: the collector turns a
1028
+ // transcript into rows and has no business knowing about the experiment, and
1029
+ // the ledger is machine state rather than transcript content. The split also
1030
+ // lets the same rules run over rows collected elsewhere (TASK-088).
1031
+ const ledgerPath = experimentPaths(claudeBaseDir()).ledgerPath;
1032
+ const ledgerAvailable = existsSync(ledgerPath);
1033
+ const rows = annotateRows(result.rows, {
1034
+ ledger: ledgerAvailable ? readLedger(claudeBaseDir()) : [],
1035
+ ledgerAvailable,
1036
+ });
1037
+
1038
+ for (const row of rows) process.stdout.write(`${formatRow(row)}\n`);
1039
+ process.stderr.write(`${formatSummary(result)}\n`);
1040
+ const arms = formatArmSummary(rows);
1041
+ if (arms !== null) process.stderr.write(`${arms}\n`);
1042
+ }
1043
+
1044
+ if (process.argv[1]?.endsWith('prompt-metrics.mjs')) {
1045
+ await main();
1046
+ }