pan-wizard 3.28.0 → 3.29.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,511 @@
1
+ 'use strict';
2
+ /**
3
+ * Cost ledger rebuild from Claude Code transcripts (`cost rebuild`, 2026-09).
4
+ *
5
+ * Every row the SubagentStop hooks wrote before v3.29 was a slice of the PARENT
6
+ * session transcript booked to whichever subagent happened to stop, and every
7
+ * usage record was counted once per content block rather than once per API
8
+ * turn. Those rows cannot be corrected in place — but the transcripts they were
9
+ * cut from still exist under `<claude config dir>/projects/<encoded cwd>/` for
10
+ * as long as Claude Code keeps them (its `cleanupPeriodDays`, 30 by default):
11
+ * <session>.jsonl the main thread
12
+ * <session>/subagents/agent-<id>.jsonl a Task/Agent-tool subagent
13
+ * <session>/subagents/workflows/<wf>/agent-<id>.jsonl a Workflow-tool subagent
14
+ * <session>/workflows/<wf>.json the run record (labels, model)
15
+ * The main thread pairs each `Agent` tool_use (subagent_type) with its result
16
+ * (`toolUseResult.agentId`, `resolvedModel`), so each agent file can be typed.
17
+ *
18
+ * This module rebuilds the ledger from those files: one row per agent file with
19
+ * its exact usage (deduped by `message.id`, the same rule the hooks apply since
20
+ * v3.29), its type, model and measured span, plus — by default — one row per
21
+ * session for the main thread's own usage, which the old parent slices had been
22
+ * the only place to capture. Hook rows of a rebuilt session are superseded;
23
+ * rows of sessions whose transcripts are gone, and rows appended by callers,
24
+ * are kept. The previous ledger is copied aside as dated evidence, the way
25
+ * hygiene's quarantine does, and never overwritten by a later copy. Dry-run
26
+ * unless `apply` is set. Never throws on a bad transcript: an unreadable file
27
+ * contributes nothing and is named in the session's `warnings`.
28
+ *
29
+ * Which sessions count: those the ledger already names, plus any session of the
30
+ * project's OWN transcript folder(s) that has agent files. A folder reached only
31
+ * because the ledger names a session in it contributes that session and nothing
32
+ * else — a ledger copied from another project must not import that project's
33
+ * whole agent history. A plain chat session with neither agents nor ledger rows
34
+ * never involved PAN agents and is left out, so the ledger stays a record of
35
+ * agent work and the sessions around it, not of every conversation.
36
+ */
37
+ const fs = require('fs');
38
+ const os = require('os');
39
+ const path = require('path');
40
+ const { output, error, loadConfig } = require('./core.cjs');
41
+ const { planningPath } = require('./utils.cjs');
42
+ const { readRecords, computeCost, effectiveRates, isSuspectRecord, isEmptyRecord, METRICS_DIR, TOKENS_FILE } = require('./cost.cjs');
43
+
44
+ const SCHEMA_V = 4; // matches hooks/pan-cost-logger.js SCHEMA_V
45
+ const MAIN_THREAD_AGENT = '(main thread)';
46
+ const AGENT_FILE = /^agent-([A-Za-z0-9][A-Za-z0-9._-]*)\.jsonl$/;
47
+ const SESSION_FILE = /^(?!agent-)[A-Za-z0-9][A-Za-z0-9-]*\.jsonl$/;
48
+ // Claude Code writes this as the model of an interruption / API-error record;
49
+ // it is not a model and cannot be priced.
50
+ const SYNTHETIC_MODEL = '<synthetic>';
51
+
52
+ function claudeConfigDir(opts) {
53
+ if (opts && opts.claudeDir) return opts.claudeDir;
54
+ if (process.env.CLAUDE_CONFIG_DIR) return process.env.CLAUDE_CONFIG_DIR;
55
+ return path.join(os.homedir(), '.claude');
56
+ }
57
+
58
+ /** Claude Code's project-directory name for a working directory: every character
59
+ * outside [A-Za-z0-9-] becomes '-' (`D:\my_proj` → `D--my-proj`, `/home/u/p` → `-home-u-p`). */
60
+ function encodeProjectDirName(cwd) {
61
+ return path.resolve(cwd).replace(/[^A-Za-z0-9-]/g, '-');
62
+ }
63
+
64
+ /** The folder names Claude Code may have used for `cwd`: the path as given and its
65
+ * resolved real path. Claude Code names the folder after `process.cwd()`, which is the
66
+ * real path (macOS: `/var/…` → `/private/var/…`), while a caller may pass either spelling. */
67
+ function projectDirNames(cwd) {
68
+ const names = new Set([encodeProjectDirName(cwd).toLowerCase()]);
69
+ try { names.add(encodeProjectDirName(fs.realpathSync(cwd)).toLowerCase()); } catch { /* unresolvable — the given spelling alone */ }
70
+ return names;
71
+ }
72
+
73
+ function tierForModel(model) {
74
+ if (typeof model !== 'string' || !model) return null;
75
+ if (/opus|fable|mythos/i.test(model)) return 'reasoning';
76
+ if (/sonnet/i.test(model)) return 'mid';
77
+ if (/haiku/i.test(model)) return 'fast';
78
+ return null;
79
+ }
80
+
81
+ /**
82
+ * Parse a JSONL file line by line from a Buffer. A whole-file string would hit
83
+ * Node's string ceiling (512 MB) on a long session — one on this machine passed
84
+ * 187 MB in 24 days — and vanish as "unreadable"; a Buffer holds up to 2 GB.
85
+ * Returns { entries, error }: `error` is the fs error code when the file could
86
+ * not be read at all, so the caller can say so instead of reporting zeros.
87
+ */
88
+ function readJsonl(file) {
89
+ let buf;
90
+ try { buf = fs.readFileSync(file); } catch (e) { return { entries: [], error: e && e.code ? e.code : 'unreadable' }; }
91
+ const entries = [];
92
+ let start = 0;
93
+ while (start < buf.length) {
94
+ let end = buf.indexOf(0x0a, start);
95
+ if (end === -1) end = buf.length;
96
+ if (end > start) {
97
+ let s = buf.toString('utf8', start, end);
98
+ if (s.charCodeAt(s.length - 1) === 13) s = s.slice(0, -1);
99
+ if (s.trim()) {
100
+ try { entries.push(JSON.parse(s)); } catch { /* torn line */ }
101
+ }
102
+ }
103
+ start = end + 1;
104
+ }
105
+ return { entries, error: null };
106
+ }
107
+
108
+ /**
109
+ * Sum a transcript's usage once per API turn (message.id), last snapshot wins.
110
+ * The model is the one most usage records name, ignoring `<synthetic>` — an
111
+ * agent whose last record is an interruption must not price as unknown.
112
+ */
113
+ function sumTranscriptUsage(file) {
114
+ const totals = { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_write_tokens: 0, model: null, first_ts: null, last_ts: null, turns: 0, unreadable: false };
115
+ const { entries, error: readError } = readJsonl(file);
116
+ if (readError) { totals.unreadable = true; return totals; }
117
+ const byMessage = new Map();
118
+ const modelCounts = new Map();
119
+ let unkeyed = 0;
120
+ for (const entry of entries) {
121
+ if (!entry || typeof entry !== 'object') continue;
122
+ if (typeof entry.timestamp === 'string') {
123
+ if (!totals.first_ts) totals.first_ts = entry.timestamp;
124
+ totals.last_ts = entry.timestamp;
125
+ }
126
+ const usage = entry.message && entry.message.usage && typeof entry.message.usage === 'object' ? entry.message.usage : null;
127
+ if (!usage) continue;
128
+ const model = entry.message && typeof entry.message.model === 'string' ? entry.message.model : null;
129
+ if (model && model !== SYNTHETIC_MODEL) modelCounts.set(model, (modelCounts.get(model) || 0) + 1);
130
+ const id = typeof entry.message.id === 'string' && entry.message.id ? entry.message.id : `__unkeyed_${unkeyed++}`;
131
+ byMessage.set(id, usage);
132
+ }
133
+ const num = (u, k) => (typeof u[k] === 'number' && Number.isFinite(u[k]) ? u[k] : 0);
134
+ for (const u of byMessage.values()) {
135
+ totals.input_tokens += num(u, 'input_tokens');
136
+ totals.output_tokens += num(u, 'output_tokens');
137
+ totals.cache_read_tokens += num(u, 'cache_read_input_tokens');
138
+ totals.cache_write_tokens += num(u, 'cache_creation_input_tokens');
139
+ }
140
+ totals.turns = byMessage.size;
141
+ let best = null;
142
+ for (const [m, n] of modelCounts) if (!best || n >= best.n) best = { m, n }; // ties → last seen
143
+ totals.model = best ? best.m : null;
144
+ return totals;
145
+ }
146
+
147
+ /** agentId → { type, model } from the main thread's Agent tool_use / tool_result pairs. */
148
+ function mapAgentsFromParent(file) {
149
+ const uses = new Map();
150
+ const agents = new Map();
151
+ for (const entry of readJsonl(file).entries) {
152
+ if (!entry || typeof entry !== 'object') continue;
153
+ const content = entry.message && Array.isArray(entry.message.content) ? entry.message.content : [];
154
+ for (const block of content) {
155
+ if (block && block.type === 'tool_use' && (block.name === 'Agent' || block.name === 'Task') && typeof block.id === 'string') {
156
+ uses.set(block.id, { type: block.input && block.input.subagent_type ? String(block.input.subagent_type) : null });
157
+ }
158
+ }
159
+ const result = entry.toolUseResult;
160
+ if (result && typeof result === 'object' && typeof result.agentId === 'string') {
161
+ // A user record can carry several tool_result blocks (a Bash result beside
162
+ // the Agent's); take the one that answers an Agent call.
163
+ const ref = content.find((b) => b && b.type === 'tool_result' && uses.has(b.tool_use_id));
164
+ const use = ref ? uses.get(ref.tool_use_id) : null;
165
+ agents.set(result.agentId, {
166
+ type: use && use.type ? use.type : null,
167
+ model: typeof result.resolvedModel === 'string' ? result.resolvedModel : null,
168
+ });
169
+ }
170
+ }
171
+ return agents;
172
+ }
173
+
174
+ /** agentId → { label, model, workflow } from the session's Workflow run records.
175
+ * The run record's shape is the Workflow tool's, not PAN's, so every array-valued
176
+ * field is scanned for entries that name an `agentId` (observed under
177
+ * `workflowProgress` as `{type:"workflow_agent", agentId, label, model, …}`). */
178
+ function mapAgentsFromWorkflowRuns(sessionDir) {
179
+ const agents = new Map();
180
+ const runsDir = path.join(sessionDir, 'workflows');
181
+ let files = [];
182
+ try { files = fs.readdirSync(runsDir).filter((f) => /^wf_.*\.json$/.test(f)); } catch { return agents; }
183
+ for (const f of files) {
184
+ let run;
185
+ try { run = JSON.parse(fs.readFileSync(path.join(runsDir, f), 'utf8')); } catch { continue; }
186
+ if (!run || typeof run !== 'object') continue;
187
+ const name = typeof run.workflowName === 'string' ? run.workflowName : null;
188
+ for (const value of Object.values(run)) {
189
+ if (!Array.isArray(value)) continue;
190
+ for (const l of value) {
191
+ if (l && typeof l === 'object' && typeof l.agentId === 'string' && !agents.has(l.agentId)) {
192
+ agents.set(l.agentId, { label: typeof l.label === 'string' ? l.label : null, model: typeof l.model === 'string' ? l.model : null, workflow: name });
193
+ }
194
+ }
195
+ }
196
+ }
197
+ return agents;
198
+ }
199
+
200
+ /** Every agent transcript of a session: direct Task/Agent subagents and Workflow-tool subagents one level down. */
201
+ function listAgentFiles(sessionDir) {
202
+ const out = [];
203
+ const subagents = path.join(sessionDir, 'subagents');
204
+ let entries = [];
205
+ try { entries = fs.readdirSync(subagents); } catch { return out; }
206
+ for (const e of entries) {
207
+ const m = AGENT_FILE.exec(e);
208
+ if (m) out.push({ file: path.join(subagents, e), agentId: m[1], workflow: null });
209
+ }
210
+ const wfRoot = path.join(subagents, 'workflows');
211
+ let runs = [];
212
+ try { runs = fs.readdirSync(wfRoot); } catch { return out; }
213
+ for (const run of runs) {
214
+ let files = [];
215
+ try { files = fs.readdirSync(path.join(wfRoot, run)); } catch { continue; }
216
+ for (const e of files) {
217
+ const m = AGENT_FILE.exec(e);
218
+ if (m) out.push({ file: path.join(wfRoot, run, e), agentId: m[1], workflow: run });
219
+ }
220
+ }
221
+ return out;
222
+ }
223
+
224
+ /** Directories the cost cursor's SESSION transcripts live in (agent-transcript keys
225
+ * point inside a session folder and are skipped — they are not project folders). */
226
+ function readCursorDirs(cwd) {
227
+ const dirs = new Set();
228
+ try {
229
+ const cursor = JSON.parse(fs.readFileSync(planningPath(cwd, METRICS_DIR, '.cost-cursor.json'), 'utf8'));
230
+ for (const key of Object.keys(cursor)) {
231
+ if (!/\.jsonl$/i.test(key) || /^agent-/.test(path.basename(key))) continue;
232
+ if (fs.existsSync(key)) dirs.add(path.dirname(key));
233
+ }
234
+ } catch { /* no cursor */ }
235
+ return dirs;
236
+ }
237
+
238
+ const dirKey = (d) => (process.platform === 'win32' ? path.resolve(d).toLowerCase() : path.resolve(d));
239
+
240
+ /**
241
+ * Sessions of this project that can be rebuilt: { sessionId, transcript, sessionDir, dir, agentFiles }.
242
+ * Swept directories (every agent-bearing session counts): the encoded cwd under the
243
+ * projects root — under the given spelling and its real path (Claude Code uses the real
244
+ * path), case-insensitive (on Windows two spellings of one folder were observed) and every directory a cost-cursor SESSION transcript points into.
245
+ * Named-only directories (just the sessions the ledger names): any directory that
246
+ * holds such a session but is neither of the above — a ledger copied from another
247
+ * project must not pull that project's whole history in.
248
+ */
249
+ function discoverSessions(cwd, ledgerSessions, opts) {
250
+ const projectsRoot = path.join(claudeConfigDir(opts), 'projects');
251
+ const wanted = projectDirNames(cwd);
252
+ const dirs = new Map(); // dirKey → { dir, sweep }
253
+ const add = (d, sweep) => {
254
+ const k = dirKey(d);
255
+ const cur = dirs.get(k);
256
+ if (!cur) dirs.set(k, { dir: path.resolve(d), sweep });
257
+ else if (sweep && !cur.sweep) cur.sweep = true;
258
+ };
259
+ let all = [];
260
+ try { all = fs.readdirSync(projectsRoot); } catch { all = []; }
261
+ for (const name of all) if (wanted.has(name.toLowerCase())) add(path.join(projectsRoot, name), true);
262
+ for (const d of readCursorDirs(cwd)) add(d, true);
263
+ for (const sid of ledgerSessions) {
264
+ for (const name of all) if (fs.existsSync(path.join(projectsRoot, name, `${sid}.jsonl`))) add(path.join(projectsRoot, name), false);
265
+ }
266
+ const sessions = new Map();
267
+ for (const { dir, sweep } of dirs.values()) {
268
+ let files = [];
269
+ try { files = fs.readdirSync(dir).filter((f) => SESSION_FILE.test(f)); } catch { continue; }
270
+ for (const f of files) {
271
+ const sid = f.slice(0, -'.jsonl'.length);
272
+ const named = ledgerSessions.has(sid);
273
+ if (!named && !sweep) continue; // a foreign folder contributes only the sessions the ledger names
274
+ const sessionDir = path.join(dir, sid);
275
+ const agentFiles = listAgentFiles(sessionDir);
276
+ if (!named && agentFiles.length === 0) continue; // a plain chat session — never involved PAN agents
277
+ if (!sessions.has(sid)) sessions.set(sid, { sessionId: sid, transcript: path.join(dir, f), sessionDir, dir, agentFiles });
278
+ }
279
+ }
280
+ return { projectsRoot, candidateDirs: [...dirs.values()].map((d) => d.dir), sessions: [...sessions.values()] };
281
+ }
282
+
283
+ function makeRow(fields) {
284
+ return {
285
+ v: SCHEMA_V,
286
+ ts: fields.ts,
287
+ agent: fields.agent,
288
+ agent_id: fields.agent_id,
289
+ command: fields.command,
290
+ model: fields.model,
291
+ tier: tierForModel(fields.model),
292
+ input_tokens: fields.usage.input_tokens,
293
+ output_tokens: fields.usage.output_tokens,
294
+ cache_read_tokens: fields.usage.cache_read_tokens,
295
+ cache_write_tokens: fields.usage.cache_write_tokens,
296
+ cost_usd: null,
297
+ duration_ms: fields.usage.first_ts && fields.usage.last_ts ? Date.parse(fields.usage.last_ts) - Date.parse(fields.usage.first_ts) : null,
298
+ phase: null,
299
+ session: fields.session,
300
+ source: 'rebuild',
301
+ token_source: fields.token_source,
302
+ clamped: false,
303
+ event_sig: null,
304
+ };
305
+ }
306
+
307
+ function buildRowsForSession(s, opts) {
308
+ const rows = [];
309
+ const warnings = [];
310
+ const fromParent = mapAgentsFromParent(s.transcript);
311
+ const fromRuns = mapAgentsFromWorkflowRuns(s.sessionDir);
312
+ for (const a of s.agentFiles) {
313
+ const usage = sumTranscriptUsage(a.file);
314
+ if (usage.unreadable) { warnings.push(`agent transcript unreadable: ${path.basename(a.file)}`); continue; }
315
+ if (usage.turns === 0) continue; // an agent that never made a call is not a spawn worth a row
316
+ const parent = fromParent.get(a.agentId) || {};
317
+ const run = fromRuns.get(a.agentId) || {};
318
+ const model = usage.model || parent.model || run.model || null;
319
+ rows.push(makeRow({
320
+ ts: usage.last_ts || usage.first_ts || new Date().toISOString(),
321
+ agent: parent.type || (a.workflow ? 'workflow-subagent' : 'subagent'),
322
+ agent_id: a.agentId,
323
+ command: run.workflow || null,
324
+ model,
325
+ usage,
326
+ session: s.sessionId,
327
+ token_source: 'agent-transcript',
328
+ }));
329
+ }
330
+ if (opts.mainThread !== false) {
331
+ const main = sumTranscriptUsage(s.transcript);
332
+ if (main.unreadable) warnings.push('session transcript unreadable — no main-thread row, agents typed from run records only');
333
+ else if (main.turns > 0) {
334
+ // Dated to the session's LAST record with the whole span as duration: one row
335
+ // for the session's own usage, not a per-day series (documented in CLI-REFERENCE).
336
+ rows.push(makeRow({
337
+ ts: main.last_ts || main.first_ts || new Date().toISOString(),
338
+ agent: MAIN_THREAD_AGENT,
339
+ agent_id: null,
340
+ command: null,
341
+ model: main.model,
342
+ usage: main,
343
+ session: s.sessionId,
344
+ token_source: 'session-transcript',
345
+ }));
346
+ }
347
+ }
348
+ return { rows, warnings };
349
+ }
350
+
351
+ /** A row the rebuild may replace: written by a hook or an earlier rebuild for a known session.
352
+ * Any versioned row with a session is such a row — `v` exists only on hook-written rows. */
353
+ function isSupersedable(row) {
354
+ if (!row || typeof row.session !== 'string' || !row.session) return false;
355
+ return row.source === 'hook' || row.source === 'rebuild' || row.v != null;
356
+ }
357
+
358
+ function ledgerFile(cwd) { return path.join(planningPath(cwd, METRICS_DIR), TOKENS_FILE); }
359
+
360
+ function readLedgerLines(cwd) {
361
+ try { return fs.readFileSync(ledgerFile(cwd), 'utf8').split('\n').filter((l) => l.trim()); } catch { return []; }
362
+ }
363
+
364
+ /**
365
+ * Plan a rebuild: which sessions, what the ledger says now, what it would say after.
366
+ * Pure apart from reads; `applyRebuild` performs the write.
367
+ */
368
+ function planRebuild(cwd, opts = {}) {
369
+ const snapshotLines = readLedgerLines(cwd);
370
+ const records = readRecords(cwd);
371
+ const rates = effectiveRates(loadConfig(cwd));
372
+ // Priced the way `cost report` prices: quarantined and unmeasured rows count for
373
+ // nothing, so "before" is what the report shows today, not the raw file sum.
374
+ const price = (r) => {
375
+ if (isSuspectRecord(r) || isEmptyRecord(r)) return 0;
376
+ const c = computeCost(r, rates);
377
+ return typeof c === 'number' ? c : 0;
378
+ };
379
+ const ledgerSessions = new Set(records.map((r) => r.session).filter((s) => typeof s === 'string' && s));
380
+ const found = discoverSessions(cwd, ledgerSessions, opts);
381
+ const rebuilt = new Set(found.sessions.map((s) => s.sessionId));
382
+
383
+ const sessions = [];
384
+ const newRows = [];
385
+ for (const s of found.sessions) {
386
+ const { rows, warnings } = buildRowsForSession(s, opts);
387
+ const old = records.filter((r) => isSupersedable(r) && r.session === s.sessionId);
388
+ sessions.push({
389
+ session: s.sessionId,
390
+ transcript: s.transcript,
391
+ agent_files: s.agentFiles.length,
392
+ superseded_rows: old.length,
393
+ rebuilt_rows: rows.length,
394
+ old_cost_usd: round(old.reduce((a, r) => a + price(r), 0)),
395
+ new_cost_usd: round(rows.reduce((a, r) => a + price(r), 0)),
396
+ main_thread_cost_usd: round(rows.filter((r) => r.agent === MAIN_THREAD_AGENT).reduce((a, r) => a + price(r), 0)),
397
+ warnings,
398
+ });
399
+ newRows.push(...rows);
400
+ }
401
+ const kept = records.filter((r) => !(isSupersedable(r) && rebuilt.has(r.session)));
402
+ const keptHook = kept.filter((r) => isSupersedable(r)).length;
403
+ const ledger = [...kept, ...newRows].sort((a, b) => String(a.ts || '').localeCompare(String(b.ts || '')));
404
+ return {
405
+ dry_run: !opts.apply,
406
+ claude_projects_dir: found.projectsRoot,
407
+ candidate_dirs: found.candidateDirs,
408
+ main_thread_rows: opts.mainThread !== false,
409
+ sessions,
410
+ kept_rows: { hook_without_transcript: keptHook, caller: kept.length - keptHook },
411
+ totals: {
412
+ old_rows: records.length,
413
+ new_rows: ledger.length,
414
+ old_cost_usd: round(records.reduce((a, r) => a + price(r), 0)),
415
+ new_cost_usd: round(ledger.reduce((a, r) => a + price(r), 0)),
416
+ },
417
+ _ledger: ledger,
418
+ _snapshot: snapshotLines,
419
+ };
420
+ }
421
+
422
+ function round(n) { return Math.round(n * 10000) / 10000; }
423
+
424
+ /**
425
+ * Write the rebuilt ledger. The current file is copied aside first (a later copy
426
+ * never overwrites an earlier one), rows a live hook appended since the plan was
427
+ * read are carried forward, and the write goes through a temp file + rename so a
428
+ * crash leaves either the old ledger or the new one, never a torn file. A plan
429
+ * that changes nothing writes nothing.
430
+ * Returns { written, backup, carried_forward }.
431
+ */
432
+ function applyRebuild(cwd, plan) {
433
+ const file = ledgerFile(cwd);
434
+ const exists = fs.existsSync(file);
435
+ if (!exists && plan.sessions.length === 0) return { written: false, backup: null, carried_forward: 0 };
436
+
437
+ // Rows appended after the plan's snapshot (a SubagentStop landing mid-rebuild).
438
+ const current = readLedgerLines(cwd);
439
+ const seen = new Map();
440
+ for (const l of plan._snapshot || []) seen.set(l, (seen.get(l) || 0) + 1);
441
+ const carried = [];
442
+ for (const l of current) {
443
+ const n = seen.get(l) || 0;
444
+ if (n > 0) seen.set(l, n - 1);
445
+ else { try { carried.push(JSON.parse(l)); } catch { /* torn line — drop */ } }
446
+ }
447
+ const rows = [...plan._ledger, ...carried].sort((a, b) => String(a.ts || '').localeCompare(String(b.ts || '')));
448
+ const content = rows.map((r) => JSON.stringify(r)).join('\n') + (rows.length ? '\n' : '');
449
+ if (exists && content === current.join('\n') + (current.length ? '\n' : '')) {
450
+ return { written: false, backup: null, carried_forward: carried.length, unchanged: true };
451
+ }
452
+
453
+ const dir = path.dirname(file);
454
+ fs.mkdirSync(dir, { recursive: true });
455
+ let backup = null;
456
+ if (exists) {
457
+ const stamp = new Date().toISOString().slice(0, 10);
458
+ backup = `${file}.rebuilt-${stamp}`;
459
+ for (let n = 2; fs.existsSync(backup); n++) backup = `${file}.rebuilt-${stamp}-${n}`;
460
+ fs.copyFileSync(file, backup);
461
+ }
462
+ const tmp = `${file}.${process.pid}.tmp`;
463
+ fs.writeFileSync(tmp, content, 'utf8');
464
+ fs.renameSync(tmp, file);
465
+ return { written: true, backup, carried_forward: carried.length };
466
+ }
467
+
468
+ function renderPlan(plan) {
469
+ const lines = [];
470
+ const mode = plan.dry_run ? 'DRY RUN (pass --apply to write)' : plan.written ? 'APPLIED' : 'APPLIED — nothing changed';
471
+ lines.push(`Cost ledger rebuild — ${mode}`);
472
+ lines.push(` Transcripts under: ${plan.claude_projects_dir}`);
473
+ lines.push(` Sessions rebuilt : ${plan.sessions.length}${plan.main_thread_rows ? ' (one main-thread row each, dated at session end)' : ''}`);
474
+ for (const s of plan.sessions) {
475
+ lines.push(` ${s.session.slice(0, 8)} agent files ${String(s.agent_files).padStart(3)} rows ${String(s.superseded_rows).padStart(3)} → ${String(s.rebuilt_rows).padStart(3)} cost $${s.old_cost_usd.toFixed(2)} → $${s.new_cost_usd.toFixed(2)}${plan.main_thread_rows ? ` (main thread $${s.main_thread_cost_usd.toFixed(2)})` : ''}`);
476
+ for (const w of s.warnings || []) lines.push(` ! ${w}`);
477
+ }
478
+ lines.push(` Kept as-is : ${plan.kept_rows.hook_without_transcript} hook row(s) whose session transcript is gone, ${plan.kept_rows.caller} caller-appended row(s)`);
479
+ lines.push(` Ledger : ${plan.totals.old_rows} → ${plan.totals.new_rows} rows, $${plan.totals.old_cost_usd.toFixed(2)} → $${plan.totals.new_cost_usd.toFixed(2)}`);
480
+ if (plan.carried_forward) lines.push(` Carried forward : ${plan.carried_forward} row(s) a hook appended while the plan was being read`);
481
+ if (plan.backup) lines.push(` Previous ledger : ${plan.backup}`);
482
+ return lines.join('\n');
483
+ }
484
+
485
+ /** `cost rebuild [--apply] [--no-main-thread] [--claude-dir <path>]` */
486
+ function cmdCostRebuild(cwd, opts = {}, raw) {
487
+ let plan;
488
+ try {
489
+ plan = planRebuild(cwd, opts);
490
+ if (opts.apply) Object.assign(plan, applyRebuild(cwd, plan));
491
+ } catch (e) {
492
+ return error(`cost rebuild failed: ${e.message}`);
493
+ }
494
+ const { _ledger, _snapshot, ...result } = plan;
495
+ output(result, raw, renderPlan(plan));
496
+ }
497
+
498
+ module.exports = {
499
+ encodeProjectDirName,
500
+ readJsonl,
501
+ sumTranscriptUsage,
502
+ mapAgentsFromParent,
503
+ mapAgentsFromWorkflowRuns,
504
+ listAgentFiles,
505
+ discoverSessions,
506
+ planRebuild,
507
+ applyRebuild,
508
+ renderPlan,
509
+ cmdCostRebuild,
510
+ MAIN_THREAD_AGENT,
511
+ };
@@ -330,23 +330,67 @@ function readRecords(cwd) {
330
330
  */
331
331
  /**
332
332
  * A record is "suspect" when its token counts are physically implausible for a
333
- * single subagent — the signature of the pre-v3.12.4 transcript-oversum bug
334
- * (billions of cache-read, cache-read dwarfing input, 100% cache-hit). Such
335
- * records are quarantined from aggregates so a poisoned ledger can't report
336
- * millions of dollars. See docs/FIELD-REPORT-army-2026-06.md.
333
+ * single subagent — the oversum signature: a session's cumulative usage booked
334
+ * to one subagent row (billions of cache-read, cache-read dwarfing input, 100%
335
+ * cache-hit). Written by pre-v3.12.4 hooks, and by the parent-transcript slice
336
+ * path of every later hook up to v3.28 whenever a slice started at cursor 0 on a
337
+ * long-lived session. Such records are quarantined from aggregates so a
338
+ * poisoned ledger can't report millions of dollars.
339
+ * See docs/FIELD-REPORT-army-2026-06.md.
337
340
  * @param {Object} r - a cost record
338
341
  * @returns {boolean}
339
342
  */
343
+ // No single subagent runs for six hours — the longest native-workflow phase runs
344
+ // measured in the harness finish inside an hour — while a parent-transcript slice
345
+ // that spans a working day, or the idle night between two stops, does. Mirrored by
346
+ // the hooks' SLICE_MAX_DURATION_MS, which nulls the span on write. Calibrated on
347
+ // eleven field ledgers (2026-09): of the timed rows the old ratio rule flagged,
348
+ // 55 spanned under three hours and 12 spanned six to twenty-four — the latter all
349
+ // parent slices booked to a `general-purpose` or workflow subagent.
350
+ const SUSPECT_MAX_DURATION_MS = 6 * 60 * 60 * 1000;
351
+
340
352
  function isSuspectRecord(r) {
341
353
  if (!r || typeof r !== 'object') return false;
354
+ // Rows measured from a transcript that belongs to exactly one actor — the
355
+ // subagent's own file (v3.29 hooks, `cost rebuild`) or the main thread's
356
+ // session file (`cost rebuild`) — cannot carry another actor's usage, so the
357
+ // oversum signature does not apply to them: a 24-day main thread with
358
+ // billions of cached reads is simply a long session, measured exactly.
359
+ if (r.token_source === 'agent-transcript' || r.token_source === 'session-transcript') return false;
342
360
  const cr = r.cache_read_tokens || 0;
343
361
  const io = (r.input_tokens || 0) + (r.output_tokens || 0);
344
362
  if (cr > 5e8) return true; // no scoped subagent re-reads >500M cached tokens
345
- if (cr > 1e7 && cr > 100 * (io + 1)) return true; // cache-read dwarfs input+output
346
363
  if ((r.output_tokens || 0) > 1e7) return true; // ~10M output = cumulative oversum
364
+ const dur = typeof r.duration_ms === 'number' ? r.duration_ms : null;
365
+ if (dur != null && dur > SUSPECT_MAX_DURATION_MS) return true; // a six-hour-plus "subagent" is a session's history
366
+ // Cache-read dwarfing input+output is the oversum signature ONLY for a row the
367
+ // hook could not time (pre-v3.20 rows, unreadable transcripts). A timed row
368
+ // with a plausible span is a real agent: under prompt caching every turn
369
+ // re-reads the cached context, so a hundred-turn agent legitimately reads
370
+ // 300× more cached tokens than it writes. Applied to timed rows, this rule
371
+ // had excluded fifty sub-hour agents (~3 billion real cache-read tokens)
372
+ // from eleven field ledgers (2026-09).
373
+ if (dur == null && cr > 1e7 && cr > 100 * (io + 1)) return true;
347
374
  return false;
348
375
  }
349
376
 
377
+ /**
378
+ * A record is "empty" when it carries no tokens on any axis and no model: a
379
+ * spawn the hook could not measure — a sibling stop that arrived before the
380
+ * shared parent transcript had grown (the pre-v3.29 slice path), or a payload
381
+ * with neither usage nor a readable transcript. It has no cost and no tokens;
382
+ * counting it as a call inflated call counts by up to 2x in the field (480 of
383
+ * 976 rows across eleven ledgers, 2026-09). A zero-token row that names a model
384
+ * is NOT empty — that is a measured run that happened to use nothing.
385
+ * @param {Object} r - a cost record
386
+ * @returns {boolean}
387
+ */
388
+ function isEmptyRecord(r) {
389
+ if (!r || typeof r !== 'object') return false;
390
+ if (r.model) return false;
391
+ return !(r.input_tokens || r.output_tokens || r.cache_read_tokens || r.cache_write_tokens);
392
+ }
393
+
350
394
  function aggregate(cwd, opts) {
351
395
  const records = readRecords(cwd);
352
396
  const malformedSkipped = _lastReadMalformed; // captured before any later read
@@ -372,6 +416,7 @@ function aggregate(cwd, opts) {
372
416
  cost_usd: 0,
373
417
  cost_unknown: 0,
374
418
  suspect_excluded: 0,
419
+ empty_excluded: 0,
375
420
  malformed_skipped: malformedSkipped,
376
421
  };
377
422
 
@@ -393,9 +438,11 @@ function aggregate(cwd, opts) {
393
438
  }
394
439
 
395
440
  for (const r of filtered) {
396
- // Quarantine physically-impossible records (pre-v3.12.4 transcript-oversum
397
- // bug) so a poisoned ledger doesn't poison the totals / HUD / /pan:cost.
441
+ // Quarantine physically-impossible records (the transcript-oversum
442
+ // signature) so a poisoned ledger doesn't poison the totals / HUD / /pan:cost.
398
443
  if (isSuspectRecord(r)) { totals.suspect_excluded += 1; continue; }
444
+ // Skip unmeasured spawns: no tokens, no model, nothing to price or count.
445
+ if (isEmptyRecord(r)) { totals.empty_excluded += 1; continue; }
399
446
  totals.calls += 1;
400
447
  totals.input_tokens += r.input_tokens || 0;
401
448
  totals.output_tokens += r.output_tokens || 0;
@@ -452,7 +499,12 @@ function renderTable(agg) {
452
499
  lines.push(window);
453
500
  lines.push('');
454
501
  lines.push('Totals');
455
- lines.push(` Calls : ${agg.totals.calls}${agg.totals.malformed_skipped > 0 ? ` (+${agg.totals.malformed_skipped} malformed)` : ''}`);
502
+ const skipped = [
503
+ agg.totals.suspect_excluded > 0 ? `${agg.totals.suspect_excluded} suspect` : null,
504
+ agg.totals.empty_excluded > 0 ? `${agg.totals.empty_excluded} empty` : null,
505
+ agg.totals.malformed_skipped > 0 ? `${agg.totals.malformed_skipped} malformed` : null,
506
+ ].filter(Boolean);
507
+ lines.push(` Calls : ${agg.totals.calls}${skipped.length ? ` (excluded: ${skipped.join(', ')})` : ''}`);
456
508
  lines.push(` Input tokens : ${agg.totals.input_tokens.toLocaleString()}`);
457
509
  lines.push(` Output tokens : ${agg.totals.output_tokens.toLocaleString()}`);
458
510
  lines.push(` Cache read : ${agg.totals.cache_read_tokens.toLocaleString()}`);
@@ -572,6 +624,7 @@ module.exports = {
572
624
  readRecords,
573
625
  aggregate,
574
626
  isSuspectRecord,
627
+ isEmptyRecord,
575
628
  renderTable,
576
629
  renderChart,
577
630
  resolveRate,
@@ -82,7 +82,11 @@ function cmdGitCommit(cwd, opts, raw) {
82
82
  const commitArgs = amend ? ['commit', '--amend', '--no-edit'] : ['commit', '-m', finalMessage];
83
83
  const r = execGit(cwd, commitArgs);
84
84
  if (r.exitCode !== 0) {
85
- if (r.stdout.includes('nothing to commit') || r.stderr.includes('nothing to commit')) {
85
+ // Git says "nothing to commit" for a clean tree and "nothing added to commit but
86
+ // untracked files present" when the only changes are untracked. Both mean no change
87
+ // was NEEDED; only the first was recognised, so the second was reported as a failed
88
+ // commit with "unknown git error" (measured 2026-09-17).
89
+ if ((r.stdout + r.stderr).includes('nothing to commit') || (r.stdout + r.stderr).includes('nothing added to commit')) {
86
90
  // No error key, exit 0: nothing to commit means no change was NEEDED, not that
87
91
  // a change failed. Pinned as a success in CLI-REFERENCE ("Error Shape").
88
92
  output({ committed: false, reason: 'nothing_to_commit' }, raw, 'nothing to commit');
@@ -424,8 +424,10 @@ function fmtTokens(n) {
424
424
  /**
425
425
  * Assess whether a cost ledger is trustworthy enough to show dollar figures.
426
426
  * Two failure modes are treated as "don't quote a number":
427
- * - legacy: more records were quarantined as implausible than survived (the
428
- * pre-v3.12.4 transcript-oversum bug) — reset advised.
427
+ * - legacy: more records were quarantined as implausible than were measured
428
+ * (the transcript-oversum signature: a session's cumulative usage booked to
429
+ * one subagent, written by hooks before v3.29) — quarantine advised.
430
+ * Unmeasured spawns (`empty_excluded`) count on neither side.
429
431
  * - unresolved: every surviving record lacks a resolvable model→rate, so the
430
432
  * computed spend is a misleading $0 even though real tokens were spent.
431
433
  * Returns { ok:true } when figures are safe to display.
@@ -439,7 +441,7 @@ function ledgerReliability(totals) {
439
441
  const total = suspect + calls;
440
442
  return {
441
443
  ok: false, kind: 'legacy',
442
- message: `${suspect} of ${total} cost records are implausible (the pre-v3.12.4 telemetry capture bug). Reset the ledger with <b>pan-tools cost clear</b> — records captured after the fix are accurate.`,
444
+ message: `${suspect} of ${total} measured cost records are implausible (a session's usage booked to one subagent — rows written by hooks before v3.29). Rebuild the ledger from the transcripts with <b>pan-tools cost rebuild --apply</b> (dry-run first without the flag); if the transcripts are gone, quarantine it with <b>pan-tools hygiene clean --apply</b>. Rows captured by v3.29+ hooks are attributed per agent.`,
443
445
  };
444
446
  }
445
447
  if (calls > 0 && unknown >= calls) {