pan-wizard 3.27.0 → 3.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +48 -48
  2. package/agents/pan-previewer.md +1 -1
  3. package/bin/install-lib.cjs +580 -18
  4. package/bin/install.js +25 -44
  5. package/commands/pan/army.md +1 -1
  6. package/commands/pan/cost.md +14 -2
  7. package/commands/pan/preview.md +2 -2
  8. package/hooks/dist/pan-check-update.js +4 -0
  9. package/hooks/dist/pan-cost-logger.js +322 -43
  10. package/hooks/dist/pan-trace-logger.js +275 -32
  11. package/package.json +8 -2
  12. package/pan-wizard-core/bin/lib/commands.cjs +3 -1
  13. package/pan-wizard-core/bin/lib/constants.cjs +39 -0
  14. package/pan-wizard-core/bin/lib/context-budget.cjs +80 -0
  15. package/pan-wizard-core/bin/lib/cost-rebuild.cjs +511 -0
  16. package/pan-wizard-core/bin/lib/cost.cjs +174 -18
  17. package/pan-wizard-core/bin/lib/foreign-planning.cjs +56 -0
  18. package/pan-wizard-core/bin/lib/git.cjs +5 -1
  19. package/pan-wizard-core/bin/lib/hud.cjs +5 -3
  20. package/pan-wizard-core/bin/lib/hygiene.cjs +52 -24
  21. package/pan-wizard-core/bin/lib/init.cjs +8 -0
  22. package/pan-wizard-core/bin/lib/memory.cjs +14 -8
  23. package/pan-wizard-core/bin/lib/optimize.cjs +78 -2
  24. package/pan-wizard-core/bin/lib/utils.cjs +22 -0
  25. package/pan-wizard-core/bin/lib/verify.cjs +46 -12
  26. package/pan-wizard-core/bin/pan-tools.cjs +8 -1
  27. package/pan-wizard-core/mcp/server.cjs +92 -8
  28. package/pan-wizard-core/mcp/tool-registry.cjs +50 -3
  29. package/pan-wizard-core/references/model-profiles.md +2 -2
  30. package/pan-wizard-core/workflows/health.md +2 -0
  31. package/pan-zcode/README.md +1 -1
  32. package/scripts/build-agent-plugin.js +220 -0
  33. package/scripts/build-plugin.js +48 -3
  34. package/scripts/coverage-gate.cjs +257 -0
  35. package/scripts/generate-skills-docs.py +1 -1
  36. package/scripts/install-git-hooks.js +5 -0
  37. package/scripts/mutation-probe.cjs +272 -0
  38. package/scripts/release-check.js +80 -13
  39. package/scripts/test-quality-lint.cjs +240 -0
  40. package/scripts/test-surface.cjs +335 -0
@@ -47,19 +47,39 @@ const TOKENS_FILE = 'tokens.jsonl';
47
47
  * Override per-model in config.json → cost.rates.
48
48
  */
49
49
  const DEFAULT_RATES = {
50
- // Anthropic — verified against platform pricing 2026-08. Opus 4.6+/Opus 5 are
50
+ // Anthropic — verified against platform pricing 2026-09-10. Opus 4.6+/Opus 5 are
51
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.
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).
58
+ 'claude-fable-5-1': { input: 10.0, output: 50.0, cache_read: 0.25, cache_write: 12.5 },
53
59
  '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.
64
+ 'claude-mythos-5-1': { input: 10.0, output: 50.0, cache_read: 0.25, cache_write: 12.5 },
65
+ 'claude-mythos-5': { input: 10.0, output: 50.0, cache_read: 1.0, cache_write: 12.5 },
54
66
  'claude-opus-5': { input: 5.0, output: 25.0, cache_read: 0.5, cache_write: 6.25 },
55
67
  'claude-opus-4-8': { input: 5.0, output: 25.0, cache_read: 0.5, cache_write: 6.25 },
56
68
  'claude-opus-4-7': { input: 5.0, output: 25.0, cache_read: 0.5, cache_write: 6.25 },
57
69
  'claude-opus-4-6': { input: 5.0, output: 25.0, cache_read: 0.5, cache_write: 6.25 },
