pan-wizard 3.28.0 → 3.30.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.
Files changed (40) hide show
  1. package/README.md +39 -39
  2. package/bin/install-lib.cjs +65 -58
  3. package/bin/install.js +199 -188
  4. package/commands/pan/army.md +2 -2
  5. package/commands/pan/audit-deployment.md +2 -2
  6. package/commands/pan/cost.md +19 -7
  7. package/commands/pan/exec-phase.md +2 -0
  8. package/commands/pan/focus-auto.md +5 -5
  9. package/hooks/dist/pan-check-update.js +4 -0
  10. package/hooks/dist/pan-cost-logger.js +322 -43
  11. package/hooks/dist/pan-stop-guard.js +81 -2
  12. package/hooks/dist/pan-trace-logger.js +275 -32
  13. package/package.json +4 -1
  14. package/pan-wizard-core/bin/lib/agents-md.cjs +3 -2
  15. package/pan-wizard-core/bin/lib/commands.cjs +3 -1
  16. package/pan-wizard-core/bin/lib/constants.cjs +17 -0
  17. package/pan-wizard-core/bin/lib/context-budget.cjs +10 -0
  18. package/pan-wizard-core/bin/lib/core.cjs +17 -4
  19. package/pan-wizard-core/bin/lib/cost-rebuild.cjs +511 -0
  20. package/pan-wizard-core/bin/lib/cost.cjs +165 -55
  21. package/pan-wizard-core/bin/lib/git.cjs +5 -1
  22. package/pan-wizard-core/bin/lib/hud.cjs +5 -3
  23. package/pan-wizard-core/bin/lib/hygiene.cjs +22 -25
  24. package/pan-wizard-core/bin/lib/memory-rebuild.cjs +3 -3
  25. package/pan-wizard-core/bin/lib/memory.cjs +14 -8
  26. package/pan-wizard-core/bin/lib/optimize.cjs +78 -2
  27. package/pan-wizard-core/bin/lib/utils.cjs +22 -0
  28. package/pan-wizard-core/bin/lib/verify-deploy.cjs +1 -1
  29. package/pan-wizard-core/bin/lib/verify.cjs +24 -10
  30. package/pan-wizard-core/bin/pan-tools.cjs +8 -1
  31. package/pan-wizard-core/references/model-profiles.md +4 -4
  32. package/pan-wizard-core/references/planning-config.md +19 -23
  33. package/pan-wizard-core/workflows/health.md +1 -0
  34. package/pan-wizard-core/workflows/settings.md +2 -4
  35. package/scripts/coverage-gate.cjs +257 -0
  36. package/scripts/install-git-hooks.js +5 -0
  37. package/scripts/mutation-probe.cjs +272 -0
  38. package/scripts/release-check.js +33 -12
  39. package/scripts/test-quality-lint.cjs +240 -0
  40. package/scripts/test-surface.cjs +336 -0
@@ -90,6 +90,10 @@ const TTL_SHORT_MIN = 5; // the default subagent lifetime, in minutes
90
90
  const TTL_LONG_MIN = 60; // the lifetime the setting buys
91
91
  const TTL_MIN_WRITE_TOKENS = 1000; // ignore trivial writes (a few tokens of tool results)
92
92
  const TTL_RECOMMEND_AT = 2; // recurrence, not a single event, earns the recommendation
93
+ // Above this much re-written context the recommendation is a warning, not an aside: it
94
+ // fired in eight of ten field projects while filed at `info`, where nothing surfaces it
95
+ // (sweep 2026-09-17). Below it the pattern is real but cheap, and stays informational.
96
+ const TTL_WARN_TOKENS = 1000000;
93
97
 
