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
@@ -30,8 +30,9 @@
30
30
  * - hit rate: cache_read / (cache_read + input - cache_write) if any cache activity
31
31
  *
32
32
  * Rate table is approximate — real pricing comes from the provider's API.
33
- * Rates are US dollars per million tokens, indicative as of 2026-08. Users
34
- * can override with `.planning/config.json` → `cost.rates`.
33
+ * Rates are US dollars per million tokens, verified against each provider's
34
+ * pricing page on the date in RATES_VERIFIED_AT (below). Users can override with
35
+ * `.planning/config.json` → `cost.rates`.
35
36
  */
36
37
 
37
38
  const fs = require('fs');
@@ -47,63 +48,109 @@ const TOKENS_FILE = 'tokens.jsonl';
47
48
  * Override per-model in config.json → cost.rates.
48
49
  */
49
50
  const DEFAULT_RATES = {
50
- // Anthropic — verified against platform pricing 2026-09-10. Opus 4.6+/Opus 5 are
51
- // $5/$25 (the old $15/$75 Opus pricing ended with the 4.5 generation). Cache
52
- // rates follow Anthropic's convention: read ≈ 0.1× input, write ≈ 1.25× input —
53
- // EXCEPT Fable 5.1, whose cache reads bill at 0.025× input ($0.25). Fable 5.1
54
- // needs its own row: without it the family-prefix fallback priced its reads at
55
- // the Fable 5 rate, 4× too high on the model the `fable`/`best` aliases resolve to
56
- // (model-config, read 2026-09-10: neither Fable model is any plan's default), and
57
- // cached re-reads are the bulk of PAN's traffic (ADR-0044).
51
+ // Anthropic — platform.claude.com/docs/en/about-claude/pricing, every row read
52
+ // 2026-09-23. Opus 4.6+/Opus 5 are $5/$25 (the old $15/$75 Opus pricing ended with
53
+ // the 4.5 generation). cache_write is the 5-minute write rate (1.25× input; the
54
+ // 1-hour write bills 2×, which the ledger does not split yet). Cache reads follow
55
+ // the 0.1× convention EXCEPT on three models the page footnotes: Fable 5.1 and
56
+ // Mythos 5.1 at 0.025× input, Opus 5.5 at 0.05×. Each needs its own row, because
57
+ // the family-prefix fallback would otherwise price it at its predecessor's reads.
58
+ // Fable 5.1 is what the `fable`/`best` aliases resolve to (model-config: neither
59
+ // Fable model is any plan's default); cached re-reads are the bulk of PAN's
60
+ // traffic (ADR-0044).
58
61
  'claude-fable-5-1': { input: 10.0, output: 50.0, cache_read: 0.25, cache_write: 12.5 },
59
62
  'claude-fable-5': { input: 10.0, output: 50.0, cache_read: 1.0, cache_write: 12.5 },
60
- // Mythos 5.1 / Mythos 5 (limited availability) — platform.claude.com/docs/en/about-claude/pricing,
61
- // read 2026-09-10: $10/$50; the page's cache footnote names Fable 5.1 AND Mythos 5.1 as
62
- // the two models whose cache reads bill at 0.025× input; Mythos 5 follows the 0.1× rule.
63
- // Added for reality check R7: resolveRate returned null for both ids.
63
+ // Mythos 5.1 / Mythos 5 (limited availability): $10/$50; Mythos 5.1 reads at
64
+ // 0.025× input like Fable 5.1, Mythos 5 follows the 0.1× rule (R7).
64
65
  'claude-mythos-5-1': { input: 10.0, output: 50.0, cache_read: 0.25, cache_write: 12.5 },
65
66
  'claude-mythos-5': { input: 10.0, output: 50.0, cache_read: 1.0, cache_write: 12.5 },
67
+ // Opus 5.5 (claude-opus-5-5, released 2026-09-22): $4 input / $20 output, 5-minute
68
+ // writes $5, cache hits $0.20 — the page prices its hits at 0.05× input. Claude
69
+ // Code 2.1.280 made it the default model on every plan (model-config, read
70
+ // 2026-09-23), so PAN's inherit tier now runs on it. Without this row the family
71
+ // prefix priced it as Opus 5: cache reads 2.5× high, the rest a quarter high
72
+ // (reality check 2026-09-22, R25). The models overview lists no dated snapshot —
73
+ // from the 4.6 generation on, the dateless id is canonical.
74
+ 'claude-opus-5-5': { input: 4.0, output: 20.0, cache_read: 0.20, cache_write: 5.0 },
66
75
  'claude-opus-5': { input: 5.0, output: 25.0, cache_read: 0.5, cache_write: 6.25 },
67
76
  'claude-opus-4-8': { input: 5.0, output: 25.0, cache_read: 0.5, cache_write: 6.25 },
68
77
  'claude-opus-4-7': { input: 5.0, output: 25.0, cache_read: 0.5, cache_write: 6.25 },
69
78
  'claude-opus-4-6': { input: 5.0, output: 25.0, cache_read: 0.5, cache_write: 6.25 },
70
- // Opus 4.5 (dated id claude-opus-4-5-20251101) — same pricing page, read 2026-09-10:
71
- // $5/$25/$0.50/$6.25. Without this row the dated id had no family prefix to land on
72
- // and priced as null (R7).
79
+ // Opus 4.5 (dated id claude-opus-4-5-20251101): without this row the dated id had
80
+ // no family prefix to land on and priced as null (R7).
73
81
  'claude-opus-4-5': { input: 5.0, output: 25.0, cache_read: 0.5, cache_write: 6.25 },
74
82
  // Sonnet 5 is $2/$10: the launch price announced as introductory through
75
- // 2026-08-31 was made permanent and the scheduled rise to $3/$15 cancelled
76
- // (pricing page, read 2026-09-10). Lesson: never write down a pre-announced
77
- // price — this row carried the future rate for a month and over-billed by half.
83
+ // 2026-08-31 was made permanent and the scheduled rise to $3/$15 cancelled.
84
+ // Lesson: never write down a pre-announced price — this row carried the future
85
+ // rate for a month and over-billed by half.
78
86
  'claude-sonnet-5': { input: 2.0, output: 10.0, cache_read: 0.20, cache_write: 2.50 },
79
87
  'claude-sonnet-4-6': { input: 3.0, output: 15.0, cache_read: 0.3, cache_write: 3.75 },
80
- // Sonnet 4.5 (dated id claude-sonnet-4-5-20250929) — pricing page, read 2026-09-10:
81
- // $3/$15/$0.30/$3.75 (the pre-Sonnet-5 rate; Sonnet 5 is $2/$10). R7.
88
+ // Sonnet 4.5 (dated id claude-sonnet-4-5-20250929): $3/$15/$0.30/$3.75 (R7).
82
89
  'claude-sonnet-4-5': { input: 3.0, output: 15.0, cache_read: 0.3, cache_write: 3.75 },
90
+ // Haiku 4.5: API id claude-haiku-4-5-20251001, alias claude-haiku-4-5.
83
91
  'claude-haiku-4-5': { input: 1.0, output: 5.0, cache_read: 0.1, cache_write: 1.25 },
84
92
 
85
- // OpenAI — verified against published pricing 2026-08. Prompt caching is a 90%
86
- // input discount with no separate write charge, so cache_write bills at the
87
- // plain input rate. GPT-5.6 ships in three tiers; the bare `gpt-5.6` id prices
88
- // as the Sol flagship, with tier-specific keys for Terra/Luna (longest-prefix
89
- // match wins in resolveRate). Luna reflects the 2026-07-30 price cut.
90
- 'gpt-5.6': { input: 5.0, output: 30.0, cache_read: 0.5, cache_write: 5.0 },
91
- 'gpt-5.6-terra': { input: 2.0, output: 12.0, cache_read: 0.2, cache_write: 2.0 },
92
- 'gpt-5.6-luna': { input: 0.20, output: 1.20, cache_read: 0.02, cache_write: 0.20 },
93
+ // OpenAI — developers.openai.com/api/docs/pricing, Standard tier, short context
94
+ // (≤272K input tokens), read 2026-09-23. On the GPT-5.6 and GPT-6 families a cache
95
+ // write bills at 1.25× input IN PLACE of the input rate ("writes are not an
96
+ // additive fee"); GPT-5.5 charges no cache write ("-"), so its write tokens bill
97
+ // as plain input. Until 2026-09-22 this block said "no separate write charge" for
98
+ // every row, which had stopped being true — Terra and Luna writes were a fifth
99
+ // low (R27). Longest-prefix match lets each tier key win over a shorter one.
100
+ // GPT-6: Astra is the flagship; Sol and Luna were released 2026-09-22. Codex's
101
+ // config docs use gpt-6-sol as the default model and gpt-6-luna for lower-cost
102
+ // subagents (learn.chatgpt.com/docs/config-file/config-basic, /agent-configuration/subagents).
103
+ 'gpt-6-astra': { input: 10.0, output: 50.0, cache_read: 1.0, cache_write: 12.5 },
104
+ 'gpt-6-sol': { input: 2.0, output: 10.0, cache_read: 0.2, cache_write: 2.5 },
105
+ 'gpt-6-luna': { input: 0.10, output: 0.50, cache_read: 0.01, cache_write: 0.125 },
106
+ // GPT-5.6. The bare `gpt-5.6` id is an alias that "routes requests to GPT-5.6 Sol"
107
+ // (its model page, read 2026-09-23), so it carries Sol's row — not the $5/$30 it
108
+ // carried before, which no page listed. Sol's price is promotional "at least
109
+ // through November 21, 2026": re-read it after that date.
110
+ 'gpt-5.6': { input: 4.0, output: 20.0, cache_read: 0.4, cache_write: 5.0 },
111
+ 'gpt-5.6-sol': { input: 4.0, output: 20.0, cache_read: 0.4, cache_write: 5.0 },
112
+ 'gpt-5.6-terra': { input: 2.0, output: 12.0, cache_read: 0.2, cache_write: 2.5 },
113
+ 'gpt-5.6-luna': { input: 0.20, output: 1.20, cache_read: 0.02, cache_write: 0.25 },
114
+ // Priced only in the page's separate "Cyber models" table, not in the Standard tiers.
115
+ 'gpt-5.6-cyber': { input: 12.5, output: 75.0, cache_read: 1.25, cache_write: 15.625 },
93
116
  'gpt-5.5': { input: 5.0, output: 30.0, cache_read: 0.5, cache_write: 5.0 },
94
117
 
95
- // Google Gemini — published rates (per million tokens, approximate; users can override via config.json → cost.rates).
96
- // Pro tiers use the <=200K-context tier; long-context calls may be billed at ~2x. Cache rates are Google's context-cache pricing (~25% of input rate).
118
+ // Google Gemini — ai.google.dev/gemini-api/docs/pricing, paid tier, text input,
119
+ // the ≤200K-token prompt tier where the page splits by length, read 2026-09-23.
120
+ // cache_read is the page's "context caching" price: 0.1× input on every row, not
121
+ // the 0.25× this block carried until 2026-09-22 (cache reads 2.5× high, R26). The
122
+ // tokens that CREATE a cache bill at the standard input price (Google Cloud's
123
+ // context-cache overview; the Gemini API pages list only cached-token and storage
124
+ // charges), so cache_write = input. Cache STORAGE bills per million tokens per
125
+ // HOUR, which a per-token table cannot carry: PAN's Gemini totals exclude it.
126
+ // The 3.6/3.7/3.8 Flash prices run "through December 31, 2026" and double from
127
+ // January 1, 2027 (the page states both) — re-read them after that date.
128
+ 'gemini-3.8-flash': { input: 0.75, output: 3.75, cache_read: 0.075, cache_write: 0.75 },
129
+ 'gemini-3.7-flash': { input: 0.75, output: 3.75, cache_read: 0.075, cache_write: 0.75 },
130
+ 'gemini-3.6-flash': { input: 0.75, output: 3.75, cache_read: 0.075, cache_write: 0.75 },
131
+ 'gemini-3.5-flash': { input: 1.50, output: 9.00, cache_read: 0.15, cache_write: 1.50 },
132
+ 'gemini-3.5-flash-lite': { input: 0.30, output: 2.50, cache_read: 0.03, cache_write: 0.30 },
133
+ // Shutdown announced for 2027-05-07 (deprecations page); replacement gemini-3.5-flash-lite.
134
+ 'gemini-3.1-flash-lite': { input: 0.25, output: 1.50, cache_read: 0.025, cache_write: 0.25 },
135
+ 'gemini-3-flash-preview': { input: 0.50, output: 3.00, cache_read: 0.05, cache_write: 0.50 },
136
+ // The page lists 3.1 Pro only as gemini-3.1-pro-preview; the bare id keeps the
137
+ // preview's row so records written under it stay priced.
138
+ 'gemini-3.1-pro': { input: 2.00, output: 12.0, cache_read: 0.20, cache_write: 2.00 },
139
+ 'gemini-3.1-pro-preview': { input: 2.00, output: 12.0, cache_read: 0.20, cache_write: 2.00 },
140
+ // The 2.5 family is "not deprecated", but the models page limits access to users
141
+ // who have used it before — new projects cannot count on it (PAN's Google routing
142
+ // tiers moved off it, R28). Rows kept for the ledgers that recorded it.
143
+ 'gemini-2.5-pro': { input: 1.25, output: 10.0, cache_read: 0.125, cache_write: 1.25 },
144
+ 'gemini-2.5-flash': { input: 0.30, output: 2.50, cache_read: 0.03, cache_write: 0.30 },
145
+ 'gemini-2.5-flash-lite': { input: 0.10, output: 0.40, cache_read: 0.01, cache_write: 0.10 },
97
146
  // (gemini-1.5-pro removed 2026-06: retired model; records for it fall back to tier rates.)
98
- 'gemini-3.1-pro': { input: 2.00, output: 12.0, cache_read: 0.50, cache_write: 2.00 },
99
- 'gemini-3.1-pro-preview': { input: 2.00, output: 12.0, cache_read: 0.50, cache_write: 2.00 },
100
- 'gemini-2.5-pro': { input: 1.25, output: 10.0, cache_read: 0.3125, cache_write: 1.25 },
101
- 'gemini-2.5-flash': { input: 0.30, output: 2.50, cache_read: 0.075, cache_write: 0.30 },
102
- 'gemini-2.5-flash-lite': { input: 0.10, output: 0.40, cache_read: 0.025, cache_write: 0.10 },
103
-
104
- // Tier fallbacks when model id is unknown (reasoning tracks current Opus pricing)
105
- 'reasoning': { input: 5.0, output: 25.0, cache_read: 0.5, cache_write: 6.25 },
106
- 'mid': { input: 3.0, output: 15.0, cache_read: 0.3, cache_write: 3.75 },
147
+
148
+ // Tier fallbacks when the model id is unknown: reasoning tracks the default Opus
149
+ // (Opus 5.5 since 2026-09-22 — the model the inherit tier runs on for most users),
150
+ // mid the Sonnet the `sonnet` alias resolves to on the Anthropic API (Sonnet 5),
151
+ // fast Haiku 4.5.
152
+ 'reasoning': { input: 4.0, output: 20.0, cache_read: 0.20, cache_write: 5.0 },
153
+ 'mid': { input: 2.0, output: 10.0, cache_read: 0.20, cache_write: 2.50 },
107
154
  'fast': { input: 1.0, output: 5.0, cache_read: 0.1, cache_write: 1.25 },
108
155
  };
