pan-wizard 3.27.0 → 3.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/install.js CHANGED
@@ -46,7 +46,7 @@ const pkg = require('../package.json');
46
46
  // phrased by capability, not by name; these are the "switch to this" examples
47
47
  // that keep the advice actionable. Nothing in PAN gates on these values.
48
48
  const RECOMMENDED_MODELS = {
49
- flagship: 'claude-fable-5',
49
+ flagship: 'claude-fable-5-1',
50
50
  reasoningTier: 'claude-opus-5 / claude-opus-4-8',
51
51
  };
52
52
 
@@ -565,21 +565,15 @@ function copyCommandsAsUnifiedSkills(srcDir, skillsDir, prefix, pathPrefix, core
565
565
  fs.mkdirSync(skillDir, { recursive: true });
566
566
 
567
567
  let content = fs.readFileSync(srcPath, 'utf8');
568
- // Core + agent-definition references → shared .agents/ copies (specific,
569
- // before the generic rewrites); everything else .claude-scoped → the
570
- // installing runtime. Agent refs point at the canonical reference copies
571
- // shipped with the shared core — the runtime's own agents dir may carry
572
- // a different format (Codex TOML, Copilot .agent.md).
573
- content = content.replace(/~\/\.claude\/pan-wizard-core\//g, `${corePrefix}pan-wizard-core/`);
574
- content = content.replace(/\.\/\.claude\/pan-wizard-core\//g, `${corePrefix}pan-wizard-core/`);
575
- content = content.replace(/~\/\.claude\/agents\//g, `${corePrefix}pan-wizard-core/agents/`);
576
- content = content.replace(/\.\/\.claude\/agents\//g, `${corePrefix}pan-wizard-core/agents/`);
577
- content = content.replace(/~\/\.claude\//g, pathPrefix);
578
- content = content.replace(/\.\/\.claude\//g, `./${getDirName(runtime)}/`);
579
- // Not every runtime puts a `pan-tools` bin on PATH — invoke via node.
580
- const panToolsPath = `${corePrefix}pan-wizard-core/bin/pan-tools.cjs`;
581
- content = content.replace(/\bpan-tools\b(?=\s+[a-z])/g, `node ${panToolsPath}`);
582
- content = processAttribution(content, getCommitAttribution(runtime));
568
+ // The path rewrite lives in install-lib (rewriteUnifiedSkillCommandContent)
569
+ // because the Agent Plugins bundle builder runs the SAME function — one
570
+ // converter, several call sites, never a second copy (ADR-0028, ADR-0045).
571
+ content = lib.rewriteUnifiedSkillCommandContent(content, {
572
+ corePrefix,
573
+ pathPrefix,
574
+ projectDirPrefix: `./${getDirName(runtime)}/`,
575
+ attribution: getCommitAttribution(runtime),
576
+ });
583
577
  content = convertClaudeCommandToUnifiedSkill(content, skillName);
584
578
 
585
579
  fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content);
@@ -670,17 +664,14 @@ function copySharedCore(srcDir, destDir, corePrefix, runtimePathPrefix, runtime)
670
664
  recurse(srcPath, destPath);
671
665
  } else if (entry.name.endsWith('.md')) {
672
666
  try {
673
- let content = fs.readFileSync(srcPath, 'utf8');
674
- content = content.replace(/~\/\.claude\/pan-wizard-core\//g, `${corePrefix}pan-wizard-core/`);
675
- content = content.replace(/\.\/\.claude\/pan-wizard-core\//g, `${corePrefix}pan-wizard-core/`);
676
- // Agent-definition refs → the canonical reference copies in the
677
- // shared core (runtime agents dirs carry runtime-specific formats).
678
- content = content.replace(/~\/\.claude\/agents\//g, `${corePrefix}pan-wizard-core/agents/`);
679
- content = content.replace(/\.\/\.claude\/agents\//g, `${corePrefix}pan-wizard-core/agents/`);
680
- content = content.replace(/~\/\.claude\//g, runtimePathPrefix);
681
- content = content.replace(/\.\/\.claude\//g, `./${dirName}/`);
682
- content = processAttribution(content, getCommitAttribution(runtime));
683
- content = convertSlashCommandsToCopilotSkillMentions(content);
667
+ // Shared with the Agent Plugins bundle builder (install-lib) — see
668
+ // rewriteSharedCoreMarkdown for the rewrite order and rationale.
669
+ const content = lib.rewriteSharedCoreMarkdown(fs.readFileSync(srcPath, 'utf8'), {
670
+ corePrefix,
671
+ pathPrefix: runtimePathPrefix,
672
+ projectDirPrefix: `./${dirName}/`,
673
+ attribution: getCommitAttribution(runtime),
674
+ });
684
675
  fs.writeFileSync(destPath, content);
685
676
  } catch (err) {
686
677
  pushInstallWarning('copySharedCore(md)', destPath, err);
@@ -728,21 +719,13 @@ function stripInternalFromLearningsIndex(indexPath) {
728
719
  if (err.code !== 'ENOENT') pushInstallWarning('stripInternalLearnings', 'learnings/index.json', err);
729
720
  return;
730
721
  }
731
- if (!parsed || !Array.isArray(parsed.topics)) return;
732
-
733
- const kept = parsed.topics.filter(t => t && t.scope !== 'internal');
734
- if (kept.length === parsed.topics.length) return; // nothing internal to drop
735
-
736
- parsed.topics = kept;
737
- if (parsed.totals && typeof parsed.totals === 'object') {
738
- parsed.totals.topics = kept.length;
739
- parsed.totals.patterns = kept.reduce((n, t) => n + (Array.isArray(t.patterns) ? t.patterns.length : 0), 0);
740
- parsed.totals.size_bytes = kept.reduce((n, t) => n + (t.size_bytes || 0), 0);
741
- parsed.totals.size_tokens_est = kept.reduce((n, t) => n + (t.size_tokens_est || 0), 0);
742
- }
722
+ // The transform is pure and shared with the bundle builders (install-lib):
723
+ // null means "not an index" or "nothing internal to drop" — both no-ops here.
724
+ const stripped = lib.stripInternalLearningsTopics(parsed);
725
+ if (!stripped) return;
743
726
 
744
727
  try {
745
- fs.writeFileSync(indexPath, JSON.stringify(parsed, null, 2) + '\n');
728
+ fs.writeFileSync(indexPath, JSON.stringify(stripped, null, 2) + '\n');
746
729
  } catch (err) {
747
730
  pushInstallWarning('stripInternalLearnings', 'learnings/index.json', err);
748
731
  }
@@ -2230,10 +2213,8 @@ function install(isGlobal, runtime = 'claude') {
2230
2213
  fs.mkdirSync(agentsRefDir, { recursive: true });
2231
2214
  const agentsSrc = path.join(src, 'agents');
2232
2215
  for (const f of fs.readdirSync(agentsSrc).filter(n => n.endsWith('.md'))) {
2233
- let content = fs.readFileSync(path.join(agentsSrc, f), 'utf8');
2234
- content = content.replace(/~\/\.claude\/pan-wizard-core\//g, `${corePrefix}pan-wizard-core/`);
2235
- content = content.replace(/\.\/\.claude\/pan-wizard-core\//g, `${corePrefix}pan-wizard-core/`);
2236
- content = convertSlashCommandsToCopilotSkillMentions(content);
2216
+ // Shared with the Agent Plugins bundle builder (install-lib).
2217
+ const content = lib.rewriteAgentReferenceCopy(fs.readFileSync(path.join(agentsSrc, f), 'utf8'), corePrefix);
2237
2218
  fs.writeFileSync(path.join(agentsRefDir, f), content);
2238
2219
  }
2239
2220
  } catch (e) {
@@ -92,7 +92,7 @@ Every cap the conductor enforces applies to the campaign, scaled up:
92
92
  | `--push` | off | Push approved merges to origin (still human-gated). |
93
93
  | `--clean-seal` | off | One clean build + full verification after the last item (commands from config). |
94
94
  | `--schedule` | off | Arm a self-resuming campaign at this cadence (`hourly`/`daily`/`weekly`/`Nh`/`Nd`) instead of running once — writes the schedule descriptor (ADR-0034). Pair with `--daily-budget`. |
95
- | `--daily-budget` | 300 | Per-day point budget for a scheduled campaign. Advisory by default (an indicator of the day's spend); it only pauses the day's run when `budget.enforce`/`enforce_budget` is set. |
95
+ | `--daily-budget` | 300 | Per-day point budget for a scheduled campaign. Advisory by default (an indicator of the day's spend); it only pauses the day's run when `enforce_budget: true` is set by hand in the schedule descriptor (`schedule.json`); no flag or config key sets it. |
96
96
  | `--dry-run` | off | Plan + squad delegation preview only; STOP. |
97
97
  | `--continue` / `--stop` / `--status` | — | Resume / halt / report from `.planning/orchestration/` + focus-auto state. |
98
98
 
@@ -34,9 +34,9 @@ Consolidates Spec B v1's architect + simulate + predict-milestone into one entry
34
34
  **What it does:**
35
35
  1. `pan-tools preview phase <N>` returns `{files_mentioned, test_files_mentioned, risk_signals, risk_score, plans[], status}`.
36
36
  2. Spawn `pan-previewer` with the payload as `<preview_input>`.
37
- 3. Agent writes `.planning/phases/<N>/preview.md` with files touched / tests at risk / migration steps / risk assessment / bottom line.
37
+ 3. Agent writes `.planning/phases/<NN-slug>/preview.md` with files touched / tests at risk / migration steps / risk assessment / bottom line.
38
38
 
39
- **Output:** `.planning/phases/<N>/preview.md`
39
+ **Output:** `.planning/phases/<NN-slug>/preview.md`
40
40
 
41
41
  ### `phases` — Cross-phase dependency graph
42
42
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pan-wizard",
3
- "version": "3.27.0",
3
+ "version": "3.28.0",
4
4
  "description": "Command a bot army for your codebase: a reasoning-tier Mission Control delegates whole-project goals to specialist squads and ships behind a human merge gate. Five AI CLIs, zero context rot.",
5
5
  "bin": {
6
6
  "pan-wizard": "bin/install.js"
@@ -70,6 +70,9 @@
70
70
  "test:e2e": "node scripts/run-tests.cjs tests/scenarios",
71
71
  "test:vscode": "npx playwright test --config tests/e2e/playwright.config.mjs",
72
72
  "test:watch": "node scripts/run-tests.cjs --watch tests tests/scenarios",
73
- "build:plugin": "node scripts/build-plugin.js"
73
+ "build:plugin": "node scripts/build-plugin.js",
74
+ "build:agent-plugin": "node scripts/build-agent-plugin.js",
75
+ "harness": "node harness/src/run.cjs --tier 0",
76
+ "harness:model": "node harness/src/run.cjs --tier 2"
74
77
  }
75
78
  }
@@ -687,6 +687,27 @@ const REFLECTION_THRESHOLD = {
687
687
  enable_on_tiers: ['reasoning'],
688
688
  };
689
689
 
690
+ /**
691
+ * Markers of a `.planning/` tree written by ANOTHER tool. gsd-core (open-gsd/gsd-core,
692
+ * the continuation of Get Shit Done) uses the same directory name and the same uppercase
693
+ * core files PAN's pre-v2.2 layout used, so hygiene's legacy-filename rename would rename
694
+ * another tool's state. These are POSITIVE markers PAN never writes — files, directories,
695
+ * and gsd-core's flat dotted config keys (PAN nests `workflow: {}`). Source: gsd-core
696
+ * docs/USER-GUIDE.md, read 2026-09-10. Consumed by foreign-planning.cjs (reality check R15).
697
+ */
698
+ const FOREIGN_PLANNING_MARKERS = Object.freeze({
699
+ gsd: Object.freeze({
700
+ tool: 'gsd-core',
701
+ files: Object.freeze(['HANDOFF.json', '.gsd-allow-shrink']),
702
+ dirs: Object.freeze(['forensics', 'threads', 'seeds', 'ui-reviews', 'sketches', 'spikes', 'onboarding']),
703
+ configKeys: Object.freeze([
704
+ 'workflow.discuss_mode', 'workflow.context_coverage_gate', 'workflow.ui_phase', 'workflow.ui_safety_gate',
705
+ 'workflow.skip_discuss', 'workflow.drift_action', 'workflow.drift_threshold', 'plan_review.source_grounding',
706
+ 'graphify.enabled', 'intel.enabled', 'hooks.workflow_guard', 'dynamic_routing', 'resolve_model_ids',
707
+ ]),
708
+ }),
709
+ });
710
+
690
711
  module.exports = {
691
712
  // Directories
692
713
  PLANNING_DIR,
@@ -765,6 +786,7 @@ module.exports = {
765
786
  COMPLEX_FILE_THRESHOLD,
766
787
  CHARS_PER_TOKEN,
767
788
  HEALTH_STATUS,
789
+ FOREIGN_PLANNING_MARKERS,
768
790
  MAX_JSON_SIZE,
769
791
  PROGRESS_BAR_WIDTH,
770
792
  MAX_SLUG_LENGTH,
@@ -71,6 +71,66 @@ function estimateRelevanceRatio(text) {
71
71
  * @param {string} cwd - Project root directory
72
72
  * @param {boolean} raw - If true, output human-readable string
73
73
  */
74
+ // ─── Prompt-cache lifetime signal (2026-09, ADR-0044 follow-up) ─────────────
75
+ //
76
+ // Claude Code decides the prompt-cache lifetime per request bucket: the main
77
+ // conversation can get one hour on a subscription, but EVERYTHING ELSE —
78
+ // subagents, workflows, forks — gets five minutes unless `subagentPromptCacheTtl`
79
+ // (≥2.1.242) says otherwise. Every PAN agent is a subagent. So a phase whose
80
+ // agents are spaced more than five minutes apart re-writes the same cached
81
+ // context block each time, and ADR-0044 measured that block as the bulk of PAN's
82
+ // token traffic. This assessor reads the cost ledger for exactly that signature:
83
+ // a cache WRITE that follows an idle gap of five to sixty minutes — a miss the
84
+ // one-hour lifetime would have turned into a hit. It recommends the setting only
85
+ // when the pattern recurs, because one-hour writes bill at 2× base input against
86
+ // 1.25× for five-minute writes: the longer lifetime pays off once a block is read
87
+ // twice inside the hour, and costs more on bursts that never idle.
88
+
89
+ const TTL_SHORT_MIN = 5; // the default subagent lifetime, in minutes
90
+ const TTL_LONG_MIN = 60; // the lifetime the setting buys
91
+ const TTL_MIN_WRITE_TOKENS = 1000; // ignore trivial writes (a few tokens of tool results)
92
+ const TTL_RECOMMEND_AT = 2; // recurrence, not a single event, earns the recommendation
93
+
94
+ /**
95
+ * Pure. Scan ledger records (oldest first by `ts`) for cache writes that follow
96
+ * an idle gap in (TTL_SHORT_MIN, TTL_LONG_MIN] — writes the one-hour lifetime
97
+ * would have avoided. Records without a parseable `ts`, and records flagged
98
+ * suspect by the caller (pass them pre-filtered), are ignored.
99
+ *
100
+ * @param {Array<object>} records - cost ledger rows ({ts, cache_write_tokens, …})
101
+ * @param {{minWriteTokens?:number, recommendAt?:number}} [opts]
102
+ * @returns {{records_considered:number, writes_after_short_idle:number, tokens_after_short_idle:number, writes_after_long_idle:number, recommend:boolean, setting:string, advice:string|null}}
103
+ */
104
+ function assessCacheTtl(records, opts = {}) {
105
+ const minWrite = opts.minWriteTokens ?? TTL_MIN_WRITE_TOKENS;
106
+ const recommendAt = opts.recommendAt ?? TTL_RECOMMEND_AT;
107
+ const rows = (Array.isArray(records) ? records : [])
108
+ .map(r => ({ t: r && r.ts ? new Date(r.ts).getTime() : NaN, w: Number(r && r.cache_write_tokens) || 0 }))
109
+ .filter(r => Number.isFinite(r.t))
110
+ .sort((a, b) => a.t - b.t);
111
+ let shortIdle = 0; let shortIdleTokens = 0; let longIdle = 0;
112
+ for (let i = 1; i < rows.length; i++) {
113
+ if (rows[i].w < minWrite) continue;
114
+ const gapMin = (rows[i].t - rows[i - 1].t) / 60000;
115
+ if (gapMin > TTL_SHORT_MIN && gapMin <= TTL_LONG_MIN) { shortIdle++; shortIdleTokens += rows[i].w; }
116
+ else if (gapMin > TTL_LONG_MIN) longIdle++;
117
+ }
118
+ const recommend = shortIdle >= recommendAt;
119
+ const setting = 'subagentPromptCacheTtl';
120
+ const advice = recommend
121
+ ? `${shortIdle} cache writes followed an idle gap of ${TTL_SHORT_MIN}–${TTL_LONG_MIN} min (~${shortIdleTokens.toLocaleString()} tokens re-written): subagents get the five-minute cache lifetime by default — set \`${setting}: "1h"\` in a Claude Code settings file. One-hour writes bill at 2× base input against 1.25×, so this pays off once a block is read twice within the hour.`
122
+ : null;
123
+ return {
124
+ records_considered: rows.length,
125
+ writes_after_short_idle: shortIdle,
126
+ tokens_after_short_idle: shortIdleTokens,
127
+ writes_after_long_idle: longIdle,
128
+ recommend,
129
+ setting,
130
+ advice,
131
+ };
132
+ }
133
+
74
134
  function cmdContextBudget(cwd, raw) {
75
135
  const planDir = planningPath(cwd);
76
136
  if (!fileAccessible(planDir)) {
@@ -204,6 +264,14 @@ function cmdContextBudget(cwd, raw) {
204
264
  : `cached context is re-read on every agent call; largest file ${largest[0].path} (~${largest[0].tokens} tokens)`
205
265
  + (largest[0].path.endsWith('state.md') ? ' — run `pan-tools state compact`' : '');
206
266
 
267
+ // Lifetime signal from the ledger (suspect rows excluded — they carry
268
+ // poisoned counters, not real writes). Absent ledger → zero rows, no advice.
269
+ let ttl = null;
270
+ try {
271
+ const { readRecords, isSuspectRecord } = require('./cost.cjs');
272
+ ttl = assessCacheTtl(readRecords(cwd).filter(r => !isSuspectRecord(r)));
273
+ } catch { ttl = null; }
274
+
207
275
  cache = {
208
276
  block_count: cached.blocks.length,
209
277
  block_paths: cached.blocks.map(b => b.path),
@@ -216,6 +284,7 @@ function cmdContextBudget(cwd, raw) {
216
284
  crit_tokens: CACHE_BLOCK_CRIT_TOKENS,
217
285
  file_warn_tokens: CACHE_FILE_WARN_TOKENS,
218
286
  advice,
287
+ ttl,
219
288
  sha: cached.sha,
220
289
  };
221
290
  } catch {
@@ -275,4 +344,5 @@ module.exports = {
275
344
  cmdContextBudget,
276
345
  estimateTokens,
277
346
  estimateRelevanceRatio,
347
+ assessCacheTtl,
278
348
  };
@@ -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 });
@@ -256,7 +353,7 @@ function aggregate(cwd, opts) {
256
353
  const since = opts?.since ? new Date(opts.since).getTime() : null;
257
354
  const until = opts?.until ? new Date(opts.until).getTime() : null;
258
355
  const config = loadConfig(cwd);
259
- const configRates = config?.cost?.rates;
356
+ const configRates = effectiveRates(config);
260
357
 
261
358
  const filtered = records.filter(r => {
262
359
  if (!r.ts) return true;
@@ -442,7 +539,7 @@ function cmdCostClear(cwd, raw) {
442
539
  // Bump this whenever the table is re-verified; `models check` flags the table
443
540
  // once it is older than RATES_STALE_AFTER_DAYS (provider prices move faster
444
541
  // than PAN releases do).
445
- const RATES_VERIFIED_AT = '2026-08-03';
542
+ const RATES_VERIFIED_AT = '2026-09-10';
446
543
  const RATES_STALE_AFTER_DAYS = 180;
447
544
  const RATE_TIERS = ['reasoning', 'mid', 'fast'];
448
545
 
@@ -460,7 +557,9 @@ function checkRatesStaleness(now = new Date()) {
460
557
  }
461
558
 
462
559
  function cmdModelsCheck(raw) {
463
- const result = checkRatesStaleness();
560
+ // Surface the managed rates too: an organisation that pins `modelPricing`
561
+ // should be able to see that PAN found the block, not infer it from totals.
562
+ const result = { ...checkRatesStaleness(), managed_model_pricing: Object.keys(loadManagedModelPricing() || {}) };
464
563
  const human = result.stale
465
564
  ? `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
565
  : `Rate table verified ${result.rates_verified_at} (${result.age_days} days ago) — OK`;
@@ -476,6 +575,10 @@ module.exports = {
476
575
  renderTable,
477
576
  renderChart,
478
577
  resolveRate,
578
+ ratesFromModelPricing,
579
+ managedSettingsDir,
580
+ loadManagedModelPricing,
581
+ effectiveRates,
479
582
  checkRatesStaleness,
480
583
  cmdCostReport,
481
584
  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 };
@@ -40,8 +40,10 @@ const {
40
40
  STATE_FILE,
41
41
  } = require('./constants.cjs');
42
42
  const { planningPath, planningRel } = require('./utils.cjs');
43
+ const { detectForeignPlanningTree } = require('./foreign-planning.cjs');
43
44
  const { listMemoryAgents, readMemory, compactMemory } = require('./memory.cjs');
44
45
  const { readRecords, isSuspectRecord, METRICS_DIR, TOKENS_FILE } = require('./cost.cjs');
46
+ const { assessCacheTtl } = require('./context-budget.cjs');
45
47
  const { planningRootRel, planningRoots, withPlanningRoot, describePlanningRoot, TRACKS_DIR } = require('./planning-root.cjs');
46
48
 
47
49
  /** Runtime config dirs a PAN install can live in, relative to project root. */
@@ -456,6 +458,17 @@ function checkCachedContext(cwd) {
456
458
  `~${fmtTokens(tokens)} tokens re-read on every agent call (warn ${fmtTokens(CACHE_FILE_WARN_TOKENS)})${suffix}`,
457
459
  fix));
458
460
  }
461
+
462
+ // Lifetime signal (ADR-0046 D5): the ledger shows cache WRITES that followed
463
+ // an idle gap of five to sixty minutes — misses a one-hour subagent cache
464
+ // lifetime would have turned into hits. Informational and never fixable: the
465
+ // remedy is a Claude Code setting the user weighs against the 2× write price.
466
+ try {
467
+ const ttl = assessCacheTtl(readRecords(cwd).filter(r => !isSuspectRecord(r)));
468
+ if (ttl.recommend) {
469
+ findings.push(mkFinding('cache-context', 'info', planningRel(path.join(METRICS_DIR, TOKENS_FILE)), ttl.advice, null));
470
+ }
471
+ } catch { /* no ledger, or unreadable — nothing to say */ }
459
472
  return { findings };
460
473
  }
461
474
 
@@ -517,6 +530,18 @@ function checkPlanningFragment(cwd) {
517
530
  */
518
531
  function scanOneRoot(cwd, root, opts) {
519
532
  return withPlanningRoot(root.rel, () => {
533
+ // A .planning/ written by ANOTHER tool (gsd-core shares the directory name and
534
+ // PAN's pre-v2.2 uppercase file names) must never be "repaired": the legacy
535
+ // rename would rename its state files. One warn finding, nothing fixable, and
536
+ // none of the per-tree checks run on it. Reality check R15.
537
+ const foreign = detectForeignPlanningTree(planningPath(cwd));
538
+ if (foreign) {
539
+ const f = mkFinding('foreign-planning-tree', 'warn', planningRel(),
540
+ `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)`,
541
+ null);
542
+ f.track = root.name;
543
+ return { findings: [f], planning_exists: true };
544
+ }
520
545
  const fragment = checkPlanningFragment(cwd);
521
546
  const findings = [
522
547
  ...fragment.findings,
@@ -594,6 +619,12 @@ function applyFix(cwd, finding) {
594
619
  try {
595
620
  switch (fix.action) {
596
621
  case 'rename-lowercase': {
622
+ // Defence in depth for R15: the scan never emits this fix for a foreign tree,
623
+ // but a stale findings list or a hand-built one must not rename another
624
+ // tool's files either.
625
+ if (detectForeignPlanningTree(path.dirname(abs))) {
626
+ return { applied: false, detail: 'refused: this planning tree belongs to another tool (see the foreign-planning-tree finding)' };
627
+ }
597
628
  // Two-step rename: Windows treats case-only renames inconsistently
598
629
  // across fs layers, so hop through a temp name.
599
630
  const dir = path.dirname(abs);
@@ -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