58
- // Sonnet 5 standard is $3/$15; a launch promo runs $2/$10 through 2026-08-31.
59
- // We track the stable post-promo rate (the table is indicative; the staleness
60
- // checker flags it for re-verification).
61
- 'claude-sonnet-5': { input: 3.0, output: 15.0, cache_read: 0.3, cache_write: 3.75 },
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).
73
+ 'claude-opus-4-5': { input: 5.0, output: 25.0, cache_read: 0.5, cache_write: 6.25 },
74
+ // 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.
78
+ 'claude-sonnet-5': { input: 2.0, output: 10.0, cache_read: 0.20, cache_write: 2.50 },
62
79
  '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.
82
+ 'claude-sonnet-4-5': { input: 3.0, output: 15.0, cache_read: 0.3, cache_write: 3.75 },
63
83
  'claude-haiku-4-5': { input: 1.0, output: 5.0, cache_read: 0.1, cache_write: 1.25 },
64
84
 
65
85
  // OpenAI — verified against published pricing 2026-08. Prompt caching is a 90%
@@ -106,6 +126,83 @@ function familyPrefixRate(rates, model) {
106
126
  return families.length > 0 ? rates[families[0]] : null;
107
127
  }
108
128
 
129
+ // ─── Claude Code `modelPricing` as a rate source (2026-09) ──────────────────
130
+ //
131
+ // Claude Code ≥2.1.243 lets an organisation pin contracted per-model rates in
132
+ // MANAGED settings — `modelPricing: { "<modelId>": { inputCostPer1MTokens,
133
+ // outputCostPer1MTokens } }` (settings-reference, read 2026-09-10) — and prices
134
+ // its own /usage with them. Honouring the same block keeps PAN's ledger on the
135
+ // numbers the organisation actually pays. The shape carries no cache fields, so
136
+ // cache rates are DERIVED: the family's own multipliers when DEFAULT_RATES knows
137
+ // the family (Fable 5.1 reads bill at 0.025× input, not 0.1×), otherwise the
138
+ // Anthropic convention (read 0.1×, write 1.25×).
139
+
140
+ const round6 = (n) => Number(n.toFixed(6));
141
+
142
+ function ratesFromModelPricing(modelPricing) {
143
+ const out = {};
144
+ if (!modelPricing || typeof modelPricing !== 'object' || Array.isArray(modelPricing)) return out;
145
+ for (const [id, p] of Object.entries(modelPricing)) {
146
+ if (!p || typeof p !== 'object') continue;
147
+ const input = Number(p.inputCostPer1MTokens);
148
+ const output = Number(p.outputCostPer1MTokens);
149
+ if (!Number.isFinite(input) || !Number.isFinite(output) || input < 0 || output < 0) continue;
150
+ const fam = DEFAULT_RATES[id] || familyPrefixRate(DEFAULT_RATES, id);
151
+ const readMult = fam && fam.input > 0 ? fam.cache_read / fam.input : 0.1;
152
+ const writeMult = fam && fam.input > 0 ? fam.cache_write / fam.input : 1.25;
153
+ out[id] = { input, output, cache_read: round6(input * readMult), cache_write: round6(input * writeMult) };
154
+ }
155
+ return out;
156
+ }
157
+
158
+ // Where Claude Code reads managed settings (code.claude.com/docs/en/managed-settings,
159
+ // read 2026-09-10): macOS `/Library/Application Support/ClaudeCode`, Linux and WSL
160
+ // `/etc/claude-code`, Windows `C:\Program Files\ClaudeCode`. Claude Code does NOT
161
+ // read the legacy Windows path `C:\ProgramData\ClaudeCode` — so neither does PAN.
162
+ // `managed-settings.json` is merged first, then every `*.json` in
163
+ // `managed-settings.d/` in alphabetical order (hidden files skipped), later
164
+ // files winning. `PAN_MANAGED_SETTINGS_DIR` redirects the lookup (tests, and
165
+ // hosts that relocate the directory).
166
+ function managedSettingsDir(platform = process.platform, env = process.env) {
167
+ if (env.PAN_MANAGED_SETTINGS_DIR) return env.PAN_MANAGED_SETTINGS_DIR;
168
+ // `path.win32.join`, not `path.join`: the platform is an argument, so a POSIX
169
+ // host asked for the win32 directory must still get backslashes. On Windows the
170
+ // two are the same function.
171
+ if (platform === 'win32') return path.win32.join(env.ProgramFiles || 'C:\\Program Files', 'ClaudeCode');
172
+ if (platform === 'darwin') return '/Library/Application Support/ClaudeCode';
173
+ return '/etc/claude-code';
174
+ }
175
+
176
+ function loadManagedModelPricing(dir = managedSettingsDir()) {
177
+ const files = [path.join(dir, 'managed-settings.json')];
178
+ try {
179
+ const dropIns = path.join(dir, 'managed-settings.d');
180
+ files.push(...fs.readdirSync(dropIns)
181
+ .filter(f => !f.startsWith('.') && f.endsWith('.json'))
182
+ .sort()
183
+ .map(f => path.join(dropIns, f)));
184
+ } catch { /* no drop-in directory */ }
185
+ let merged = null;
186
+ for (const f of files) {
187
+ let parsed;
188
+ try { parsed = JSON.parse(fs.readFileSync(f, 'utf8')); } catch { continue; }
189
+ const mp = parsed && typeof parsed === 'object' ? parsed.modelPricing : null;
190
+ if (mp && typeof mp === 'object' && !Array.isArray(mp)) merged = { ...(merged || {}), ...mp };
191
+ }
192
+ return merged;
193
+ }
194
+
195
+ // The rate table a cost computation actually sees. Precedence, highest first:
196
+ // 1. `.planning/config.json → cost.rates` — PAN's explicit per-project override
197
+ // 2. managed `modelPricing` — the organisation's contracted rates
198
+ // 3. DEFAULT_RATES — resolveRate's own fallback when neither names the model
199
+ // Returns undefined (not {}) when nothing overrides, so callers keep the exact
200
+ // pre-2026-09 behaviour of passing no config rates.
201
+ function effectiveRates(config, managedPricing = loadManagedModelPricing()) {
202
+ const rates = { ...ratesFromModelPricing(managedPricing), ...(config?.cost?.rates || {}) };
203
+ return Object.keys(rates).length > 0 ? rates : undefined;
204
+ }
205
+
109
206
  function resolveRate(model, tier, configRates) {
110
207
  // Config overrides win over the built-in table — including for versioned ids.
111
208
  // Without the family-prefix pass here, a cost.rates override keyed on a family
@@ -187,7 +284,7 @@ function appendRecord(cwd, rec) {
187
284
  // time, so the two producers priced identical tokens differently.
188
285
  normalized.cost_usd = typeof rec.cost_usd === 'number'
189
286
  ? rec.cost_usd
190
- : computeCost(normalized, loadConfig(cwd)?.cost?.rates);
287
+ : computeCost(normalized, effectiveRates(loadConfig(cwd)));
191
288
 
192
289
  try {
193
290
  fs.mkdirSync(metricsDir(cwd), { recursive: true });
@@ -233,30 +330,74 @@ function readRecords(cwd) {
233
330
  */
234
331
  /**
235
332
  * A record is "suspect" when its token counts are physically implausible for a
236
- * single subagent — the signature of the pre-v3.12.4 transcript-oversum bug
237
- * (billions of cache-read, cache-read dwarfing input, 100% cache-hit). Such
238
- * records are quarantined from aggregates so a poisoned ledger can't report
239
- * millions of dollars. See docs/FIELD-REPORT-army-2026-06.md.
333
+ * single subagent — the oversum signature: a session's cumulative usage booked
334
+ * to one subagent row (billions of cache-read, cache-read dwarfing input, 100%
335
+ * cache-hit). Written by pre-v3.12.4 hooks, and by the parent-transcript slice
336
+ * path of every later hook up to v3.28 whenever a slice started at cursor 0 on a
337
+ * long-lived session. Such records are quarantined from aggregates so a
338
+ * poisoned ledger can't report millions of dollars.
339
+ * See docs/FIELD-REPORT-army-2026-06.md.
240
340
  * @param {Object} r - a cost record
241
341
  * @returns {boolean}
242
342
  */
343
+ // No single subagent runs for six hours — the longest native-workflow phase runs
344
+ // measured in the harness finish inside an hour — while a parent-transcript slice
345
+ // that spans a working day, or the idle night between two stops, does. Mirrored by
346
+ // the hooks' SLICE_MAX_DURATION_MS, which nulls the span on write. Calibrated on
347
+ // eleven field ledgers (2026-09): of the timed rows the old ratio rule flagged,
348
+ // 55 spanned under three hours and 12 spanned six to twenty-four — the latter all
349
+ // parent slices booked to a `general-purpose` or workflow subagent.
350
+ const SUSPECT_MAX_DURATION_MS = 6 * 60 * 60 * 1000;
351
+
243
352
  function isSuspectRecord(r) {
244
353
  if (!r || typeof r !== 'object') return false;
354
+ // Rows measured from a transcript that belongs to exactly one actor — the
355
+ // subagent's own file (v3.29 hooks, `cost rebuild`) or the main thread's
356
+ // session file (`cost rebuild`) — cannot carry another actor's usage, so the
357
+ // oversum signature does not apply to them: a 24-day main thread with
358
+ // billions of cached reads is simply a long session, measured exactly.
359
+ if (r.token_source === 'agent-transcript' || r.token_source === 'session-transcript') return false;
245
360
  const cr = r.cache_read_tokens || 0;
246
361
  const io = (r.input_tokens || 0) + (r.output_tokens || 0);
247
362
  if (cr > 5e8) return true; // no scoped subagent re-reads >500M cached tokens
248
- if (cr > 1e7 && cr > 100 * (io + 1)) return true; // cache-read dwarfs input+output
249
363
  if ((r.output_tokens || 0) > 1e7) return true; // ~10M output = cumulative oversum
364
+ const dur = typeof r.duration_ms === 'number' ? r.duration_ms : null;
365
+ if (dur != null && dur > SUSPECT_MAX_DURATION_MS) return true; // a six-hour-plus "subagent" is a session's history
366
+ // Cache-read dwarfing input+output is the oversum signature ONLY for a row the
367
+ // hook could not time (pre-v3.20 rows, unreadable transcripts). A timed row
368
+ // with a plausible span is a real agent: under prompt caching every turn
369
+ // re-reads the cached context, so a hundred-turn agent legitimately reads
370
+ // 300× more cached tokens than it writes. Applied to timed rows, this rule
371
+ // had excluded fifty sub-hour agents (~3 billion real cache-read tokens)
372
+ // from eleven field ledgers (2026-09).
373
+ if (dur == null && cr > 1e7 && cr > 100 * (io + 1)) return true;
250
374
  return false;
251
375
  }
252
376
 
377
+ /**
378
+ * A record is "empty" when it carries no tokens on any axis and no model: a
379
+ * spawn the hook could not measure — a sibling stop that arrived before the
380
+ * shared parent transcript had grown (the pre-v3.29 slice path), or a payload
381
+ * with neither usage nor a readable transcript. It has no cost and no tokens;
382
+ * counting it as a call inflated call counts by up to 2x in the field (480 of
383
+ * 976 rows across eleven ledgers, 2026-09). A zero-token row that names a model
384
+ * is NOT empty — that is a measured run that happened to use nothing.
385
+ * @param {Object} r - a cost record
386
+ * @returns {boolean}
387
+ */
388
+ function isEmptyRecord(r) {
389
+ if (!r || typeof r !== 'object') return false;
390
+ if (r.model) return false;
391
+ return !(r.input_tokens || r.output_tokens || r.cache_read_tokens || r.cache_write_tokens);
392
+ }
393
+
253
394
  function aggregate(cwd, opts) {
254
395
  const records = readRecords(cwd);
255
396
  const malformedSkipped = _lastReadMalformed; // captured before any later read
256
397
  const since = opts?.since ? new Date(opts.since).getTime() : null;
257
398
  const until = opts?.until ? new Date(opts.until).getTime() : null;
258
399
  const config = loadConfig(cwd);
259
- const configRates = config?.cost?.rates;
400
+ const configRates = effectiveRates(config);
260
401
 
261
402
  const filtered = records.filter(r => {
262
403
  if (!r.ts) return true;
@@ -275,6 +416,7 @@ function aggregate(cwd, opts) {
275
416
  cost_usd: 0,
276
417
  cost_unknown: 0,
277
418
  suspect_excluded: 0,
419
+ empty_excluded: 0,
278
420
  malformed_skipped: malformedSkipped,
279
421
  };
280
422
 
@@ -296,9 +438,11 @@ function aggregate(cwd, opts) {
296
438
  }
297
439
 
298
440
  for (const r of filtered) {
299
- // Quarantine physically-impossible records (pre-v3.12.4 transcript-oversum
300
- // bug) so a poisoned ledger doesn't poison the totals / HUD / /pan:cost.
441
+ // Quarantine physically-impossible records (the transcript-oversum
442
+ // signature) so a poisoned ledger doesn't poison the totals / HUD / /pan:cost.
301
443
  if (isSuspectRecord(r)) { totals.suspect_excluded += 1; continue; }
444
+ // Skip unmeasured spawns: no tokens, no model, nothing to price or count.
445
+ if (isEmptyRecord(r)) { totals.empty_excluded += 1; continue; }
302
446
  totals.calls += 1;
303
447
  totals.input_tokens += r.input_tokens || 0;
304
448
  totals.output_tokens += r.output_tokens || 0;
@@ -355,7 +499,12 @@ function renderTable(agg) {
355
499
  lines.push(window);
356
500
  lines.push('');
357
501
  lines.push('Totals');
358
- lines.push(` Calls : ${agg.totals.calls}${agg.totals.malformed_skipped > 0 ? ` (+${agg.totals.malformed_skipped} malformed)` : ''}`);
502
+ const skipped = [
503
+ agg.totals.suspect_excluded > 0 ? `${agg.totals.suspect_excluded} suspect` : null,
504
+ agg.totals.empty_excluded > 0 ? `${agg.totals.empty_excluded} empty` : null,
505
+ agg.totals.malformed_skipped > 0 ? `${agg.totals.malformed_skipped} malformed` : null,
506
+ ].filter(Boolean);
507
+ lines.push(` Calls : ${agg.totals.calls}${skipped.length ? ` (excluded: ${skipped.join(', ')})` : ''}`);
359
508
  lines.push(` Input tokens : ${agg.totals.input_tokens.toLocaleString()}`);
360
509
  lines.push(` Output tokens : ${agg.totals.output_tokens.toLocaleString()}`);
361
510
  lines.push(` Cache read : ${agg.totals.cache_read_tokens.toLocaleString()}`);
@@ -442,7 +591,7 @@ function cmdCostClear(cwd, raw) {
442
591
  // Bump this whenever the table is re-verified; `models check` flags the table
443
592
  // once it is older than RATES_STALE_AFTER_DAYS (provider prices move faster
444
593
  // than PAN releases do).
445
- const RATES_VERIFIED_AT = '2026-08-03';
594
+ const RATES_VERIFIED_AT = '2026-09-10';
446
595
  const RATES_STALE_AFTER_DAYS = 180;
447
596
  const RATE_TIERS = ['reasoning', 'mid', 'fast'];
448
597
 
@@ -460,7 +609,9 @@ function checkRatesStaleness(now = new Date()) {
460
609
  }
461
610
 
462
611
  function cmdModelsCheck(raw) {
463
- const result = checkRatesStaleness();
612
+ // Surface the managed rates too: an organisation that pins `modelPricing`
613
+ // should be able to see that PAN found the block, not infer it from totals.
614
+ const result = { ...checkRatesStaleness(), managed_model_pricing: Object.keys(loadManagedModelPricing() || {}) };
464
615
  const human = result.stale
465
616
  ? `Rate table verified ${result.rates_verified_at} (${result.age_days} days ago) — STALE: re-verify provider pricing and bump RATES_VERIFIED_AT in cost.cjs`
466
617
  : `Rate table verified ${result.rates_verified_at} (${result.age_days} days ago) — OK`;
@@ -473,9 +624,14 @@ module.exports = {
473
624
  readRecords,
474
625
  aggregate,
475
626
  isSuspectRecord,
627
+ isEmptyRecord,
476
628
  renderTable,
477
629
  renderChart,
478
630
  resolveRate,
631
+ ratesFromModelPricing,
632
+ managedSettingsDir,
633
+ loadManagedModelPricing,
634
+ effectiveRates,
479
635
  checkRatesStaleness,
480
636
  cmdCostReport,
481
637
  cmdCostAppend,
@@ -0,0 +1,56 @@
1
+ 'use strict';
2
+ /**
3
+ * Foreign planning-tree detection (reality check RC17 / plan item R15, 2026-09-10).
4
+ *
5
+ * gsd-core (open-gsd/gsd-core, the continuation of Get Shit Done) writes a `.planning/`
6
+ * directory with the same core files PAN's pre-v2.2 layout used — STATE.md, ROADMAP.md,
7
+ * PROJECT.md, REQUIREMENTS.md, MILESTONES.md in uppercase. To PAN's hygiene those read
8
+ * as LEGACY PAN files, and `hygiene clean --apply` would rename another tool's state.
9
+ * `validate health` would call the tree broken; `init new-project` would scaffold PAN
10
+ * files into it. This module answers one question — does this tree belong to another
11
+ * tool? — from POSITIVE markers PAN never writes (FOREIGN_PLANNING_MARKERS in
12
+ * constants.cjs, sourced from gsd-core's docs/USER-GUIDE.md, read 2026-09-10).
13
+ *
14
+ * Rule: foreign when any marker FILE exists, or at least two marker DIRECTORIES exist,
15
+ * or config.json carries any of the tool's flat dotted keys (PAN nests `workflow: {}`;
16
+ * gsd-core writes `"workflow.discuss_mode"`). An unreadable config.json is not evidence.
17
+ * Returns { tool, key, evidence[] } or null. Never throws.
18
+ */
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+ const { FOREIGN_PLANNING_MARKERS } = require('./constants.cjs');
22
+ const { planningPath } = require('./utils.cjs');
23
+
24
+ function detectForeignPlanningTree(planningDir) {
25
+ let entries;
26
+ try { entries = fs.readdirSync(planningDir, { withFileTypes: true }); } catch { return null; }
27
+ const names = new Set(entries.map(e => e.name));
28
+ const dirs = new Set(entries.filter(e => e.isDirectory()).map(e => e.name));
29
+ let cfg = null;
30
+ if (names.has('config.json')) {
31
+ try {
32
+ const parsed = JSON.parse(fs.readFileSync(path.join(planningDir, 'config.json'), 'utf8'));
33
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) cfg = parsed;
34
+ } catch { cfg = null; }
35
+ }
36
+ for (const [key, m] of Object.entries(FOREIGN_PLANNING_MARKERS)) {
37
+ const fileHits = m.files.filter(f => names.has(f));
38
+ const dirHits = m.dirs.filter(d => dirs.has(d));
39
+ const configHits = cfg ? m.configKeys.filter(k => Object.prototype.hasOwnProperty.call(cfg, k)) : [];
40
+ if (fileHits.length > 0 || dirHits.length >= 2 || configHits.length > 0) {
41
+ return {
42
+ tool: m.tool,
43
+ key,
44
+ evidence: [...fileHits, ...dirHits.map(d => d + '/'), ...configHits.map(k => 'config.json:' + k)],
45
+ };
46
+ }
47
+ }
48
+ return null;
49
+ }
50
+
51
+ /** Same check, addressed by project cwd (honours --planning-dir / --track). */
52
+ function detectForeignPlanningTreeAt(cwd) {
53
+ return detectForeignPlanningTree(planningPath(cwd));
54
+ }
55
+
56
+ module.exports = { detectForeignPlanningTree, detectForeignPlanningTreeAt };
@@ -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,9 +41,11 @@ 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');
45
+ const { detectForeignPlanningTree } = require('./foreign-planning.cjs');
43
46
  const { listMemoryAgents, readMemory, compactMemory } = require('./memory.cjs');
44
47
  const { readRecords, isSuspectRecord, METRICS_DIR, TOKENS_FILE } = require('./cost.cjs');
48
+ const { assessCacheTtl } = require('./context-budget.cjs');
45
49
  const { planningRootRel, planningRoots, withPlanningRoot, describePlanningRoot, TRACKS_DIR } = require('./planning-root.cjs');
46
50
 
47
51
  /** Runtime config dirs a PAN install can live in, relative to project root. */
@@ -251,7 +255,8 @@ function recordMass(r) {
251
255
  }
252
256
 
253
257
  /**
254
- * 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.
255
260
  *
256
261
  * Gated on token MASS as well as record count. A count-only gate passes a ledger
257
262
  * whose few bad rows carry most of the tokens — field case: 24% of rows suspect
@@ -284,7 +289,7 @@ function checkCostLedger(cwd) {
284
289
  : 'token mass';
285
290
  findings.push(mkFinding('poisoned-ledger', 'critical',
286
291
  planningRel(METRICS_DIR, TOKENS_FILE),
287
- `${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`,
288
293
  { action: 'quarantine-ledger' }));
289
294
  return { findings };
290
295
  }
@@ -456,6 +461,17 @@ function checkCachedContext(cwd) {
456
461
  `~${fmtTokens(tokens)} tokens re-read on every agent call (warn ${fmtTokens(CACHE_FILE_WARN_TOKENS)})${suffix}`,
457
462
  fix));
458
463
  }
464
+
465
+ // Lifetime signal (ADR-0046 D5): the ledger shows cache WRITES that followed
466
+ // an idle gap of five to sixty minutes — misses a one-hour subagent cache
467
+ // lifetime would have turned into hits. Informational and never fixable: the
468
+ // remedy is a Claude Code setting the user weighs against the 2× write price.
469
+ try {
470
+ const ttl = assessCacheTtl(readRecords(cwd).filter(r => !isSuspectRecord(r)));
471
+ if (ttl.recommend) {
472
+ findings.push(mkFinding('cache-context', ttl.severity, planningRel(path.join(METRICS_DIR, TOKENS_FILE)), ttl.advice, null));
473
+ }
474
+ } catch { /* no ledger, or unreadable — nothing to say */ }
459
475
  return { findings };
460
476
  }
461
477
 
@@ -481,15 +497,11 @@ function checkPlanningFragment(cwd) {
481
497
  const dir = planningPath(cwd);
482
498
  let entries = [];
483
499
  try { entries = fs.readdirSync(dir); } catch { return { findings, planning_exists: false }; }
484
- const lower = entries.map(e => e.toLowerCase());
485
- // Spine = anything that marks a deliberate PAN workflow: the phase model
486
- // (project/state/phases/roadmap/requirements/milestones) OR the focus model
487
- // (focus/quick) OR an orchestration campaign. A dir holding only generated
488
- // artifacts (codebase maps, metrics, traces) is a stray fragment.
489
- const SPINE = ['project.md', 'state.md', 'phases', 'roadmap.md', 'requirements.md',
490
- 'milestones', 'focus', 'quick', 'orchestration'];
491
- const hasSpine = SPINE.some(s => lower.includes(s));
492
- 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') {
493
505
  findings.push(mkFinding('planning-fragment', 'info', planningRootRel(),
494
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`,
495
507
  null));
@@ -517,6 +529,18 @@ function checkPlanningFragment(cwd) {
517
529
  */
518
530
  function scanOneRoot(cwd, root, opts) {
519
531
  return withPlanningRoot(root.rel, () => {
532
+ // A .planning/ written by ANOTHER tool (gsd-core shares the directory name and
533
+ // PAN's pre-v2.2 uppercase file names) must never be "repaired": the legacy
534
+ // rename would rename its state files. One warn finding, nothing fixable, and
535
+ // none of the per-tree checks run on it. Reality check R15.
536
+ const foreign = detectForeignPlanningTree(planningPath(cwd));
537
+ if (foreign) {
538
+ const f = mkFinding('foreign-planning-tree', 'warn', planningRel(),
539
+ `planning tree belongs to ${foreign.tool} (${foreign.evidence.join(', ')}) — PAN will not rename or repair its files; run PAN with --planning-dir to give it a tree of its own (ADR-0043)`,
540
+ null);
541
+ f.track = root.name;
542
+ return { findings: [f], planning_exists: true };
543
+ }
520
544
  const fragment = checkPlanningFragment(cwd);
521
545
  const findings = [
522
546
  ...fragment.findings,
@@ -594,6 +618,12 @@ function applyFix(cwd, finding) {
594
618
  try {
595
619
  switch (fix.action) {
596
620
  case 'rename-lowercase': {
621
+ // Defence in depth for R15: the scan never emits this fix for a foreign tree,
622
+ // but a stale findings list or a hand-built one must not rename another
623
+ // tool's files either.
624
+ if (detectForeignPlanningTree(path.dirname(abs))) {
625
+ return { applied: false, detail: 'refused: this planning tree belongs to another tool (see the foreign-planning-tree finding)' };
626
+ }
597
627
  // Two-step rename: Windows treats case-only renames inconsistently
598
628
  // across fs layers, so hop through a temp name.
599
629
  const dir = path.dirname(abs);
@@ -630,16 +660,14 @@ function applyFix(cwd, finding) {
630
660
  const dest = `${abs}.quarantined-${stamp}`;
631
661
  fs.renameSync(abs, dest);
632
662
 
633
- // The cursor is a per-transcript high-water mark INTO the ledger we just
634
- // moved aside. Left behind it points at rows that are no longer there,
635
- // so the fresh ledger starts mid-stream and the next slice is undercounted.
636
- // A "fresh ledger" that inherits the old ledger's read position is not fresh.
637
- let cursorNote = '';
638
- try {
639
- const cursor = path.join(path.dirname(abs), COST_CURSOR_FILE);
640
- fs.unlinkSync(cursor);
641
- cursorNote = ', cursor reset';
642
- } 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.
643
671
 
644
672
  // Quarantine leaves a dated copy behind, and nothing else ever removes
645
673
  // one. Run hygiene a few times over a year and the metrics dir fills
@@ -649,7 +677,7 @@ function applyFix(cwd, finding) {
649
677
 
650
678
  return {
651
679
  applied: true,
652
- 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`,
653
681
  };
654
682
  }
655
683
  default:
@@ -10,6 +10,7 @@ const { planningPath, phasesPath, filterPlanFiles, filterSummaryFiles, classifyP
10
10
  const { classifyPlanTier } = require('./phase.cjs');
11
11
  const { extractFrontmatter } = require('./frontmatter.cjs');
12
12
  const { detectLanguages } = require('./codebase.cjs');
13
+ const { detectForeignPlanningTreeAt } = require('./foreign-planning.cjs');
13
14
  const { planningRootRel, describePlanningRoot, planningRoots, withPlanningRoot } = require('./planning-root.cjs');
14
15
 
15
16
  // ---- Git helpers ----
@@ -282,6 +283,13 @@ function cmdInitPlanPhase(cwd, phase, raw) {
282
283
  * @returns {void}
283
284
  */
284
285
  function cmdInitNewProject(cwd, raw) {
286
+ // Never scaffold PAN's files into a .planning/ another tool owns (R15). The error
287
+ // key carries the exit code; the fix is a separate tree via --planning-dir.
288
+ const foreign = detectForeignPlanningTreeAt(cwd);
289
+ if (foreign) {
290
+ output({ error: `planning tree belongs to ${foreign.tool}`, evidence: foreign.evidence, fix: 'Run PAN with --planning-dir <dir> to use a separate tree (ADR-0043)' }, raw);
291
+ return;
292
+ }
285
293
  const config = loadConfig(cwd);
286
294
 
287
295
  // Detect Brave Search API key availability
@@ -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 ────────────────────────────────────────────────────