109
156
 
@@ -330,23 +377,67 @@ function readRecords(cwd) {
330
377
  */
331
378
  /**
332
379
  * A record is "suspect" when its token counts are physically implausible for a
333
- * single subagent — the signature of the pre-v3.12.4 transcript-oversum bug
334
- * (billions of cache-read, cache-read dwarfing input, 100% cache-hit). Such
335
- * records are quarantined from aggregates so a poisoned ledger can't report
336
- * millions of dollars. See docs/FIELD-REPORT-army-2026-06.md.
380
+ * single subagent — the oversum signature: a session's cumulative usage booked
381
+ * to one subagent row (billions of cache-read, cache-read dwarfing input, 100%
382
+ * cache-hit). Written by pre-v3.12.4 hooks, and by the parent-transcript slice
383
+ * path of every later hook up to v3.28 whenever a slice started at cursor 0 on a
384
+ * long-lived session. Such records are quarantined from aggregates so a
385
+ * poisoned ledger can't report millions of dollars.
386
+ * See docs/FIELD-REPORT-army-2026-06.md.
337
387
  * @param {Object} r - a cost record
338
388
  * @returns {boolean}
339
389
  */
390
+ // No single subagent runs for six hours — the longest native-workflow phase runs
391
+ // measured in the harness finish inside an hour — while a parent-transcript slice
392
+ // that spans a working day, or the idle night between two stops, does. Mirrored by
393
+ // the hooks' SLICE_MAX_DURATION_MS, which nulls the span on write. Calibrated on
394
+ // eleven field ledgers (2026-09): of the timed rows the old ratio rule flagged,
395
+ // 55 spanned under three hours and 12 spanned six to twenty-four — the latter all
396
+ // parent slices booked to a `general-purpose` or workflow subagent.
397
+ const SUSPECT_MAX_DURATION_MS = 6 * 60 * 60 * 1000;
398
+
340
399
  function isSuspectRecord(r) {
341
400
  if (!r || typeof r !== 'object') return false;
401
+ // Rows measured from a transcript that belongs to exactly one actor — the
402
+ // subagent's own file (v3.29 hooks, `cost rebuild`) or the main thread's
403
+ // session file (`cost rebuild`) — cannot carry another actor's usage, so the
404
+ // oversum signature does not apply to them: a 24-day main thread with
405
+ // billions of cached reads is simply a long session, measured exactly.
406
+ if (r.token_source === 'agent-transcript' || r.token_source === 'session-transcript') return false;
342
407
  const cr = r.cache_read_tokens || 0;
343
408
  const io = (r.input_tokens || 0) + (r.output_tokens || 0);
344
409
  if (cr > 5e8) return true; // no scoped subagent re-reads >500M cached tokens
345
- if (cr > 1e7 && cr > 100 * (io + 1)) return true; // cache-read dwarfs input+output
346
410
  if ((r.output_tokens || 0) > 1e7) return true; // ~10M output = cumulative oversum
411
+ const dur = typeof r.duration_ms === 'number' ? r.duration_ms : null;
412
+ if (dur != null && dur > SUSPECT_MAX_DURATION_MS) return true; // a six-hour-plus "subagent" is a session's history
413
+ // Cache-read dwarfing input+output is the oversum signature ONLY for a row the
414
+ // hook could not time (pre-v3.20 rows, unreadable transcripts). A timed row
415
+ // with a plausible span is a real agent: under prompt caching every turn
416
+ // re-reads the cached context, so a hundred-turn agent legitimately reads
417
+ // 300× more cached tokens than it writes. Applied to timed rows, this rule
418
+ // had excluded fifty sub-hour agents (~3 billion real cache-read tokens)
419
+ // from eleven field ledgers (2026-09).
420
+ if (dur == null && cr > 1e7 && cr > 100 * (io + 1)) return true;
347
421
  return false;
348
422
  }
349
423
 
424
+ /**
425
+ * A record is "empty" when it carries no tokens on any axis and no model: a
426
+ * spawn the hook could not measure — a sibling stop that arrived before the
427
+ * shared parent transcript had grown (the pre-v3.29 slice path), or a payload
428
+ * with neither usage nor a readable transcript. It has no cost and no tokens;
429
+ * counting it as a call inflated call counts by up to 2x in the field (480 of
430
+ * 976 rows across eleven ledgers, 2026-09). A zero-token row that names a model
431
+ * is NOT empty — that is a measured run that happened to use nothing.
432
+ * @param {Object} r - a cost record
433
+ * @returns {boolean}
434
+ */
435
+ function isEmptyRecord(r) {
436
+ if (!r || typeof r !== 'object') return false;
437
+ if (r.model) return false;
438
+ return !(r.input_tokens || r.output_tokens || r.cache_read_tokens || r.cache_write_tokens);
439
+ }
440
+
350
441
  function aggregate(cwd, opts) {
351
442
  const records = readRecords(cwd);
352
443
  const malformedSkipped = _lastReadMalformed; // captured before any later read
@@ -372,6 +463,7 @@ function aggregate(cwd, opts) {
372
463
  cost_usd: 0,
373
464
  cost_unknown: 0,
374
465
  suspect_excluded: 0,
466
+ empty_excluded: 0,
375
467
  malformed_skipped: malformedSkipped,
376
468
  };
377
469
 
@@ -393,9 +485,11 @@ function aggregate(cwd, opts) {
393
485
  }
394
486
 
395
487
  for (const r of filtered) {
396
- // Quarantine physically-impossible records (pre-v3.12.4 transcript-oversum
397
- // bug) so a poisoned ledger doesn't poison the totals / HUD / /pan:cost.
488
+ // Quarantine physically-impossible records (the transcript-oversum
489
+ // signature) so a poisoned ledger doesn't poison the totals / HUD / /pan:cost.
398
490
  if (isSuspectRecord(r)) { totals.suspect_excluded += 1; continue; }
491
+ // Skip unmeasured spawns: no tokens, no model, nothing to price or count.
492
+ if (isEmptyRecord(r)) { totals.empty_excluded += 1; continue; }
399
493
  totals.calls += 1;
400
494
  totals.input_tokens += r.input_tokens || 0;
401
495
  totals.output_tokens += r.output_tokens || 0;
@@ -452,7 +546,12 @@ function renderTable(agg) {
452
546
  lines.push(window);
453
547
  lines.push('');
454
548
  lines.push('Totals');
455
- lines.push(` Calls : ${agg.totals.calls}${agg.totals.malformed_skipped > 0 ? ` (+${agg.totals.malformed_skipped} malformed)` : ''}`);
549
+ const skipped = [
550
+ agg.totals.suspect_excluded > 0 ? `${agg.totals.suspect_excluded} suspect` : null,
551
+ agg.totals.empty_excluded > 0 ? `${agg.totals.empty_excluded} empty` : null,
552
+ agg.totals.malformed_skipped > 0 ? `${agg.totals.malformed_skipped} malformed` : null,
553
+ ].filter(Boolean);
554
+ lines.push(` Calls : ${agg.totals.calls}${skipped.length ? ` (excluded: ${skipped.join(', ')})` : ''}`);
456
555
  lines.push(` Input tokens : ${agg.totals.input_tokens.toLocaleString()}`);
457
556
  lines.push(` Output tokens : ${agg.totals.output_tokens.toLocaleString()}`);
458
557
  lines.push(` Cache read : ${agg.totals.cache_read_tokens.toLocaleString()}`);
@@ -535,12 +634,22 @@ function cmdCostClear(cwd, raw) {
535
634
 
536
635
  // ─── Rate-table staleness ───────────────────────────────────────────────────
537
636
 
538
- // Date DEFAULT_RATES was last verified against published provider pricing.
539
- // Bump this whenever the table is re-verified; `models check` flags the table
540
- // once it is older than RATES_STALE_AFTER_DAYS (provider prices move faster
541
- // than PAN releases do).
542
- const RATES_VERIFIED_AT = '2026-09-10';
543
- const RATES_STALE_AFTER_DAYS = 180;
637
+ // Date DEFAULT_RATES was last verified against published provider pricing — ALL
638
+ // providers' pricing pages, not only Anthropic's (the 2026-09-10 bump covered the
639
+ // Anthropic rows alone, and the Gemini and OpenAI rows it vouched for had never
640
+ // matched their pages: reality check 2026-09-22, RC29). Bump it only when every
641
+ // row has been read against its page that day.
642
+ //
643
+ // `models check` flags the table once it is older than RATES_STALE_AFTER_DAYS.
644
+ // That window was half a year until 2026-09-22 (R38), which is longer than the
645
+ // interval at which the lineup now moves: the default Claude model changed on
646
+ // every plan inside twelve days of the last verification. Sixty days is the
647
+ // review cadence the ecosystem reviews settled on. The calendar still cannot see
648
+ // a default-model change inside the window — that is the job of the
649
+ // documented-default-ids fixture test (tests/fixtures/documented-default-models.json,
650
+ // R31), which fails the suite the day a documented default id has no exact row.
651
+ const RATES_VERIFIED_AT = '2026-09-23';
652
+ const RATES_STALE_AFTER_DAYS = 60;
544
653
  const RATE_TIERS = ['reasoning', 'mid', 'fast'];
545
654
 
546
655
  function checkRatesStaleness(now = new Date()) {
@@ -572,6 +681,7 @@ module.exports = {
572
681
  readRecords,
573
682
  aggregate,
574
683
  isSuspectRecord,
684
+ isEmptyRecord,
575
685
  renderTable,
576
686
  renderChart,
577
687
  resolveRate,
@@ -82,7 +82,11 @@ function cmdGitCommit(cwd, opts, raw) {
82
82
  const commitArgs = amend ? ['commit', '--amend', '--no-edit'] : ['commit', '-m', finalMessage];
83
83
  const r = execGit(cwd, commitArgs);
84
84
  if (r.exitCode !== 0) {
85
- if (r.stdout.includes('nothing to commit') || r.stderr.includes('nothing to commit')) {
85
+ // Git says "nothing to commit" for a clean tree and "nothing added to commit but
86
+ // untracked files present" when the only changes are untracked. Both mean no change
87
+ // was NEEDED; only the first was recognised, so the second was reported as a failed
88
+ // commit with "unknown git error" (measured 2026-09-17).
89
+ if ((r.stdout + r.stderr).includes('nothing to commit') || (r.stdout + r.stderr).includes('nothing added to commit')) {
86
90
  // No error key, exit 0: nothing to commit means no change was NEEDED, not that
87
91
  // a change failed. Pinned as a success in CLI-REFERENCE ("Error Shape").
88
92
  output({ committed: false, reason: 'nothing_to_commit' }, raw, 'nothing to commit');
@@ -424,8 +424,10 @@ function fmtTokens(n) {
424
424
  /**
425
425
  * Assess whether a cost ledger is trustworthy enough to show dollar figures.
426
426
  * Two failure modes are treated as "don't quote a number":
427
- * - legacy: more records were quarantined as implausible than survived (the
428
- * pre-v3.12.4 transcript-oversum bug) — reset advised.
427
+ * - legacy: more records were quarantined as implausible than were measured
428
+ * (the transcript-oversum signature: a session's cumulative usage booked to
429
+ * one subagent, written by hooks before v3.29) — quarantine advised.
430
+ * Unmeasured spawns (`empty_excluded`) count on neither side.
429
431
  * - unresolved: every surviving record lacks a resolvable model→rate, so the
430
432
  * computed spend is a misleading $0 even though real tokens were spent.
431
433
  * Returns { ok:true } when figures are safe to display.
@@ -439,7 +441,7 @@ function ledgerReliability(totals) {
439
441
  const total = suspect + calls;
440
442
  return {
441
443
  ok: false, kind: 'legacy',
442
- message: `${suspect} of ${total} cost records are implausible (the pre-v3.12.4 telemetry capture bug). Reset the ledger with <b>pan-tools cost clear</b> — records captured after the fix are accurate.`,
444
+ message: `${suspect} of ${total} measured cost records are implausible (a session's usage booked to one subagent — rows written by hooks before v3.29). Rebuild the ledger from the transcripts with <b>pan-tools cost rebuild --apply</b> (dry-run first without the flag); if the transcripts are gone, quarantine it with <b>pan-tools hygiene clean --apply</b>. Rows captured by v3.29+ hooks are attributed per agent.`,
443
445
  };
444
446
  }
445
447
  if (calls > 0 && unknown >= calls) {
@@ -5,7 +5,9 @@
5
5
  * campaigns run: runtime installs fall behind the latest version, legacy
6
6
  * uppercase planning filenames linger from pre-v2.2 layouts, atomic-write
7
7
  * .tmp orphans survive crashes, per-agent memory logs grow past the cap,
8
- * cost ledgers written by pre-v3.12.4 hooks are 100% poisoned, telemetry
8
+ * cost ledgers carry oversum rows (a session's cumulative usage booked to one
9
+ * subagent — pre-v3.12.4 hooks wrote nothing else, and the parent-transcript
10
+ * slice path kept producing them up to v3.28), telemetry
9
11
  * trace sessions pile up unboundedly, and stray fragment `.planning/`
10
12
  * directories appear where a mapping step once ran.
11
13
  *
@@ -39,7 +41,7 @@ const {
39
41
  CHARS_PER_TOKEN,
40
42
  STATE_FILE,
41
43
  } = require('./constants.cjs');
42
- const { planningPath, planningRel } = require('./utils.cjs');
44
+ const { planningPath, planningRel, detectPlanningModel } = require('./utils.cjs');
43
45
  const { detectForeignPlanningTree } = require('./foreign-planning.cjs');
44
46
  const { listMemoryAgents, readMemory, compactMemory } = require('./memory.cjs');
45
47
  const { readRecords, isSuspectRecord, METRICS_DIR, TOKENS_FILE } = require('./cost.cjs');
@@ -253,7 +255,8 @@ function recordMass(r) {
253
255
  }
254
256
 
255
257
  /**
256
- * H-5: cost ledger dominated by physically implausible (pre-v3.12.4) records.
258
+ * H-5: cost ledger dominated by physically implausible records — the oversum
259
+ * signature, a session's cumulative usage booked to one subagent row.
257
260
  *
258
261
  * Gated on token MASS as well as record count. A count-only gate passes a ledger
259
262
  * whose few bad rows carry most of the tokens — field case: 24% of rows suspect
@@ -286,7 +289,7 @@ function checkCostLedger(cwd) {
286
289
  : 'token mass';
287
290
  findings.push(mkFinding('poisoned-ledger', 'critical',
288
291
  planningRel(METRICS_DIR, TOKENS_FILE),
289
- `${suspect}/${records.length} records suspect (${Math.round(ratio * 100)}% of rows, ${Math.round(massRatio * 100)}% of token mass) — pre-v3.12.4 oversum signature, tripped on ${basis}; aggregates quarantine them but the file is dead weight`,
292
+ `${suspect}/${records.length} records suspect (${Math.round(ratio * 100)}% of rows, ${Math.round(massRatio * 100)}% of token mass) — oversum signature (a session's cumulative usage booked to one subagent row), tripped on ${basis}; aggregates quarantine them but the file is dead weight. Run \`cost rebuild\` first while the session transcripts still exist — quarantining moves the whole ledger aside, and a rebuild afterwards has no rows left to keep`,
290
293
  { action: 'quarantine-ledger' }));
291
294
  return { findings };
292
295
  }
@@ -466,7 +469,7 @@ function checkCachedContext(cwd) {
466
469
  try {
467
470
  const ttl = assessCacheTtl(readRecords(cwd).filter(r => !isSuspectRecord(r)));
468
471
  if (ttl.recommend) {
469
- findings.push(mkFinding('cache-context', 'info', planningRel(path.join(METRICS_DIR, TOKENS_FILE)), ttl.advice, null));
472
+ findings.push(mkFinding('cache-context', ttl.severity, planningRel(path.join(METRICS_DIR, TOKENS_FILE)), ttl.advice, null));
470
473
  }
471
474
  } catch { /* no ledger, or unreadable — nothing to say */ }
472
475
  return { findings };
@@ -494,15 +497,11 @@ function checkPlanningFragment(cwd) {
494
497
  const dir = planningPath(cwd);
495
498
  let entries = [];
496
499
  try { entries = fs.readdirSync(dir); } catch { return { findings, planning_exists: false }; }
497
- const lower = entries.map(e => e.toLowerCase());
498
- // Spine = anything that marks a deliberate PAN workflow: the phase model
499
- // (project/state/phases/roadmap/requirements/milestones) OR the focus model
500
- // (focus/quick) OR an orchestration campaign. A dir holding only generated
501
- // artifacts (codebase maps, metrics, traces) is a stray fragment.
502
- const SPINE = ['project.md', 'state.md', 'phases', 'roadmap.md', 'requirements.md',
503
- 'milestones', 'focus', 'quick', 'orchestration'];
504
- const hasSpine = SPINE.some(s => lower.includes(s));
505
- if (!hasSpine && entries.length > 0) {
500
+ // A spine is whatever marks a deliberate PAN workflow — the phase model, the focus
501
+ // model, or an orchestration campaign (PLANNING_MODEL_MARKERS, shared with
502
+ // `validate health` so the two verbs cannot disagree about a tree). A dir holding
503
+ // only generated artifacts (codebase maps, metrics, traces) is a stray fragment.
504
+ if (detectPlanningModel(dir).model === 'fragment') {
506
505
  findings.push(mkFinding('planning-fragment', 'info', planningRootRel(),
507
506
  `.planning exists with ${entries.length} entr${entries.length === 1 ? 'y' : 'ies'} (${entries.slice(0, 5).join(', ')}) but no workflow spine (project/state/phases/focus/…) — likely a stray partial run; review and delete manually`,
508
507
  null));
@@ -661,16 +660,14 @@ function applyFix(cwd, finding) {
661
660
  const dest = `${abs}.quarantined-${stamp}`;
662
661
  fs.renameSync(abs, dest);
663
662
 
664
- // The cursor is a per-transcript high-water mark INTO the ledger we just
665
- // moved aside. Left behind it points at rows that are no longer there,
666
- // so the fresh ledger starts mid-stream and the next slice is undercounted.
667
- // A "fresh ledger" that inherits the old ledger's read position is not fresh.
668
- let cursorNote = '';
669
- try {
670
- const cursor = path.join(path.dirname(abs), COST_CURSOR_FILE);
671
- fs.unlinkSync(cursor);
672
- cursorNote = ', cursor reset';
673
- } catch { /* no cursor to reset */ }
663
+ // The cost cursor (COST_CURSOR_FILE) STAYS. It is a per-TRANSCRIPT
664
+ // high-water mark — how many records of each session transcript the
665
+ // hooks have already attributed — not a position in the ledger, so it
666
+ // has nothing to do with the file being moved aside. This step used to
667
+ // delete it as "a fresh ledger must not inherit the old read position",
668
+ // and the next SubagentStop then re-summed every session transcript from
669
+ // line 0: a fresh oversum row the day after each quarantine (field,
670
+ // 2026-08-25 → 08-26). Quarantine and poison had become a loop.
674
671
 
675
672
  // Quarantine leaves a dated copy behind, and nothing else ever removes
676
673
  // one. Run hygiene a few times over a year and the metrics dir fills
@@ -680,7 +677,7 @@ function applyFix(cwd, finding) {
680
677
 
681
678
  return {
682
679
  applied: true,
683
- detail: `renamed to ${path.basename(dest)}${cursorNote}${prunedNote} — fresh ledger starts clean`,
680
+ detail: `renamed to ${path.basename(dest)}${prunedNote} — fresh ledger starts clean; transcript cursor kept`,
684
681
  };
685
682
  }
686
683
  default:
@@ -10,8 +10,8 @@
10
10
  *
11
11
  * Three derived targets:
12
12
  * 1. AGENTS.md — the universal, cross-runtime tools memory. PAN owns exactly
13
- * the marker-fenced `<!-- BEGIN/END PAN WIZARD -->` section (every runtime,
14
- * including Copilot/.github, reads AGENTS.md natively). User content
13
+ * the marker-fenced `<!-- BEGIN/END PAN WIZARD -->` section (Codex, Copilot and
14
+ * OpenCode read AGENTS.md natively; Claude via the CLAUDE.md bridge; Gemini reads GEMINI.md by default). User content
15
15
  * outside the markers is preserved byte-for-byte.
16
16
  * 2. CLAUDE.md — the Claude bridge (`@AGENTS.md` import), regenerated only
17
17
  * when the Claude runtime is installed here.
@@ -126,7 +126,7 @@ function cmdMemoryRebuild(cwd, opts = {}, raw) {
126
126
  const targets = [];
127
127
  const warnings = [];
128
128
 
129
- // 1. AGENTS.md — universal PAN section (all runtimes read it natively).
129
+ // 1. AGENTS.md — universal PAN section (Claude Code reads it through the bridge in step 2).
130
130
  {
131
131
  const p = path.join(cwd, 'AGENTS.md');
132
132
  const existing = safeReadFile(p);
@@ -301,11 +301,17 @@ function selectMemory(cwd, agent, opts = {}) {
301
301
  /**
302
302
  * Memory-load telemetry gate (ADR-0036 acceptance signal). Estimates the tokens
303
303
  * of memory that would be injected whole (every agent log) and compares to the
304
- * median per-agent input from the trustworthy cost ledger (suspect records
304
+ * median per-agent PROMPT from the trustworthy cost ledger (suspect records
305
305
  * quarantined). Read-only, non-blocking; degrades to an absolute-token check
306
306
  * when the ledger is thin.
307
307
  *
308
- * @returns {{memory_tokens, agents, median_input_tokens, fraction, status, advisory}}
308
+ * The prompt is `input + cache_read + cache_write`, not `input` alone: under prompt
309
+ * caching the uncached remainder is tens of tokens, so dividing by it reported 1.8k
310
+ * of memory as 8,940% of a "median agent input" and called it critical (field sweep
311
+ * 2026-09-17). Memory is injected into the whole prompt, so the whole prompt is what
312
+ * it must be measured against.
313
+ *
314
+ * @returns {{memory_tokens, agents, median_prompt_tokens, fraction, status, advisory}}
309
315
  */
310
316
  function memoryLoadBudget(cwd, opts = {}) {
311
317
  const { agents } = listMemoryAgents(cwd);
@@ -317,12 +323,12 @@ function memoryLoadBudget(cwd, opts = {}) {
317
323
  let median = null;
318
324
  try {
319
325
  const cost = require('./cost.cjs');
320
- const inputs = (cost.readRecords(cwd) || [])
321
- .filter(r => !cost.isSuspectRecord(r))
322
- .map(r => Number(r.input_tokens) || 0)
326
+ const prompts = (cost.readRecords(cwd) || [])
327
+ .filter(r => !cost.isSuspectRecord(r) && !cost.isEmptyRecord(r))
328
+ .map(r => (Number(r.input_tokens) || 0) + (Number(r.cache_read_tokens) || 0) + (Number(r.cache_write_tokens) || 0))
323
329
  .filter(n => n > 0)
324
330
  .sort((a, b) => a - b);
325
- if (inputs.length) median = inputs[Math.floor(inputs.length / 2)];
331
+ if (prompts.length) median = prompts[Math.floor(prompts.length / 2)];
326
332
  } catch { /* thin/absent ledger — absolute-token check only */ }
327
333
 
328
334
  const fraction = median ? memoryTokens / median : null;
@@ -335,9 +341,9 @@ function memoryLoadBudget(cwd, opts = {}) {
335
341
  const advisory = status === 'ok'
336
342
  ? 'Memory-load within budget.'
337
343
  : `Memory injection is ~${memoryTokens} tokens across ${agents.length} agent log(s)` +
338
- (fraction != null ? ` (~${Math.round(fraction * 100)}% of median agent input)` : '') +
344
+ (fraction != null ? ` (~${Math.round(fraction * 100)}% of a median agent prompt)` : '') +
339
345
  `. Bound it with cue-scoped 'memory select' or trim with 'memory compact <agent>'.`;
340
- return { memory_tokens: memoryTokens, agents: agents.length, median_input_tokens: median, fraction, status, advisory };
346
+ return { memory_tokens: memoryTokens, agents: agents.length, median_prompt_tokens: median, fraction, status, advisory };
341
347
  }
342
348
 
343
349
  // ─── CLI command wrappers ────────────────────────────────────────────────────
@@ -134,9 +134,82 @@ function getCurrentSessionId(cwd) {
134
134
  }
135
135
  }
136
136
 
137
+ /**
138
+ * Day-scoped auto-session id, the same shape the trace hook mints
139
+ * (`sess_auto_YYYYMMDD`) so a day's hook-written and agent-reported events share one
140
+ * session instead of splitting into two.
141
+ */
142
+ function autoSessionId(now = new Date()) {
143
+ const p = (n) => String(n).padStart(2, '0');
144
+ return `sess_auto_${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}`;
145
+ }
146
+
147
+ /**
148
+ * How long a `current-session` pointer is evidence of a live session. An explicit
149
+ * (non-auto) session used to stay "current" indefinitely — a July session was still
150
+ * current in September in a field project, so `readActiveSessionMeta` in the cost hook
151
+ * backfilled two-month-old command/phase onto today's ledger rows, and agent-reported
152
+ * events would have landed in a long-dead session's directory (field sweep 2026-09-17).
153
+ */
154
+ const SESSION_STALE_MS = 24 * 60 * 60 * 1000;
155
+
156
+ /**
157
+ * Last time anything was written to a session: its event log if it has one, else the
158
+ * moment it started. Null when the session cannot be read at all.
159
+ */
160
+ function sessionLastActivityMs(cwd, sid) {
161
+ const dir = path.join(getTracesDir(cwd), sid);
162
+ try {
163
+ return fs.statSync(path.join(dir, TRACE_EVENT_FILE)).mtimeMs;
164
+ } catch { /* no events yet */ }
165
+ try {
166
+ const meta = JSON.parse(fs.readFileSync(path.join(dir, OPT_SESSION_FILE), 'utf-8'));
167
+ const t = new Date(meta.started_at).getTime();
168
+ return Number.isFinite(t) ? t : null;
169
+ } catch {
170
+ return null;
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Is this session finished, or too old to still be the one running? Read-only —
176
+ * finalizing a stale session is the writing path's job (the trace hook's rollover).
177
+ */
178
+ function isSessionStale(cwd, sid, now = Date.now()) {
179
+ const dir = path.join(getTracesDir(cwd), sid);
180
+ try {
181
+ const meta = JSON.parse(fs.readFileSync(path.join(dir, OPT_SESSION_FILE), 'utf-8'));
182
+ if (meta && meta.ended_at) return true;
183
+ } catch { /* unreadable meta — fall through to the age test */ }
184
+ const last = sessionLastActivityMs(cwd, sid);
185
+ if (last === null) return true;
186
+ return now - last > SESSION_STALE_MS;
187
+ }
188
+
189
+ /**
190
+ * Append one trace event.
191
+ *
192
+ * Creates the day's auto-session when none is active. It used to return false instead,
193
+ * and the 16 `optimize trace log` call sites in the workflows are fire-and-forget
194
+ * (`2>/dev/null || true`), so on the phase pipeline — which never starts a trace
195
+ * session — every agent-reported event was silently discarded. Across fourteen field
196
+ * projects the instrument held 3,737 events, 99.7% of them the completion rows the hook
197
+ * writes, and not one error, gap or correction in its whole history (sweep 2026-09-17).
198
+ * An explicit `--session` is still honoured verbatim.
199
+ */
137
200
  function logTraceEvent(cwd, event, sessionId) {
138
- const sid = sessionId || getCurrentSessionId(cwd);
139
- if (!sid) return false;
201
+ // An explicit id is honoured verbatim. A POINTER, by contrast, is only evidence while
202
+ // the session it names is alive; a dead one is no session at all.
203
+ let sid = sessionId || null;
204
+ if (!sid) {
205
+ const current = getCurrentSessionId(cwd);
206
+ if (current && !isSessionStale(cwd, current)) sid = current;
207
+ }
208
+ if (!sid) {
209
+ const created = initTraceSession(cwd, { sessionId: autoSessionId(), description: 'auto-session (day-scoped)' });
210
+ if (!created || created.error) return false;
211
+ sid = created.session_id;
212
+ }
140
213
 
141
214
  try {
142
215
  const sessionDir = path.join(getTracesDir(cwd), sid);
@@ -1290,6 +1363,9 @@ module.exports = {
1290
1363
  TRACE_EVENT_FILE,
1291
1364
  OPT_SESSION_FILE,
1292
1365
  CURRENT_SESSION_FILE,
1366
+ autoSessionId,
1367
+ isSessionStale,
1368
+ SESSION_STALE_MS,
1293
1369
  EVENT_TYPES,
1294
1370
  IMPACT_LEVELS,
1295
1371
  VALID_SCOPES,