94
98
  /**
95
99
  * Pure. Scan ledger records (oldest first by `ts`) for cache writes that follow
@@ -105,6 +109,10 @@ function assessCacheTtl(records, opts = {}) {
105
109
  const minWrite = opts.minWriteTokens ?? TTL_MIN_WRITE_TOKENS;
106
110
  const recommendAt = opts.recommendAt ?? TTL_RECOMMEND_AT;
107
111
  const rows = (Array.isArray(records) ? records : [])
112
+ // A `cost rebuild` main-thread row is one session's whole usage dated to its
113
+ // last record: its cache writes are not a subagent's re-write after an idle
114
+ // gap, and the subagent cache lifetime setting does not govern them.
115
+ .filter(r => !(r && r.token_source === 'session-transcript'))
108
116
  .map(r => ({ t: r && r.ts ? new Date(r.ts).getTime() : NaN, w: Number(r && r.cache_write_tokens) || 0 }))
109
117
  .filter(r => Number.isFinite(r.t))
110
118
  .sort((a, b) => a.t - b.t);
@@ -117,6 +125,7 @@ function assessCacheTtl(records, opts = {}) {
117
125
  }
118
126
  const recommend = shortIdle >= recommendAt;
119
127
  const setting = 'subagentPromptCacheTtl';
128
+ const severity = recommend && shortIdleTokens >= (opts.warnTokens ?? TTL_WARN_TOKENS) ? 'warn' : 'info';
120
129
  const advice = recommend
121
130
  ? `${shortIdle} cache writes followed an idle gap of ${TTL_SHORT_MIN}–${TTL_LONG_MIN} min (~${shortIdleTokens.toLocaleString()} tokens re-written): subagents get the five-minute cache lifetime by default — set \`${setting}: "1h"\` in a Claude Code settings file. One-hour writes bill at 2× base input against 1.25×, so this pays off once a block is read twice within the hour.`
122
131
  : null;
@@ -127,6 +136,7 @@ function assessCacheTtl(records, opts = {}) {
127
136
  writes_after_long_idle: longIdle,
128
137
  recommend,
129
138
  setting,
139
+ severity,
130
140
  advice,
131
141
  };
132
142
  }
@@ -31,10 +31,23 @@ const { planningPath, planningRel } = require('./utils.cjs');
31
31
  * Each provider maps reasoning/mid/fast to its native model identifiers.
32
32
  * "inherit" means the host runtime uses its own top-tier model selection.
33
33
  */
34
+ //
35
+ // Every non-inherit value must be a model id the host accepts: a Claude Code alias
36
+ // (sonnet, haiku) for Anthropic, otherwise a concrete API id that DEFAULT_RATES in
37
+ // cost.cjs prices exactly (tests/documented-default-models.test.cjs pins both).
38
+ // Until 2026-09-22 the OpenAI row held the literal strings 'mid' and 'fast', so a
39
+ // budget-profile project on Codex or OpenCode was told to spawn a model called
40
+ // "mid" (reality check R28).
41
+ // - openai: Codex's subagent docs (learn.chatgpt.com/docs/agent-configuration/
42
+ // subagents, read 2026-09-23): "start with gpt-6-sol. Use gpt-6-luna when you
43
+ // want a faster, lower-cost option". gpt-6-astra is the flagship above both.
44
+ // - google: the newest stable Flash and Flash-Lite on ai.google.dev's models page
45
+ // (read 2026-09-23). The 2.5 family these rows used is "not deprecated" but
46
+ // limited to users who have used it before, so a new project may not reach it.
34
47
  const PROVIDER_MODELS = {
35
48
  anthropic: { reasoning: 'inherit', mid: 'sonnet', fast: 'haiku' },
36
- openai: { reasoning: 'inherit', mid: 'mid', fast: 'fast' },
37
- google: { reasoning: 'inherit', mid: 'gemini-2.5-flash', fast: 'gemini-2.5-flash-lite' },
49
+ openai: { reasoning: 'inherit', mid: 'gpt-6-sol', fast: 'gpt-6-luna' },
50
+ google: { reasoning: 'inherit', mid: 'gemini-3.8-flash', fast: 'gemini-3.5-flash-lite' },
38
51
  default: { reasoning: 'inherit', mid: 'sonnet', fast: 'haiku' },
39
52
  };
40
53
 
@@ -52,7 +65,7 @@ const COST_MULTIPLIERS = { reasoning: 15, mid: 3, fast: 1 };
52
65
  // isolation (each subagent runs in its own window), not a cheaper model, is what
53
66
  // keeps the main conversation clean. Cheapness is now OPT-IN: choose the `budget`
54
67
  // profile (the only column that still down-tiers) or pin a specific agent via
55
- // config `model_overrides`. The 3 security agents additionally pin `model: opus`
68
+ // config `model_overrides`. The reviewer-class agents additionally pin `model: opus`
56
69
  // in their own frontmatter (a native, deliberate exception). resolve-model /
57
70
  // MODEL_PROFILES is advisory + cost-estimation; native Claude Code delegation
58
71
  // reads each agent file's static `model:` (unset → inherit).
@@ -781,7 +794,7 @@ function adjustTierForCapabilities(tier, opts) {
781
794
  * @param {string} agentType - Agent name (e.g., "pan-planner", "pan-executor")
782
795
  * @param {Object} [taskMetadata] - Optional metadata. Supports complexity fields and
783
796
  * capability hints: {context_estimate, needs_thinking, cache_warm}.
784
- * @returns {string} Model identifier: "inherit", "sonnet", "haiku", "mid", "fast", etc.
797
+ * @returns {string} Model identifier: "inherit", a Claude Code alias ("sonnet", "haiku"), or a provider model id from PROVIDER_MODELS
785
798
  */
786
799
  function resolveModelInternal(cwd, agentType, taskMetadata) {
787
800
  const config = loadConfig(cwd);
@@ -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
+ };