pan-wizard 3.26.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.
Files changed (54) 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/hygiene.md +14 -8
  7. package/commands/pan/milestone-audit.md +10 -4
  8. package/commands/pan/preview.md +2 -2
  9. package/hooks/dist/pan-cost-logger.js +69 -5
  10. package/hooks/dist/pan-stop-guard.js +32 -1
  11. package/hooks/dist/pan-trace-logger.js +35 -2
  12. package/package.json +5 -2
  13. package/pan-wizard-core/bin/lib/bridge.cjs +0 -1
  14. package/pan-wizard-core/bin/lib/bus.cjs +0 -1
  15. package/pan-wizard-core/bin/lib/campaign.cjs +3 -2
  16. package/pan-wizard-core/bin/lib/commands-learnings.cjs +8 -8
  17. package/pan-wizard-core/bin/lib/commands.cjs +15 -14
  18. package/pan-wizard-core/bin/lib/config.cjs +5 -5
  19. package/pan-wizard-core/bin/lib/constants.cjs +49 -0
  20. package/pan-wizard-core/bin/lib/context-budget.cjs +98 -0
  21. package/pan-wizard-core/bin/lib/core.cjs +190 -26
  22. package/pan-wizard-core/bin/lib/cost.cjs +113 -11
  23. package/pan-wizard-core/bin/lib/distill.cjs +3 -3
  24. package/pan-wizard-core/bin/lib/focus.cjs +16 -16
  25. package/pan-wizard-core/bin/lib/foreign-planning.cjs +56 -0
  26. package/pan-wizard-core/bin/lib/hud.cjs +1 -1
  27. package/pan-wizard-core/bin/lib/hygiene.cjs +428 -37
  28. package/pan-wizard-core/bin/lib/init.cjs +98 -13
  29. package/pan-wizard-core/bin/lib/knowledge.cjs +0 -1
  30. package/pan-wizard-core/bin/lib/memory.cjs +1 -1
  31. package/pan-wizard-core/bin/lib/milestone.cjs +3 -3
  32. package/pan-wizard-core/bin/lib/optimize.cjs +3 -3
  33. package/pan-wizard-core/bin/lib/phase.cjs +4 -4
  34. package/pan-wizard-core/bin/lib/planning-root.cjs +327 -0
  35. package/pan-wizard-core/bin/lib/preview.cjs +0 -1
  36. package/pan-wizard-core/bin/lib/review-deep.cjs +0 -1
  37. package/pan-wizard-core/bin/lib/roadmap.cjs +1 -1
  38. package/pan-wizard-core/bin/lib/state-compact.cjs +339 -0
  39. package/pan-wizard-core/bin/lib/state.cjs +0 -1
  40. package/pan-wizard-core/bin/lib/template.cjs +1 -1
  41. package/pan-wizard-core/bin/lib/utils.cjs +39 -11
  42. package/pan-wizard-core/bin/lib/verify.cjs +26 -5
  43. package/pan-wizard-core/bin/lib/whatif.cjs +0 -1
  44. package/pan-wizard-core/bin/pan-tools.cjs +58 -4
  45. package/pan-wizard-core/mcp/server.cjs +92 -8
  46. package/pan-wizard-core/mcp/tool-registry.cjs +50 -3
  47. package/pan-wizard-core/references/model-profiles.md +2 -2
  48. package/pan-wizard-core/workflows/health.md +1 -0
  49. package/pan-wizard-core/workflows/milestone-audit.md +35 -6
  50. package/pan-zcode/README.md +1 -1
  51. package/scripts/build-agent-plugin.js +220 -0
  52. package/scripts/build-plugin.js +48 -3
  53. package/scripts/generate-skills-docs.py +1 -1
  54. package/scripts/release-check.js +58 -12
@@ -7,7 +7,6 @@ const path = require('path');
7
7
  const os = require('os');
8
8
  const { execFileSync } = require('child_process');
