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.
- package/commands/pan/cost.md +14 -2
- package/hooks/dist/pan-check-update.js +4 -0
- package/hooks/dist/pan-cost-logger.js +322 -43
- package/hooks/dist/pan-trace-logger.js +275 -32
- package/package.json +4 -1
- package/pan-wizard-core/bin/lib/commands.cjs +3 -1
- package/pan-wizard-core/bin/lib/constants.cjs +17 -0
- package/pan-wizard-core/bin/lib/context-budget.cjs +10 -0
- package/pan-wizard-core/bin/lib/cost-rebuild.cjs +511 -0
- package/pan-wizard-core/bin/lib/cost.cjs +61 -8
- package/pan-wizard-core/bin/lib/git.cjs +5 -1
- package/pan-wizard-core/bin/lib/hud.cjs +5 -3
- package/pan-wizard-core/bin/lib/hygiene.cjs +22 -25
- package/pan-wizard-core/bin/lib/memory.cjs +14 -8
- package/pan-wizard-core/bin/lib/optimize.cjs +78 -2
- package/pan-wizard-core/bin/lib/utils.cjs +22 -0
- package/pan-wizard-core/bin/lib/verify.cjs +24 -10
- package/pan-wizard-core/bin/pan-tools.cjs +8 -1
- package/pan-wizard-core/workflows/health.md +1 -0
- package/scripts/coverage-gate.cjs +257 -0
- package/scripts/install-git-hooks.js +5 -0
- package/scripts/mutation-probe.cjs +272 -0
- package/scripts/release-check.js +33 -12
- package/scripts/test-quality-lint.cjs +240 -0
- package/scripts/test-surface.cjs +335 -0
|
@@ -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
|
|
334
|
-
* (billions of cache-read, cache-read dwarfing input, 100%
|
|
335
|
-
*
|
|
336
|
-
*
|
|
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 (
|
|
397
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
428
|
-
*
|
|
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 (
|
|
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) {
|