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
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: focus-auto
3
3
  group: Focus
4
- description: Continuous scan-plan-exec loop with purpose-driven categories and 5-layer safety harness
4
+ description: Continuous scan-plan-exec loop with purpose-driven categories and a layered safety harness
5
5
  allowed-tools:
6
6
  - Read
7
7
  - Write
@@ -107,7 +107,7 @@ Wait for the user's reply before proceeding. Do not guess or pick a default cate
107
107
  | `--stop` | — | Gracefully stop active run |
108
108
  | `--status` | — | Show current campaign progress |
109
109
  | `--dry-run` | — | Show plan without executing |
110
- | `--deep-review` | off | After every exec cycle, run inline OWASP security check on changed files. Verdict `block` or `review_required` stops the campaign (6th safety harness). Works with all categories. |
110
+ | `--deep-review` | off | After every exec cycle, run inline OWASP security check on changed files. Verdict `block` or `review_required` stops the campaign (the security-gate layer of the safety harness). Works with all categories. |
111
111
  | `--parallel-research` | off | Fan out the per-item *research* stage via the Workflow tool (read-only agents). No-op fallback to sequential where the host has no Workflow tool. (ADR-0031) |
112
112
  | `--parallel-verify` | off | Fan out the per-item *verify* stage via the Workflow tool (read-only). The implement/exec stage always stays a single agent. (ADR-0031) |
113
113
  | `--clean-seal` | off | After the loop's last item, run one clean build + full verification (commands from `config.json → build`/`verification`) to catch cross-item orphans. (ADR-0031) |
@@ -185,7 +185,7 @@ Phase 2 (each cycle): Scan → Plan → Exec → Commit is strictly sequential w
185
185
  HARD STOP conditions:
186
186
  - Phase 1 fails (tests broken): Do not enter main loop — report and exit
187
187
  - Any cycle: test count drops below baseline after revert → stop campaign, preserve state
188
- - Context drops below 25%: stop campaign cleanly (safety harness 3)
188
+ - Context drops below 25%: stop campaign cleanly (safety harness)
189
189
  </phase_dependencies>
190
190
 
191
191
  ### Phase 2: Main Loop
@@ -413,7 +413,7 @@ Then continue immediately to the next cycle (back to Step 2.1).
413
413
 
414
414
  4. Remove safety tag: `git tag -d focus-auto-baseline 2>/dev/null`
415
415
 
416
- ## 6-Layer Safety Harness
416
+ ## Layered Safety Harness
417
417
 
418
418
  | Layer | Mechanism | Action |
419
419
  |-------|-----------|--------|
@@ -425,7 +425,7 @@ Then continue immediately to the next cycle (back to Step 2.1).
425
425
  | Zero-completed guard | 0 items done in a cycle | Stop — further cycles won't help |
426
426
  | Security gate (`--deep-review`) | Critical/high OWASP pattern in changed files | Revert last commit (critical) or flag for manual review (high), stop campaign |
427
427
 
428
- ## 9 Behavioral Rules
428
+ ## Behavioral Rules
429
429
 
430
430
  1. **Read Before Write** — Read every file before editing. Understand context, callers, invariants.
431
431
  2. **Root Cause** — Fix the actual defect, not symptoms. Trace the code path.
@@ -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 are best-effort: if the hook input doesn't carry
11
- // them, we log a record with zeros + a `source: "hook"` flag so the
12
- // aggregator distinguishes these from fully-instrumented records.
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
- const PAN_RUNTIME_DIRS = ['.claude', '.codex', '.gemini', '.opencode', '.github'];
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. Kept as a literal in each hook —
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 = 3;
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 meta = JSON.parse(fs.readFileSync(path.join(optDir, 'traces', sid, 'session.json'), 'utf-8'));
110
- return meta && typeof meta === 'object' ? meta : {};
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, and Claude Code after --unified-skills). 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
- // • No recorded payload in this repo carries an `agent_id` or any other
218
- // per-invocation id. `grep -rn agent_id hooks/ pan-wizard-core/ tests/`
219
- // finds only PAN's own agent-tracking artifacts (written by workflows) and
220
- // the hook tests, which inject one as a stand-in.
221
- // So for two CONCURRENT SAME-TYPE siblings no varying payload field is
222
- // confirmed on any host. Where the host supplies one, both spawns are admitted;
223
- // where it supplies none the two payloads are the same bytes, hence
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
- for (const [tp, v] of Object.entries(cursor)) {
305
- if (tp === SEEN_EVENTS || tp === LEGACY_CONSUME_KEYS) continue; // reserved markers — not paths
306
- if (tp && fs.existsSync(tp)) pruned[tp] = v;
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; clamped marks a value
356
- // dropped to 0 by the plausibility guard so a guarded zero is distinguishable
357
- // from a genuine zero-token run.
358
- let tokenSource = data.transcript_path ? 'transcript' : 'usage-fallback';
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 (data.transcript_path) {
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[data.transcript_path] || 0;
374
- const fromTranscript = readUsageFromTranscript(data.transcript_path, data.session_id, since);
375
- inputTokens = fromTranscript.input_tokens;
376
- outputTokens = fromTranscript.output_tokens;
377
- cacheRead = fromTranscript.cache_read_input_tokens;
378
- cacheWrite = fromTranscript.cache_creation_input_tokens;
379
- durationMs = durationFromSpan(fromTranscript.first_ts, fromTranscript.last_ts);
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
- cursor[data.transcript_path] = fromTranscript.lineCount;
388
- addSeenSig(cursor, data.transcript_path, eventSig);
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, data.transcript_path).includes(eventSig)) {
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 (zero tokens) and remember its
413
- // signature so a subsequent re-fire of THIS event is dropped.
414
- addSeenSig(cursor, data.transcript_path, eventSig);
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
- const command = data.command || sessionMeta.command || null;
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
- if (sessionId && entry.session_id && entry.session_id !== sessionId) continue;
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
- if (!isPanProject(cwd)) return;
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 };