pan-wizard 3.27.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/README.md +48 -48
- package/agents/pan-previewer.md +1 -1
- package/bin/install-lib.cjs +580 -18
- package/bin/install.js +25 -44
- package/commands/pan/army.md +1 -1
- package/commands/pan/cost.md +14 -2
- package/commands/pan/preview.md +2 -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 +8 -2
- package/pan-wizard-core/bin/lib/commands.cjs +3 -1
- package/pan-wizard-core/bin/lib/constants.cjs +39 -0
- package/pan-wizard-core/bin/lib/context-budget.cjs +80 -0
- package/pan-wizard-core/bin/lib/cost-rebuild.cjs +511 -0
- package/pan-wizard-core/bin/lib/cost.cjs +174 -18
- package/pan-wizard-core/bin/lib/foreign-planning.cjs +56 -0
- 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 +52 -24
- package/pan-wizard-core/bin/lib/init.cjs +8 -0
- 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 +46 -12
- package/pan-wizard-core/bin/pan-tools.cjs +8 -1
- package/pan-wizard-core/mcp/server.cjs +92 -8
- package/pan-wizard-core/mcp/tool-registry.cjs +50 -3
- package/pan-wizard-core/references/model-profiles.md +2 -2
- package/pan-wizard-core/workflows/health.md +2 -0
- package/pan-zcode/README.md +1 -1
- package/scripts/build-agent-plugin.js +220 -0
- package/scripts/build-plugin.js +48 -3
- package/scripts/coverage-gate.cjs +257 -0
- package/scripts/generate-skills-docs.py +1 -1
- package/scripts/install-git-hooks.js +5 -0
- package/scripts/mutation-probe.cjs +272 -0
- package/scripts/release-check.js +80 -13
- 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
|
+
};
|