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
package/commands/pan/cost.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: pan:cost
|
|
3
3
|
group: Observability
|
|
4
4
|
description: Show token usage and estimated cost across PAN commands and agents
|
|
5
|
-
argument-hint: "[report|append|clear] [--format json|table|chart] [--since YYYY-MM-DD] [--until YYYY-MM-DD]"
|
|
5
|
+
argument-hint: "[report|append|clear|rebuild] [--format json|table|chart] [--since YYYY-MM-DD] [--until YYYY-MM-DD] [--apply] [--no-main-thread]"
|
|
6
6
|
allowed-tools:
|
|
7
7
|
- Read
|
|
8
8
|
- Bash
|
|
@@ -45,7 +45,10 @@ pan-tools cost report [--format json|table|chart] [--since YYYY-MM-DD] [--until
|
|
|
45
45
|
"cache_read_tokens": 50000,
|
|
46
46
|
"cache_write_tokens": 5000,
|
|
47
47
|
"cost_usd": 2.1234,
|
|
48
|
-
"cost_unknown": 0
|
|
48
|
+
"cost_unknown": 0,
|
|
49
|
+
"suspect_excluded": 0,
|
|
50
|
+
"empty_excluded": 0,
|
|
51
|
+
"malformed_skipped": 0
|
|
49
52
|
},
|
|
50
53
|
"cache_hit_rate_pct": 40.5,
|
|
51
54
|
"by_agent": { "pan-planner": { "calls": 8, "input": 50000, ... } },
|
|
@@ -78,6 +81,15 @@ Delete the cost log. Useful at the start of a billing cycle.
|
|
|
78
81
|
pan-tools cost clear
|
|
79
82
|
```
|
|
80
83
|
|
|
84
|
+
### `rebuild`
|
|
85
|
+
|
|
86
|
+
Rebuild the ledger from Claude Code's own transcripts (session file plus the per-agent files under `<session>/subagents/`, Workflow-tool subagents one level down). Rows written by hooks before v3.29 booked a slice of the parent session to whichever subagent stopped and counted turns once per content block; the rebuild replaces them with one exact row per agent transcript, typed from the main thread's `Agent` calls, plus one row per session for the main thread's own usage (`--no-main-thread` omits it). Rows whose session transcript is gone, and caller-appended rows, are kept. Dry-run by default — show the user the per-session before → after (and any `warnings`), then apply only on their say-so; the previous ledger is kept beside the new one as `tokens.jsonl.rebuilt-<date>` (a later copy never overwrites an earlier one). Run it **before** `/pan:hygiene --apply` on a poisoned ledger: quarantine moves the whole file aside, and a rebuild afterwards has no rows left to keep. The main-thread row is dated to the session's last record, so `--since`/`--until` windows and the per-day view see a session's own usage on its final day.
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
pan-tools cost rebuild # dry run
|
|
90
|
+
pan-tools cost rebuild --apply # write the rebuilt ledger
|
|
91
|
+
```
|
|
92
|
+
|
|
81
93
|
</subcommands>
|
|
82
94
|
|
|
83
95
|
<rate_table>
|
|
@@ -113,6 +113,10 @@ function defaultFetchLatest() {
|
|
|
113
113
|
encoding: 'utf8',
|
|
114
114
|
timeout: 10000,
|
|
115
115
|
windowsHide: true,
|
|
116
|
+
// Own the silence here rather than relying on the caller's stdio: 'ignore'. npm
|
|
117
|
+
// writes registry and PATH failures to stderr, and a hook that lets them through
|
|
118
|
+
// puts its own diagnostics in front of the user mid-session.
|
|
119
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
116
120
|
}).trim();
|
|
117
121
|
}
|
|
118
122
|
|
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
//
|
|
8
8
|
// We append a minimal record to .planning/metrics/tokens.jsonl so
|
|
9
9
|
// `/pan:cost` reports reflect real agent spawns, not just manually-appended
|
|
10
|
-
// entries. Token counts
|
|
11
|
-
//
|
|
12
|
-
//
|
|
10
|
+
// entries. Token counts come from the subagent's OWN transcript when the host
|
|
11
|
+
// names one (`agent_id` → `<session>/subagents/agent-<id>.jsonl`, see
|
|
12
|
+
// resolveAgentTranscript), else from a slice of the parent session transcript,
|
|
13
|
+
// else from the payload's plausibility-guarded counters. A `source: "hook"`
|
|
14
|
+
// flag distinguishes these rows from fully-instrumented records.
|
|
13
15
|
//
|
|
14
16
|
// This hook NEVER blocks the main agent loop — all errors are swallowed.
|
|
15
17
|
|
|
@@ -18,7 +20,13 @@ const path = require('path');
|
|
|
18
20
|
const crypto = require('crypto');
|
|
19
21
|
|
|
20
22
|
// Runtime config dirs a local PAN install lands in (mirrors installer getDirName).
|
|
21
|
-
|
|
23
|
+
// Every runtime PAN installs into, by config-directory name. Built from the runtime
|
|
24
|
+
// names rather than written as literals on purpose: the installer templates a hook by
|
|
25
|
+
// rewriting the string `'.claude'`, including a catch-all for unanchored occurrences,
|
|
26
|
+
// which rewrote this list too — a Codex install shipped `.codex` twice and no `.claude`
|
|
27
|
+
// at all, so this predicate stopped recognising a Claude-only project (2026-09-17).
|
|
28
|
+
// This list is runtime-AGNOSTIC and must survive the install byte for byte.
|
|
29
|
+
const PAN_RUNTIME_DIRS = ['claude', 'codex', 'gemini', 'opencode', 'github'].map((r) => `.${r}`);
|
|
22
30
|
|
|
23
31
|
/**
|
|
24
32
|
* Which planning tree this hook writes to.
|
|
@@ -73,18 +81,36 @@ function isPanProject(cwd) {
|
|
|
73
81
|
}
|
|
74
82
|
}
|
|
75
83
|
|
|
84
|
+
|
|
85
|
+
// Telemetry FILLS a planning tree; it never brings one into existence. isPanProject
|
|
86
|
+
// above also accepts a bare install marker, which is right for "is PAN here" but wrong
|
|
87
|
+
// as a licence to write: a global-install hook fires in every repo the user opens, and
|
|
88
|
+
// scaffolding `.planning/` on the marker alone made repos that had merely installed PAN
|
|
89
|
+
// look like half-built projects to `validate health` and `hygiene scan` — five of the
|
|
90
|
+
// fourteen field projects swept on 2026-09-17 had a planning tree no /pan command ever
|
|
91
|
+
// created. Before the first /pan command there is no project to attribute a run to, so
|
|
92
|
+
// the honest record is no record. Best-effort — never throws.
|
|
93
|
+
function hasPlanningTree(cwd) {
|
|
94
|
+
try {
|
|
95
|
+
return !!cwd && fs.existsSync(planningPath(cwd));
|
|
96
|
+
} catch {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
76
101
|
const METRICS_DIR = 'metrics';
|
|
77
102
|
const TOKENS_FILE = 'tokens.jsonl';
|
|
78
103
|
const CURSOR_FILE = '.cost-cursor.json';
|
|
79
104
|
|
|
80
105
|
// Ledger row schema version. Bump when the record shape changes so readers can
|
|
81
106
|
// tell which shape a row was written in (pre-versioned rows read as v1); v3 added
|
|
82
|
-
// the per-invocation `event_sig` discriminator
|
|
107
|
+
// the per-invocation `event_sig` discriminator; v4 added `agent_id` and the
|
|
108
|
+
// `agent-transcript` token source. Kept as a literal in each hook —
|
|
83
109
|
// they are standalone zero-dep scripts that can't import from pan-wizard-core, so
|
|
84
110
|
// the two hooks must stay in sync by hand. No constant in pan-wizard-core mirrors
|
|
85
111
|
// it: the readers there take a row field by field rather than switching on its
|
|
86
112
|
// version, so an added field is additive for them.
|
|
87
|
-
const SCHEMA_V =
|
|
113
|
+
const SCHEMA_V = 4;
|
|
88
114
|
|
|
89
115
|
// Reverse-map a resolved model id to its cost tier so the "By tier" dashboard
|
|
90
116
|
// section isn't blind on the hook path. Anthropic families only (the tiers PAN
|
|
@@ -97,6 +123,37 @@ function tierForModel(model) {
|
|
|
97
123
|
return null;
|
|
98
124
|
}
|
|
99
125
|
|
|
126
|
+
// How long a `current-session` pointer is evidence of a live session. An explicit
|
|
127
|
+
// (non-auto) session used to stay "current" indefinitely: a field project still pointed
|
|
128
|
+
// at a session started on 17 July when it was swept on 17 September, so every ledger row
|
|
129
|
+
// since had inherited that session's command and phase. Mirrors SESSION_STALE_MS in
|
|
130
|
+
// optimize.cjs — the hooks cannot import it.
|
|
131
|
+
const SESSION_STALE_MS = 24 * 60 * 60 * 1000;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Last write to a session — its event log, else the moment it started, else null.
|
|
135
|
+
* Best-effort; never throws.
|
|
136
|
+
*/
|
|
137
|
+
function sessionLastActivityMs(dir) {
|
|
138
|
+
try {
|
|
139
|
+
return fs.statSync(path.join(dir, 'trace.jsonl')).mtimeMs;
|
|
140
|
+
} catch { /* no events yet */ }
|
|
141
|
+
try {
|
|
142
|
+
const meta = JSON.parse(fs.readFileSync(path.join(dir, 'session.json'), 'utf-8'));
|
|
143
|
+
const t = new Date(meta.started_at).getTime();
|
|
144
|
+
return Number.isFinite(t) ? t : null;
|
|
145
|
+
} catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Finished, or too old to be the session running now. */
|
|
151
|
+
function isSessionStale(dir, meta, now = Date.now()) {
|
|
152
|
+
if (meta && meta.ended_at) return true;
|
|
153
|
+
const last = sessionLastActivityMs(dir);
|
|
154
|
+
return last === null || now - last > SESSION_STALE_MS;
|
|
155
|
+
}
|
|
156
|
+
|
|
100
157
|
// Best-effort read of the active trace session's command/phase so hook rows can
|
|
101
158
|
// be attributed to the command that spawned them. current-session →
|
|
102
159
|
// traces/<sid>/session.json (both written by the trace logger / optimize.cjs).
|
|
@@ -106,13 +163,94 @@ function readActiveSessionMeta(cwd) {
|
|
|
106
163
|
const optDir = planningPath(cwd, 'optimization');
|
|
107
164
|
const sid = fs.readFileSync(path.join(optDir, 'current-session'), 'utf-8').trim();
|
|
108
165
|
if (!sid) return {};
|
|
109
|
-
const
|
|
110
|
-
|
|
166
|
+
const dir = path.join(optDir, 'traces', sid);
|
|
167
|
+
const meta = JSON.parse(fs.readFileSync(path.join(dir, 'session.json'), 'utf-8'));
|
|
168
|
+
if (!meta || typeof meta !== 'object') return {};
|
|
169
|
+
// A pointer at a finished or long-dead session describes nothing that is running
|
|
170
|
+
// now; its command/phase must not be copied onto this row.
|
|
171
|
+
if (isSessionStale(dir, meta)) return {};
|
|
172
|
+
return meta;
|
|
111
173
|
} catch {
|
|
112
174
|
return {};
|
|
113
175
|
}
|
|
114
176
|
}
|
|
115
177
|
|
|
178
|
+
|
|
179
|
+
// How much of a session transcript's tail to read when looking for the command that
|
|
180
|
+
// spawned this agent. A long session file runs to hundreds of megabytes, and the answer
|
|
181
|
+
// is always in the most recent turns, so the read is bounded.
|
|
182
|
+
const COMMAND_TAIL_BYTES = 262144;
|
|
183
|
+
|
|
184
|
+
// PAN's own command namespace, as a runtime writes it: `/pan:exec-phase` (Claude Code,
|
|
185
|
+
// Gemini) or `/pan-exec-phase` (Codex, OpenCode, Copilot). Only these spawn PAN agents,
|
|
186
|
+
// so only these are attributed — a host UI command (`/model`, `/compact`) and a plain
|
|
187
|
+
// typed prompt leave the row honestly unattributed instead of borrowing a name.
|
|
188
|
+
const PAN_COMMAND_RE = /<command-name>\s*\/?pan[:-]([a-z0-9][a-z0-9-]*)\s*<\/command-name>/i;
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The PAN command in whose turn this agent ran, read from the PARENT session transcript.
|
|
192
|
+
*
|
|
193
|
+
* `command` used to come only from the optimizer's trace session, and tracing is off by
|
|
194
|
+
* default, so outside focus mode every field row carried `command: null` and "which
|
|
195
|
+
* command got expensive" was unanswerable from PAN's own telemetry (field sweep
|
|
196
|
+
* 2026-09-17, 976 rows). A runtime records a slash-command invocation as a TYPED user
|
|
197
|
+
* turn carrying `<command-name>`, so the tail's most recent such turn names this work.
|
|
198
|
+
*
|
|
199
|
+
* Only typed user turns count. A tool_result record is the host replying to a tool call,
|
|
200
|
+
* and its payload may quote a command tag verbatim — a transcript that had grepped another
|
|
201
|
+
* project's history reported that project's command as its own until this was record-scoped
|
|
202
|
+
* rather than text-scoped. Turns without a command (plain prose, an injected reminder) are
|
|
203
|
+
* skipped rather than treated as clearing the attribution, so a mid-run "continue" does not
|
|
204
|
+
* erase it.
|
|
205
|
+
*
|
|
206
|
+
* This is attribution by recency, not by proof: an agent spawned from a plain prompt long
|
|
207
|
+
* after a PAN command still reads as that command while it remains in the window. Scoping
|
|
208
|
+
* the match to PAN's namespace is what keeps that bounded — a session that has run no PAN
|
|
209
|
+
* command reports null rather than naming whatever the user last typed.
|
|
210
|
+
*
|
|
211
|
+
* Returns the bare command name (`/pan:exec-phase` → `exec-phase`) or null. Never throws.
|
|
212
|
+
*/
|
|
213
|
+
function readCommandFromTranscript(transcriptPath) {
|
|
214
|
+
try {
|
|
215
|
+
if (typeof transcriptPath !== 'string' || !transcriptPath) return null;
|
|
216
|
+
const fd = fs.openSync(transcriptPath, 'r');
|
|
217
|
+
let text;
|
|
218
|
+
let partial = false;
|
|
219
|
+
try {
|
|
220
|
+
const size = fs.fstatSync(fd).size;
|
|
221
|
+
const start = Math.max(0, size - COMMAND_TAIL_BYTES);
|
|
222
|
+
partial = start > 0;
|
|
223
|
+
const len = size - start;
|
|
224
|
+
if (len <= 0) return null;
|
|
225
|
+
const buf = Buffer.allocUnsafe(len);
|
|
226
|
+
const read = fs.readSync(fd, buf, 0, len, start);
|
|
227
|
+
text = buf.toString('utf-8', 0, read);
|
|
228
|
+
} finally {
|
|
229
|
+
fs.closeSync(fd);
|
|
230
|
+
}
|
|
231
|
+
const lines = text.split(/\r?\n/);
|
|
232
|
+
if (partial) lines.shift(); // a mid-record first line
|
|
233
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
234
|
+
if (!lines[i]) continue;
|
|
235
|
+
let rec;
|
|
236
|
+
try { rec = JSON.parse(lines[i]); } catch { continue; }
|
|
237
|
+
if (!rec || rec.type !== 'user' || !rec.message) continue;
|
|
238
|
+
const content = rec.message.content;
|
|
239
|
+
let typed = null;
|
|
240
|
+
if (typeof content === 'string') typed = content;
|
|
241
|
+
else if (Array.isArray(content)) {
|
|
242
|
+
if (content.some((b) => b && b.type === 'tool_result')) continue; // host reply, not a typed turn
|
|
243
|
+
typed = content.filter((b) => b && b.type === 'text' && typeof b.text === 'string').map((b) => b.text).join('\n');
|
|
244
|
+
}
|
|
245
|
+
if (!typed) continue;
|
|
246
|
+
const m = typed.match(PAN_COMMAND_RE);
|
|
247
|
+
if (m) return m[1].toLowerCase();
|
|
248
|
+
}
|
|
249
|
+
return null;
|
|
250
|
+
} catch {
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
116
254
|
/**
|
|
117
255
|
* Current phase from state.md — the fallback when no optimizer trace is running.
|
|
118
256
|
*
|
|
@@ -194,6 +332,12 @@ const LEGACY_CONSUME_KEYS = '__consumeKeys';
|
|
|
194
332
|
// below for what widening either window was measured to cost.
|
|
195
333
|
const MAX_SEEN_SIGS = 8;
|
|
196
334
|
const MAX_SEEN_TRANSCRIPTS = 16;
|
|
335
|
+
// Cursor path keys. With per-agent transcripts every spawn adds a key that lives
|
|
336
|
+
// as long as Claude Code keeps the file (weeks), so existence-pruning alone no
|
|
337
|
+
// longer bounds the map (L40). Keep the most recently written keys only; a
|
|
338
|
+
// spawn's transcript is sliced once, so an evicted key costs nothing but a
|
|
339
|
+
// re-sum if that agent is ever resumed weeks later.
|
|
340
|
+
const MAX_CURSOR_KEYS = 512;
|
|
197
341
|
|
|
198
342
|
// Signature of a SubagentStop event: a hash of the FULL payload as delivered.
|
|
199
343
|
// A dual-registration re-fire is byte-identical on stdin (the host pipes the
|
|
@@ -214,13 +358,15 @@ const MAX_SEEN_TRANSCRIPTS = 16;
|
|
|
214
358
|
// • `model` and `phase` came out null in those recorded rows: the payload
|
|
215
359
|
// carried neither.
|
|
216
360
|
// • `usage` is absent entirely in headless mode (docs/HOOKS.md, P-1805).
|
|
217
|
-
// •
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
361
|
+
// • Claude Code's hook reference documents `agent_id` as a common input field
|
|
362
|
+
// "present only when the hook fires inside a subagent call", and the
|
|
363
|
+
// matching per-agent transcript files exist on disk beside the session
|
|
364
|
+
// transcript (resolveAgentTranscript). Where it is present, the slice comes
|
|
365
|
+
// from the agent's own transcript and two concurrent same-type siblings are
|
|
366
|
+
// separable by that field alone.
|
|
367
|
+
// So on a host that omits `agent_id`, two CONCURRENT SAME-TYPE siblings have no
|
|
368
|
+
// confirmed varying payload field. Where the host supplies one, both spawns are
|
|
369
|
+
// admitted; where it supplies none the two payloads are the same bytes, hence
|
|
224
370
|
// informationally indistinguishable from a re-fire, and the second stays
|
|
225
371
|
// suppressed.
|
|
226
372
|
//
|
|
@@ -301,10 +447,10 @@ function writeCursor(cwd, cursor) {
|
|
|
301
447
|
// Prune cursor keys for transcripts that no longer exist so the map can't
|
|
302
448
|
// grow without bound over a long-lived project (L40, ADR audit 2026-08).
|
|
303
449
|
const pruned = {};
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
450
|
+
const live = Object.entries(cursor).filter(([tp]) => tp !== SEEN_EVENTS && tp !== LEGACY_CONSUME_KEYS && tp && fs.existsSync(tp));
|
|
451
|
+
// Insertion order is write order (a key is re-inserted when advanced), so
|
|
452
|
+
// the tail of the list is the most recently sliced transcripts.
|
|
453
|
+
for (const [tp, v] of live.slice(-MAX_CURSOR_KEYS)) pruned[tp] = v;
|
|
308
454
|
// Preserve the seen-event marker (N17/N25-N27). Deliberately NOT pruned by
|
|
309
455
|
// transcript existence — a missing-transcript first fire's marker must
|
|
310
456
|
// survive this very write, or its re-fire is re-admitted as a phantom row
|
|
@@ -324,6 +470,73 @@ function writeCursor(cwd, cursor) {
|
|
|
324
470
|
} catch { /* best-effort — never block the agent loop */ }
|
|
325
471
|
}
|
|
326
472
|
|
|
473
|
+
// ─── Per-agent transcript resolution ────────────────────────────────────────
|
|
474
|
+
//
|
|
475
|
+
// On SubagentStop Claude Code hands the hook the PARENT session's transcript
|
|
476
|
+
// (`transcript_path`) plus the subagent's `agent_id`; the subagent's own
|
|
477
|
+
// conversation is written beside the parent transcript as
|
|
478
|
+
// `<parent dir>/<session_id>/subagents/agent-<agent_id>.jsonl`. Slicing the
|
|
479
|
+
// parent transcript per event booked whatever the session had done since the
|
|
480
|
+
// previous stop to whichever subagent happened to stop next — and the FIRST stop
|
|
481
|
+
// on a long-lived session (cursor 0, or a cursor a ledger quarantine had reset)
|
|
482
|
+
// booked the session's entire history to one row: 7.5 billion cache-read tokens
|
|
483
|
+
// over a ten-day "duration" was observed in the field (2026-09). Sibling stops
|
|
484
|
+
// arriving before the parent transcript grew produced the complementary all-zero
|
|
485
|
+
// rows — 63% of one ledger. The agent transcript is the subagent's usage and
|
|
486
|
+
// nothing else, so it is preferred whenever it exists; the parent-slice path
|
|
487
|
+
// remains the fallback for hosts that supply no agent id. An explicit
|
|
488
|
+
// `agent_transcript_path` in the payload wins over the derivation.
|
|
489
|
+
const AGENT_ID_SAFE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
490
|
+
function resolveAgentTranscript(data) {
|
|
491
|
+
try {
|
|
492
|
+
const explicit = data.agent_transcript_path;
|
|
493
|
+
if (typeof explicit === 'string' && explicit && fs.existsSync(explicit)) return explicit;
|
|
494
|
+
const agentId = data.agent_id;
|
|
495
|
+
if (typeof agentId !== 'string' || !AGENT_ID_SAFE.test(agentId)) return null;
|
|
496
|
+
if (typeof data.transcript_path !== 'string' || !data.transcript_path) return null;
|
|
497
|
+
if (typeof data.session_id !== 'string' || !AGENT_ID_SAFE.test(data.session_id)) return null;
|
|
498
|
+
const base = path.dirname(data.transcript_path);
|
|
499
|
+
const subagentsDir = path.join(base, data.session_id, 'subagents');
|
|
500
|
+
// Containment: every derived path must stay under the parent transcript's directory.
|
|
501
|
+
const resolvedBase = path.resolve(base);
|
|
502
|
+
const contained = (p) => path.resolve(p).startsWith(resolvedBase + path.sep);
|
|
503
|
+
const direct = path.join(subagentsDir, `agent-${agentId}.jsonl`);
|
|
504
|
+
if (contained(direct) && fs.existsSync(direct)) return direct;
|
|
505
|
+
// Subagents spawned by the Workflow tool (the native `/pan-*` workflow
|
|
506
|
+
// scripts) are written one level down, under the run that spawned them:
|
|
507
|
+
// `subagents/workflows/<wf_id>/agent-<agent_id>.jsonl`. In the field those
|
|
508
|
+
// were the majority of spawns (269 of 331 rows in one ledger), so missing
|
|
509
|
+
// this level would have recorded them as unmeasured.
|
|
510
|
+
const workflowsDir = path.join(subagentsDir, 'workflows');
|
|
511
|
+
let runs = [];
|
|
512
|
+
try { runs = fs.readdirSync(workflowsDir); } catch { return null; }
|
|
513
|
+
for (const run of runs) {
|
|
514
|
+
const nested = path.join(workflowsDir, run, `agent-${agentId}.jsonl`);
|
|
515
|
+
if (contained(nested) && fs.existsSync(nested)) return nested;
|
|
516
|
+
}
|
|
517
|
+
return null;
|
|
518
|
+
} catch {
|
|
519
|
+
return null;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// A slice is a SUM over one subagent's conversation, so its ceiling sits above a
|
|
524
|
+
// single call's (PLAUSIBLE_MAX): a long agent legitimately re-reads its cached
|
|
525
|
+
// context on every turn. The cache_read and output ceilings are the absolute
|
|
526
|
+
// limits cost.cjs isSuspectRecord quarantines on; input and cache_write are
|
|
527
|
+
// hook-only sanity ceilings (the reader has no rule for them). A value past its
|
|
528
|
+
// ceiling is a session's cumulative usage that leaked into the slice, never one
|
|
529
|
+
// subagent's own — drop it to 0 and flag the row. The parent-slice path had NO
|
|
530
|
+
// guard before v3.29: every oversum row in the field carried `clamped: false`.
|
|
531
|
+
// The slice's SPAN is not clamped: it is recorded as measured, and the reader's
|
|
532
|
+
// six-hour rule (cost.cjs SUSPECT_MAX_DURATION_MS) quarantines a slice that ran
|
|
533
|
+
// longer. Nulling the span here instead would hand the row to the reader's
|
|
534
|
+
// untimed cache-ratio rule and lose a legitimate long agent with it.
|
|
535
|
+
const SLICE_MAX = { input: 2e7, output: 1e7, cache_read: 5e8, cache_write: 1e8 };
|
|
536
|
+
function clampSlice(n, max) {
|
|
537
|
+
return typeof n === 'number' && n >= 0 && n <= max ? n : 0;
|
|
538
|
+
}
|
|
539
|
+
|
|
327
540
|
/**
|
|
328
541
|
* Extract what we can from the SubagentStop event payload.
|
|
329
542
|
* Pure function — safe to test without stdin.
|
|
@@ -352,10 +565,27 @@ function buildCostRecord(data, cwd) {
|
|
|
352
565
|
let cacheRead = 0;
|
|
353
566
|
let cacheWrite = 0;
|
|
354
567
|
let durationMs = null;
|
|
355
|
-
// token_source records WHICH path produced the counts
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
|
|
568
|
+
// token_source records WHICH path produced the counts: `agent-transcript` (the
|
|
569
|
+
// subagent's own conversation file — see resolveAgentTranscript), `transcript`
|
|
570
|
+
// (a slice of the parent session transcript, the fallback when the host names
|
|
571
|
+
// no agent) or `usage-fallback` (the payload's own counters). `clamped` marks
|
|
572
|
+
// a value dropped to 0 by a plausibility guard so a guarded zero is
|
|
573
|
+
// distinguishable from a genuine zero-token run.
|
|
574
|
+
const agentTranscript = resolveAgentTranscript(data);
|
|
575
|
+
const parentPath = typeof data.transcript_path === 'string' && data.transcript_path ? data.transcript_path : null;
|
|
576
|
+
// The host NAMED an agent (a bare id, or an explicit agent transcript path) but
|
|
577
|
+
// its file is not there — not flushed yet, or a host that sends ids without
|
|
578
|
+
// per-agent files. The parent slice is the wrong answer for that spawn: it is
|
|
579
|
+
// the whole session since the last parent-slice stop, booked to one agent. So
|
|
580
|
+
// this event consumes nothing and records an unmeasured spawn (zeros, a
|
|
581
|
+
// distinct token_source), which the reader excludes from calls.
|
|
582
|
+
const agentNamed = (typeof data.agent_id === 'string' && AGENT_ID_SAFE.test(data.agent_id))
|
|
583
|
+
|| (typeof data.agent_transcript_path === 'string' && data.agent_transcript_path !== '');
|
|
584
|
+
const agentFileMissing = !agentTranscript && agentNamed && parentPath !== null;
|
|
585
|
+
const sliceSource = agentTranscript || (agentFileMissing ? null : parentPath);
|
|
586
|
+
const tokenSource = agentTranscript ? 'agent-transcript'
|
|
587
|
+
: agentFileMissing ? 'agent-transcript-missing'
|
|
588
|
+
: sliceSource ? 'transcript' : 'usage-fallback';
|
|
359
589
|
let clamped = false;
|
|
360
590
|
// Set when a transcript-sourced event consumed no new records AND is an
|
|
361
591
|
// identical re-fire / dual-registration. Carried on the returned record as a
|
|
@@ -363,29 +593,46 @@ function buildCostRecord(data, cwd) {
|
|
|
363
593
|
// the ledger (M61).
|
|
364
594
|
let emptySlice = false;
|
|
365
595
|
const agent = data.agent_type || data.subagent_type || null;
|
|
596
|
+
const agentId = typeof data.agent_id === 'string' && data.agent_id ? data.agent_id : null;
|
|
366
597
|
// This event's per-invocation identity, hashed once and used by BOTH layers:
|
|
367
598
|
// the seen-event marker below and the `event_sig` row field further down.
|
|
368
599
|
// buildCostRecord never mutates `data`, so hoisting the hash here yields the
|
|
369
600
|
// same value the marker calls used when they each computed it themselves.
|
|
370
601
|
const eventSig = eventSignature(data);
|
|
371
|
-
if (
|
|
602
|
+
if (sliceSource || agentFileMissing) {
|
|
603
|
+
// The cursor and the seen-event marker are keyed by whichever file is being
|
|
604
|
+
// sliced: the agent transcript (one file per spawn, so a re-fire is the only
|
|
605
|
+
// way to see an empty slice) or the shared parent transcript. A named-but-
|
|
606
|
+
// missing agent file consumes nothing and is keyed by the parent, so the
|
|
607
|
+
// re-fire / sibling marker logic below applies to it unchanged.
|
|
608
|
+
const keyPath = sliceSource || parentPath;
|
|
372
609
|
const cursor = readCursor(cwd);
|
|
373
|
-
const since = cursor[
|
|
374
|
-
const fromTranscript =
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
610
|
+
const since = cursor[keyPath] || 0;
|
|
611
|
+
const fromTranscript = sliceSource
|
|
612
|
+
? readUsageFromTranscript(sliceSource, data.session_id, since)
|
|
613
|
+
: { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, model: null, first_ts: null, last_ts: null, lineCount: since };
|
|
614
|
+
const rawIn = fromTranscript.input_tokens;
|
|
615
|
+
const rawOut = fromTranscript.output_tokens;
|
|
616
|
+
const rawCr = fromTranscript.cache_read_input_tokens;
|
|
617
|
+
const rawCw = fromTranscript.cache_creation_input_tokens;
|
|
618
|
+
inputTokens = clampSlice(rawIn, SLICE_MAX.input);
|
|
619
|
+
outputTokens = clampSlice(rawOut, SLICE_MAX.output);
|
|
620
|
+
cacheRead = clampSlice(rawCr, SLICE_MAX.cache_read);
|
|
621
|
+
cacheWrite = clampSlice(rawCw, SLICE_MAX.cache_write);
|
|
622
|
+
clamped = rawIn > SLICE_MAX.input || rawOut > SLICE_MAX.output
|
|
623
|
+
|| rawCr > SLICE_MAX.cache_read || rawCw > SLICE_MAX.cache_write;
|
|
624
|
+
durationMs = durationFromSpan(fromTranscript.first_ts, fromTranscript.last_ts); // as measured — see SLICE_MAX
|
|
380
625
|
if (!model) model = fromTranscript.model;
|
|
381
626
|
if (fromTranscript.lineCount > since) {
|
|
382
627
|
// A real slice. Advance the cursor so the next subagent's record starts
|
|
383
628
|
// fresh — the slices partition the transcript, so it is never re-summed on
|
|
384
629
|
// every event. Remember this event's signature (N17/N25) so a later
|
|
385
630
|
// empty-slice event can tell its re-fire from a parallel sibling — even
|
|
386
|
-
// when other siblings are recorded in between (N25).
|
|
387
|
-
|
|
388
|
-
|
|
631
|
+
// when other siblings are recorded in between (N25). Re-insert the key so
|
|
632
|
+
// insertion order tracks write order (writeCursor keeps the newest keys).
|
|
633
|
+
delete cursor[keyPath];
|
|
634
|
+
cursor[keyPath] = fromTranscript.lineCount;
|
|
635
|
+
addSeenSig(cursor, keyPath, eventSig);
|
|
389
636
|
writeCursor(cwd, cursor);
|
|
390
637
|
} else {
|
|
391
638
|
// No transcript records past the cursor: this event consumed NO slice of
|
|
@@ -406,12 +653,13 @@ function buildCostRecord(data, cwd) {
|
|
|
406
653
|
// The full-payload signature distinguishes them (N25/N26): DROP only when
|
|
407
654
|
// this exact payload was already seen for this transcript (true re-fire);
|
|
408
655
|
// otherwise record the spawn.
|
|
409
|
-
if (eventSig && getSeenSigs(cursor,
|
|
656
|
+
if (eventSig && getSeenSigs(cursor, keyPath).includes(eventSig)) {
|
|
410
657
|
emptySlice = true; // already-seen event → re-fire; appendRecord drops the phantom row (M61)
|
|
411
658
|
} else {
|
|
412
|
-
// Sibling / first-fire: record the spawn
|
|
413
|
-
// signature so a subsequent re-fire of
|
|
414
|
-
|
|
659
|
+
// Sibling / first-fire / named-but-missing agent file: record the spawn
|
|
660
|
+
// (zero tokens) and remember its signature so a subsequent re-fire of
|
|
661
|
+
// THIS event is dropped.
|
|
662
|
+
addSeenSig(cursor, keyPath, eventSig);
|
|
415
663
|
writeCursor(cwd, cursor);
|
|
416
664
|
}
|
|
417
665
|
}
|
|
@@ -433,7 +681,10 @@ function buildCostRecord(data, cwd) {
|
|
|
433
681
|
// Backfill command/phase from the active trace session when the payload omits
|
|
434
682
|
// them (real SubagentStop payloads carry neither); tier is derived from the model.
|
|
435
683
|
const sessionMeta = readActiveSessionMeta(cwd);
|
|
436
|
-
|
|
684
|
+
// The trace session only exists while the optimizer runs, so the parent transcript is
|
|
685
|
+
// the fallback that makes command attribution work outside focus mode — see
|
|
686
|
+
// readCommandFromTranscript.
|
|
687
|
+
const command = data.command || sessionMeta.command || readCommandFromTranscript(data.transcript_path) || null;
|
|
437
688
|
// The trace session is only present while the optimizer is running (off by
|
|
438
689
|
// default), so state.md is the fallback that makes phase attribution work in
|
|
439
690
|
// ordinary use instead of only under tracing.
|
|
@@ -443,6 +694,10 @@ function buildCostRecord(data, cwd) {
|
|
|
443
694
|
v: SCHEMA_V,
|
|
444
695
|
ts: new Date().toISOString(),
|
|
445
696
|
agent,
|
|
697
|
+
// The host's per-spawn id (null where the host supplies none). What made
|
|
698
|
+
// the agent's own transcript addressable; persisted so a reader can tell
|
|
699
|
+
// two same-type siblings apart without hashing the payload.
|
|
700
|
+
agent_id: agentId,
|
|
446
701
|
command,
|
|
447
702
|
model,
|
|
448
703
|
tier: tierForModel(model),
|
|
@@ -527,13 +782,30 @@ function readUsageFromTranscript(transcriptPath, sessionId, sinceLine = 0) {
|
|
|
527
782
|
let raw;
|
|
528
783
|
try { raw = fs.readFileSync(transcriptPath, 'utf-8'); } catch { return totals; }
|
|
529
784
|
let seen = 0; // count of non-empty JSONL records (the cursor unit)
|
|
785
|
+
// One API turn, one usage. Claude Code writes an assistant turn as one JSONL
|
|
786
|
+
// record PER CONTENT BLOCK (text, tool_use, …), each carrying that turn's
|
|
787
|
+
// `message.id` and a usage snapshot; the last block's snapshot holds the
|
|
788
|
+
// turn's final counts. Summing every record therefore counted a three-block
|
|
789
|
+
// turn three times — a real 65-turn agent transcript summed to 14.8M
|
|
790
|
+
// cache-read tokens against 8.5M actual (2026-09). Keyed by `message.id`,
|
|
791
|
+
// last snapshot wins; records with no id (older transcripts, other hosts)
|
|
792
|
+
// are summed as they come.
|
|
793
|
+
const byMessage = new Map();
|
|
794
|
+
let unkeyed = 0;
|
|
530
795
|
for (const line of raw.split('\n')) {
|
|
531
796
|
if (!line) continue;
|
|
532
797
|
seen++;
|
|
533
798
|
if (seen <= sinceLine) continue; // already attributed to an earlier event
|
|
534
799
|
let entry;
|
|
535
800
|
try { entry = JSON.parse(line); } catch { continue; }
|
|
536
|
-
|
|
801
|
+
// Claude Code names this field `sessionId`; the guard read `session_id` only, so it
|
|
802
|
+
// was inert — 199 of 200 records in a real local transcript carry the camelCase
|
|
803
|
+
// spelling and none carry the snake_case one (measured 2026-09-17). Harmless on the
|
|
804
|
+
// per-agent path, where every record in the file belongs to the one agent, but the
|
|
805
|
+
// scoping it claims to do never happened on the parent-slice fallback. Both
|
|
806
|
+
// spellings are accepted rather than one guessed at.
|
|
807
|
+
const entrySession = entry.sessionId || entry.session_id;
|
|
808
|
+
if (sessionId && entrySession && entrySession !== sessionId) continue;
|
|
537
809
|
// Span of THIS subagent's slice (after the session filter) — first→last
|
|
538
810
|
// record timestamp gives a measured runtime rather than an idle-gap proxy.
|
|
539
811
|
const entryTs = typeof entry.timestamp === 'string' ? entry.timestamp : null;
|
|
@@ -551,6 +823,12 @@ function readUsageFromTranscript(transcriptPath, sessionId, sinceLine = 0) {
|
|
|
551
823
|
|| (entry.type === 'assistant' && entry.message?.usage)
|
|
552
824
|
|| null;
|
|
553
825
|
if (!usage || typeof usage !== 'object') continue;
|
|
826
|
+
const messageId = entry.message && typeof entry.message.id === 'string' && entry.message.id
|
|
827
|
+
? entry.message.id
|
|
828
|
+
: `__unkeyed_${unkeyed++}`;
|
|
829
|
+
byMessage.set(messageId, usage);
|
|
830
|
+
}
|
|
831
|
+
for (const usage of byMessage.values()) {
|
|
554
832
|
totals.input_tokens += extractNumber(usage, 'input_tokens');
|
|
555
833
|
totals.output_tokens += extractNumber(usage, 'output_tokens');
|
|
556
834
|
totals.cache_read_input_tokens += extractNumber(usage, 'cache_read_input_tokens');
|
|
@@ -645,8 +923,9 @@ if (require.main === module) {
|
|
|
645
923
|
// invokes the hook.
|
|
646
924
|
const cwd = data.cwd || data.workspace?.current_dir || process.cwd();
|
|
647
925
|
// M62: a global-install hook fires in every repo; don't pollute non-PAN
|
|
648
|
-
// projects with .planning/ metrics artifacts.
|
|
649
|
-
|
|
926
|
+
// projects with .planning/ metrics artifacts. The tree must already exist —
|
|
927
|
+
// see hasPlanningTree (field sweep 2026-09-17).
|
|
928
|
+
if (!hasPlanningTree(cwd)) return;
|
|
650
929
|
const record = buildCostRecord(data, cwd);
|
|
651
930
|
appendRecord(cwd, record);
|
|
652
931
|
} catch {
|
|
@@ -655,4 +934,4 @@ if (require.main === module) {
|
|
|
655
934
|
});
|
|
656
935
|
}
|
|
657
936
|
|
|
658
|
-
module.exports = { buildCostRecord, appendRecord, readUsageFromTranscript, readCursor, writeCursor, isPanProject, METRICS_DIR, TOKENS_FILE, CURSOR_FILE };
|
|
937
|
+
module.exports = { buildCostRecord, appendRecord, readUsageFromTranscript, resolveAgentTranscript, readCursor, writeCursor, isPanProject, hasPlanningTree, readCommandFromTranscript, isSessionStale, PAN_RUNTIME_DIRS, METRICS_DIR, TOKENS_FILE, CURSOR_FILE, SLICE_MAX, MAX_CURSOR_KEYS };
|