9
9
  const {
10
- PLANNING_DIR,
11
10
  PHASES_DIR,
12
11
  MILESTONES_DIR,
13
12
  ROADMAP_FILE,
@@ -22,8 +21,8 @@ const {
22
21
  isVerificationFile,
23
22
  getPlanId,
24
23
  getSummaryId,
25
- MILESTONE_VERSION_RE,
26
24
  } = require('./constants.cjs');
25
+ const { planningPath, planningRel } = require('./utils.cjs');
27
26
 
28
27
  // ─── Multi-Model Routing ─────────────────────────────────────────────────────
29
28
 
@@ -331,7 +330,7 @@ function safeReadFile(filePath) {
331
330
  * plan_checker, verifier, parallelization, brave_search
332
331
  */
333
332
  function loadConfig(cwd) {
334
- const configPath = path.join(cwd, PLANNING_DIR, 'config.json');
333
+ const configPath = planningPath(cwd, 'config.json');
335
334
  const defaults = {
336
335
  model_profile: 'balanced',
337
336
  commit_docs: true,
@@ -391,6 +390,11 @@ function loadConfig(cwd) {
391
390
  // Cost dashboard config: `cost.rates` per-model overrides (surfaced so the
392
391
  // documented override actually reaches cost.cjs — it was dropped before).
393
392
  cost: parsed.cost || {},
393
+ // Prompt-cache config: `cache.extra_files` lets a project add its own
394
+ // stable documents to the cached context block. Needed because the
395
+ // built-in list is the phase-model spine, so a focus-model project had
396
+ // an empty block and therefore no prompt caching at all.
397
+ cache: parsed.cache || {},
394
398
  // ADR-0031: project build/verification commands. null = not configured
395
399
  // (focus-auto --clean-seal then asks or skips rather than guessing).
396
400
  build: parsed.build || null,
@@ -408,6 +412,7 @@ function loadConfig(cwd) {
408
412
  effort_overrides: {},
409
413
  routing: { strategy: 'static', provider: 'auto' },
410
414
  cost: {},
415
+ cache: {},
411
416
  build: null,
412
417
  verification: null,
413
418
  concurrency: { serial_build: false },
@@ -595,18 +600,18 @@ function searchPhaseInDir(baseDir, relBase, normalized) {
595
600
  function findPhaseInternal(cwd, phase) {
596
601
  if (!phase) return null;
597
602
 
598
- const phasesDir = path.join(cwd, PLANNING_DIR, PHASES_DIR);
603
+ const phasesDir = planningPath(cwd, PHASES_DIR);
599
604
  const normalized = normalizePhaseName(phase);
600
605
 
601
606
  // Two-phase search strategy:
602
607
  // 1. Search the active phases directory (.planning/phases/) first.
603
608
  // 2. If not found, search archived milestone directories (.planning/milestones/v*-phases/)
604
609
  // in reverse order (newest archive first) so the most recent match wins.
605
- const current = searchPhaseInDir(phasesDir, path.join(PLANNING_DIR, PHASES_DIR), normalized);
610
+ const current = searchPhaseInDir(phasesDir, planningRel(PHASES_DIR), normalized);
606
611
  if (current) return current;
607
612
 
608
613
  // Search archived milestone phases (newest first)
609
- const milestonesDir = path.join(cwd, PLANNING_DIR, MILESTONES_DIR);
614
+ const milestonesDir = planningPath(cwd, MILESTONES_DIR);
610
615
  try {
611
616
  const milestoneEntries = fs.readdirSync(milestonesDir, { withFileTypes: true });
612
617
  const archiveDirs = milestoneEntries
@@ -620,7 +625,7 @@ function findPhaseInternal(cwd, phase) {
620
625
  if (!vm) continue;
621
626
  const version = vm[1];
622
627
  const archivePath = path.join(milestonesDir, archiveName);
623
- const relBase = path.join(PLANNING_DIR, MILESTONES_DIR, archiveName);
628
+ const relBase = planningRel(MILESTONES_DIR, archiveName);
624
629
  const result = searchPhaseInDir(archivePath, relBase, normalized);
625
630
  if (result) {
626
631
  result.archived = version;
@@ -633,7 +638,7 @@ function findPhaseInternal(cwd, phase) {
633
638
  }
634
639
 
635
640
  function getArchivedPhaseDirs(cwd) {
636
- const milestonesDir = path.join(cwd, PLANNING_DIR, MILESTONES_DIR);
641
+ const milestonesDir = planningPath(cwd, MILESTONES_DIR);
637
642
  const results = [];
638
643
 
639
644
  try {
@@ -657,7 +662,7 @@ function getArchivedPhaseDirs(cwd) {
657
662
  results.push({
658
663
  name: dir,
659
664
  milestone: version,
660
- basePath: path.join(PLANNING_DIR, MILESTONES_DIR, archiveName),
665
+ basePath: planningRel(MILESTONES_DIR, archiveName),
661
666
  fullPath: path.join(archivePath, dir),
662
667
  });
663
668
  }
@@ -677,7 +682,7 @@ function getArchivedPhaseDirs(cwd) {
677
682
  */
678
683
  function getRoadmapPhaseInternal(cwd, phaseNum) {
679
684
  if (!phaseNum) return null;
680
- const roadmapPath = path.join(cwd, PLANNING_DIR, ROADMAP_FILE);
685
+ const roadmapPath = planningPath(cwd, ROADMAP_FILE);
681
686
 
682
687
  try {
683
688
  const content = fs.readFileSync(roadmapPath, 'utf-8');
@@ -927,22 +932,150 @@ function generateSlugInternal(text) {
927
932
  }
928
933
 
929
934
  /**
930
- * Extract current milestone version and name from roadmap.md.
935
+ * Match a milestone HEADING — a markdown heading line that carries a version.
936
+ *
937
+ * Anchored to line start and allowing `#{1,6}`, because the previous pattern
938
+ * (`/## .*v\d+\.\d+.../`, unanchored) matched inside `### ` headings AND inside
939
+ * body prose, which is how a version could be read out of a sentence.
940
+ */
941
+ const MILESTONE_HEADING_RE = /^[ \t]{0,3}#{1,6}[ \t]+(.*\bv\d+(?:\.\d+)+\b.*?)[ \t]*$/;
942
+
943
+ /** Collapsed shipped milestones live in `<summary>` lines, not headings. */
944
+ const MILESTONE_SUMMARY_RE = /^[ \t]*<summary>(.*\bv\d+(?:\.\d+)+\b.*?)<\/summary>[ \t]*$/;
945
+
946
+ /** Status markers PAN's own roadmap template emits, plus their prose forms. */
947
+ const MILESTONE_STATUS_MARKERS = [
948
+ { status: 'shipped', re: /✅|\bshipped\b|\bcomplete[d]?\b|\bdone\b/i },
949
+ // `current` is matched as a word anywhere in the heading, not as the whole
950
+ // parenthetical: real roadmaps write "(current, phases 1–10)", and requiring
951
+ // an exact "(current)" silently missed the marker and fell through to
952
+ // positional guessing — the failure mode this resolver exists to remove.
953
+ { status: 'current', re: /🚧|\bcurrent\b|\bin[-\s]progress\b|\bactive\b/i },
954
+ { status: 'planned', re: /📋|\bplanned\b|\bupcoming\b|\bfuture\b/i },
955
+ ];
956
+
957
+ /**
958
+ * Parse every milestone heading in a roadmap into {version, name, status}.
959
+ *
960
+ * Version and name are taken from THE SAME heading — the whole point. Reading
961
+ * them with two independent whole-document regexes let a version from one
962
+ * milestone pair with a name from another and produce a milestone that does not
963
+ * exist, with nothing in the output to suggest anything had gone wrong.
964
+ *
965
+ * @param {string} roadmap - full roadmap.md text
966
+ * @returns {Array<{version: string, name: string, status: string, line: number, heading: string}>}
967
+ */
968
+ function parseMilestoneHeadings(roadmap) {
969
+ const out = [];
970
+ const lines = String(roadmap || '').split(/\r?\n/);
971
+
972
+ lines.forEach((line, i) => {
973
+ const m = line.match(MILESTONE_HEADING_RE) || line.match(MILESTONE_SUMMARY_RE);
974
+ if (!m) return;
975
+ const heading = m[1];
976
+
977
+ const versionMatch = heading.match(/\bv(\d+(?:\.\d+)+)\b/);
978
+ if (!versionMatch) return;
979
+
980
+ let status = 'unknown';
981
+ for (const marker of MILESTONE_STATUS_MARKERS) {
982
+ if (marker.re.test(heading)) { status = marker.status; break; }
983
+ }
984
+ // A <summary> heading is a collapsed, already-shipped milestone even when
985
+ // it carries no explicit marker.
986
+ if (status === 'unknown' && MILESTONE_SUMMARY_RE.test(line)) status = 'shipped';
987
+
988
+ out.push({
989
+ version: `v${versionMatch[1]}`,
990
+ name: extractMilestoneName(heading, versionMatch[0]),
991
+ status,
992
+ line: i + 1,
993
+ heading: heading.trim(),
994
+ });
995
+ });
996
+
997
+ return out;
998
+ }
999
+
1000
+ /**
1001
+ * Reduce a milestone heading to its bare name.
1002
+ * "### 🚧 Milestone v4.1 — Full Platform (phases 1–12)" → "Full Platform"
1003
+ *
1004
+ * @param {string} heading - heading text with the leading #'s already stripped
1005
+ * @param {string} versionToken - the matched version, e.g. "v4.1"
1006
+ * @returns {string} the name, or '' when the heading carries none
1007
+ */
1008
+ function extractMilestoneName(heading, versionToken) {
1009
+ let name = heading;
1010
+ name = name.split(versionToken).slice(1).join(versionToken); // everything after the version
1011
+ name = name.replace(/\([^)]*\)/g, ' '); // "(current)", "(phases 1–12)"
1012
+ name = name.replace(/<\/?[^>]+>/g, ' '); // stray inline tags
1013
+ name = name.replace(/[*_`]+/g, ''); // markdown emphasis
1014
+ name = name.replace(/\p{Extended_Pictographic}️?/gu, ' '); // ✅ 🚧 📋 status glyphs
1015
+ name = name.replace(/^[\s:—–\-–]+/, '').replace(/[\s:—–\-–]+$/, '');
1016
+ // "Shipped: 2025-11-25" style trailers add nothing to the name.
1017
+ name = name.replace(/\b(shipped|completed?|done)\b[:\s]*\d{4}-\d{2}-\d{2}\s*$/i, '').trim();
1018
+ return name.trim();
1019
+ }
1020
+
1021
+ /**
1022
+ * Pick the CURRENT milestone from parsed headings.
1023
+ *
1024
+ * Order of preference:
1025
+ * 1. a heading explicitly marked in-progress (🚧 / "(current)" / "in progress")
1026
+ * 2. the first heading that is not shipped — work not yet done
1027
+ * 3. the last shipped heading — everything is done, so the newest is current
1028
+ *
1029
+ * @param {Array} headings - from parseMilestoneHeadings()
1030
+ * @returns {{milestone: Object|null, ambiguous: boolean, basis: string}}
1031
+ */
1032
+ function selectCurrentMilestone(headings) {
1033
+ if (!headings.length) return { milestone: null, ambiguous: false, basis: 'none' };
1034
+
1035
+ const current = headings.filter(h => h.status === 'current');
1036
+ if (current.length) {
1037
+ // More than one milestone marked current is a planning-state error, not
1038
+ // something to resolve silently — report it alongside the pick.
1039
+ return { milestone: current[0], ambiguous: current.length > 1, basis: 'marked-current' };
1040
+ }
1041
+
1042
+ const unshipped = headings.find(h => h.status !== 'shipped');
1043
+ if (unshipped) return { milestone: unshipped, ambiguous: false, basis: 'first-unshipped' };
1044
+
1045
+ return { milestone: headings[headings.length - 1], ambiguous: false, basis: 'last-shipped' };
1046
+ }
1047
+
1048
+ /**
1049
+ * Extract the current milestone's version and name from roadmap.md.
1050
+ *
1051
+ * Both values come from a single heading — see parseMilestoneHeadings() for why
1052
+ * that constraint is the whole fix.
1053
+ *
931
1054
  * @param {string} cwd - Project root directory
932
- * @returns {{version: string, name: string}} Milestone info (defaults: v1.0, "milestone")
1055
+ * @returns {{version: string, name: string, status: string, basis: string, ambiguous: boolean, candidates: number}}
1056
+ * Milestone info (defaults: v1.0, "milestone")
933
1057
  */
934
1058
  function getMilestoneInfo(cwd) {
1059
+ const fallback = { version: 'v1.0', name: 'milestone', status: 'unknown', basis: 'default', ambiguous: false, candidates: 0 };
1060
+ let roadmap;
935
1061
  try {
936
- const roadmap = fs.readFileSync(path.join(cwd, PLANNING_DIR, ROADMAP_FILE), 'utf-8');
937
- const versionMatch = roadmap.match(MILESTONE_VERSION_RE);
938
- const nameMatch = roadmap.match(/## .*v\d+\.\d+[:\s]+([^\n(]+)/);
939
- return {
940
- version: versionMatch ? versionMatch[0] : 'v1.0',
941
- name: nameMatch ? nameMatch[1].trim() : 'milestone',
942
- };
1062
+ roadmap = fs.readFileSync(planningPath(cwd, ROADMAP_FILE), 'utf-8');
943
1063
  } catch {
944
- return { version: 'v1.0', name: 'milestone' };
1064
+ return fallback;
945
1065
  }
1066
+
1067
+ const headings = parseMilestoneHeadings(roadmap);
1068
+ const { milestone, ambiguous, basis } = selectCurrentMilestone(headings);
1069
+ if (!milestone) return { ...fallback, basis: 'no-milestone-heading' };
1070
+
1071
+ return {
1072
+ version: milestone.version,
1073
+ name: milestone.name || 'milestone',
1074
+ status: milestone.status,
1075
+ basis,
1076
+ ambiguous,
1077
+ candidates: headings.length,
1078
+ };
946
1079
  }
947
1080
 
948
1081
  /**
@@ -952,7 +1085,7 @@ function getMilestoneInfo(cwd) {
952
1085
  * @returns {{ count: number, todos: Array<{file: string, created: string, title: string, area: string, path: string}> }}
953
1086
  */
954
1087
  function scanPendingTodos(cwd, area) {
955
- const pendingDir = path.join(cwd, PLANNING_DIR, 'todos', 'pending');
1088
+ const pendingDir = planningPath(cwd, 'todos', 'pending');
956
1089
  let count = 0;
957
1090
  const todos = [];
958
1091
 
@@ -974,7 +1107,7 @@ function scanPendingTodos(cwd, area) {
974
1107
  created: createdMatch ? createdMatch[1].trim() : 'unknown',
975
1108
  title: titleMatch ? titleMatch[1].trim() : 'Untitled',
976
1109
  area: todoArea,
977
- path: path.join(PLANNING_DIR, 'todos', 'pending', file),
1110
+ path: planningRel('todos', 'pending', file),
978
1111
  });
979
1112
  } catch { /* skip unreadable file */ }
980
1113
  }
@@ -1004,17 +1137,44 @@ function scanPendingTodos(cwd, area) {
1004
1137
  * @returns {{blocks: Array<{path: string, content: string, cache: true}>, total_bytes: number, sha: string}}
1005
1138
  */
1006
1139
  function buildCachedContext(cwd) {
1007
- const { PLANNING_DIR, CACHEABLE_CONTEXT_FILES } = require('./constants.cjs');
1140
+ const { CACHEABLE_CONTEXT_FILES } = require('./constants.cjs');
1008
1141
  const crypto = require('crypto');
1009
1142
  const blocks = [];
1010
1143
  let totalBytes = 0;
1011
1144
  const hasher = crypto.createHash('sha256');
1012
1145
 
1013
- for (const file of CACHEABLE_CONTEXT_FILES) {
1014
- const abs = path.join(cwd, PLANNING_DIR, file);
1146
+ // The built-in list is the PHASE-model spine. A focus-model project has none
1147
+ // of those files, so its cached block came out empty and it silently received
1148
+ // no prompt caching at all. `cache.extra_files` lets such a project name its
1149
+ // own stable documents rather than PAN inventing a convention it doesn't
1150
+ // otherwise define.
1151
+ //
1152
+ // Entries are planning-root-relative, must stay inside it, and are appended
1153
+ // after the built-ins so the prefix stays byte-stable for projects that set
1154
+ // nothing — changing the prefix would invalidate every existing cache key.
1155
+ const extra = [];
1156
+ try {
1157
+ const configured = loadConfig(cwd)?.cache?.extra_files;
1158
+ if (Array.isArray(configured)) {
1159
+ for (const entry of configured) {
1160
+ if (typeof entry !== 'string' || !entry.trim()) continue;
1161
+ const rel = entry.trim().replace(/\\/g, '/');
1162
+ // Inline literal guard: a cache entry must not escape the planning root
1163
+ // or reach an absolute path. Checked here rather than via a helper
1164
+ // because static analysis does not follow guards across functions.
1165
+ if (rel.startsWith('/') || rel.startsWith('\\') || /^[A-Za-z]:/.test(rel)) continue;
1166
+ if (rel.split('/').includes('..')) continue;
1167
+ if (CACHEABLE_CONTEXT_FILES.includes(rel) || extra.includes(rel)) continue;
1168
+ extra.push(rel);
1169
+ }
1170
+ }
1171
+ } catch { /* unreadable config — built-ins only */ }
1172
+
1173
+ for (const file of [...CACHEABLE_CONTEXT_FILES, ...extra]) {
1174
+ const abs = planningPath(cwd, file);
1015
1175
  try {
1016
1176
  const content = fs.readFileSync(abs, 'utf-8');
1017
- blocks.push({ path: toPosix(path.join(PLANNING_DIR, file)), content, cache: true });
1177
+ blocks.push({ path: planningRel(file), content, cache: true });
1018
1178
  totalBytes += Buffer.byteLength(content, 'utf-8');
1019
1179
  hasher.update(file + '\0' + content + '\0');
1020
1180
  } catch {
@@ -1091,6 +1251,10 @@ module.exports = {
1091
1251
  pathExistsInternal,
1092
1252
  generateSlugInternal,
1093
1253
  getMilestoneInfo,
1254
+ parseMilestoneHeadings,
1255
+ selectCurrentMilestone,
1256
+ extractMilestoneName,
1257
+ MILESTONE_HEADING_RE,
1094
1258
  toPosix,
1095
1259
  buildCachedContext,
1096
1260
  scanPendingTodos,
@@ -37,7 +37,6 @@
37
37
  const fs = require('fs');
38
38
  const path = require('path');
39
39
  const { output, error, safeReadFile, loadConfig } = require('./core.cjs');
40
- const { PLANNING_DIR } = require('./constants.cjs');
41
40
  const { planningPath } = require('./utils.cjs');
42
41
 
43
42
  const METRICS_DIR = 'metrics';
@@ -48,19 +47,39 @@ const TOKENS_FILE = 'tokens.jsonl';
48
47
  * Override per-model in config.json → cost.rates.
49
48
  */
50
49
  const DEFAULT_RATES = {
51
- // 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
52
51
  // $5/$25 (the old $15/$75 Opus pricing ended with the 4.5 generation). Cache
53
- // 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 },
54
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 },
55
66
  'claude-opus-5': { input: 5.0, output: 25.0, cache_read: 0.5, cache_write: 6.25 },
56
67
  'claude-opus-4-8': { input: 5.0, output: 25.0, cache_read: 0.5, cache_write: 6.25 },
57
68
  'claude-opus-4-7': { input: 5.0, output: 25.0, cache_read: 0.5, cache_write: 6.25 },
58
69
  'claude-opus-4-6': { input: 5.0, output: 25.0, cache_read: 0.5, cache_write: 6.25 },
59
- // Sonnet 5 standard is $3/$15; a launch promo runs $2/$10 through 2026-08-31.
60
- // We track the stable post-promo rate (the table is indicative; the staleness
61
- // checker flags it for re-verification).
62
- '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 },
63
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 },
64
83
  'claude-haiku-4-5': { input: 1.0, output: 5.0, cache_read: 0.1, cache_write: 1.25 },
65
84
 
66
85
  // OpenAI — verified against published pricing 2026-08. Prompt caching is a 90%
@@ -107,6 +126,83 @@ function familyPrefixRate(rates, model) {
107
126
  return families.length > 0 ? rates[families[0]] : null;
108
127
  }
109
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
+
110
206
  function resolveRate(model, tier, configRates) {
111
207
  // Config overrides win over the built-in table — including for versioned ids.
112
208
  // Without the family-prefix pass here, a cost.rates override keyed on a family
@@ -188,7 +284,7 @@ function appendRecord(cwd, rec) {
188
284
  // time, so the two producers priced identical tokens differently.
189
285
  normalized.cost_usd = typeof rec.cost_usd === 'number'
190
286
  ? rec.cost_usd
191
- : computeCost(normalized, loadConfig(cwd)?.cost?.rates);
287
+ : computeCost(normalized, effectiveRates(loadConfig(cwd)));
192
288
 
193
289
  try {
194
290
  fs.mkdirSync(metricsDir(cwd), { recursive: true });
@@ -257,7 +353,7 @@ function aggregate(cwd, opts) {
257
353
  const since = opts?.since ? new Date(opts.since).getTime() : null;
258
354
  const until = opts?.until ? new Date(opts.until).getTime() : null;
259
355
  const config = loadConfig(cwd);
260
- const configRates = config?.cost?.rates;
356
+ const configRates = effectiveRates(config);
261
357
 
262
358
  const filtered = records.filter(r => {
263
359
  if (!r.ts) return true;
@@ -443,7 +539,7 @@ function cmdCostClear(cwd, raw) {
443
539
  // Bump this whenever the table is re-verified; `models check` flags the table
444
540
  // once it is older than RATES_STALE_AFTER_DAYS (provider prices move faster
445
541
  // than PAN releases do).
446
- const RATES_VERIFIED_AT = '2026-08-03';
542
+ const RATES_VERIFIED_AT = '2026-09-10';
447
543
  const RATES_STALE_AFTER_DAYS = 180;
448
544
  const RATE_TIERS = ['reasoning', 'mid', 'fast'];
449
545
 
@@ -461,7 +557,9 @@ function checkRatesStaleness(now = new Date()) {
461
557
  }
462
558
 
463
559
  function cmdModelsCheck(raw) {
464
- 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() || {}) };
465
563
  const human = result.stale
466
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`
467
565
  : `Rate table verified ${result.rates_verified_at} (${result.age_days} days ago) — OK`;
@@ -477,6 +575,10 @@ module.exports = {
477
575
  renderTable,
478
576
  renderChart,
479
577
  resolveRate,
578
+ ratesFromModelPricing,
579
+ managedSettingsDir,
580
+ loadManagedModelPricing,
581
+ effectiveRates,
480
582
  checkRatesStaleness,
481
583
  cmdCostReport,
482
584
  cmdCostAppend,
@@ -3,9 +3,9 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const { output, error, safeReadFile } = require('./core.cjs');
6
+ const { planningRel } = require('./utils.cjs');
6
7
 
7
- const PLANNING_DIR = '.planning';
8
- const MEMORY_DIR = path.join(PLANNING_DIR, 'memory');
8
+ const MEMORY_DIR = planningRel('memory');
9
9
  const PATTERNS_FILE = 'distill-patterns.md';
10
10
 
11
11
  const SAFETY_TIERS = { SAFE: 'safe', REVIEW: 'review_required', RISKY: 'risky' };
@@ -307,7 +307,7 @@ function loadFiles(filePaths, cwd) {
307
307
  const out = {};
308
308
  for (const f of filePaths) {
309
309
  const content = safeReadFile(f);
310
- if (content) out[path.relative(cwd, f).replace(/\\/g, '/')] = content;
310
+ if (content) out[path.relative(cwd, f)] = content;
311
311
  }
312
312
  return out;
313
313
  }