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.
- package/README.md +48 -48
- package/agents/pan-previewer.md +1 -1
- package/bin/install-lib.cjs +580 -18
- package/bin/install.js +25 -44
- package/commands/pan/army.md +1 -1
- package/commands/pan/hygiene.md +14 -8
- package/commands/pan/milestone-audit.md +10 -4
- package/commands/pan/preview.md +2 -2
- package/hooks/dist/pan-cost-logger.js +69 -5
- package/hooks/dist/pan-stop-guard.js +32 -1
- package/hooks/dist/pan-trace-logger.js +35 -2
- package/package.json +5 -2
- package/pan-wizard-core/bin/lib/bridge.cjs +0 -1
- package/pan-wizard-core/bin/lib/bus.cjs +0 -1
- package/pan-wizard-core/bin/lib/campaign.cjs +3 -2
- package/pan-wizard-core/bin/lib/commands-learnings.cjs +8 -8
- package/pan-wizard-core/bin/lib/commands.cjs +15 -14
- package/pan-wizard-core/bin/lib/config.cjs +5 -5
- package/pan-wizard-core/bin/lib/constants.cjs +49 -0
- package/pan-wizard-core/bin/lib/context-budget.cjs +98 -0
- package/pan-wizard-core/bin/lib/core.cjs +190 -26
- package/pan-wizard-core/bin/lib/cost.cjs +113 -11
- package/pan-wizard-core/bin/lib/distill.cjs +3 -3
- package/pan-wizard-core/bin/lib/focus.cjs +16 -16
- package/pan-wizard-core/bin/lib/foreign-planning.cjs +56 -0
- package/pan-wizard-core/bin/lib/hud.cjs +1 -1
- package/pan-wizard-core/bin/lib/hygiene.cjs +428 -37
- package/pan-wizard-core/bin/lib/init.cjs +98 -13
- package/pan-wizard-core/bin/lib/knowledge.cjs +0 -1
- package/pan-wizard-core/bin/lib/memory.cjs +1 -1
- package/pan-wizard-core/bin/lib/milestone.cjs +3 -3
- package/pan-wizard-core/bin/lib/optimize.cjs +3 -3
- package/pan-wizard-core/bin/lib/phase.cjs +4 -4
- package/pan-wizard-core/bin/lib/planning-root.cjs +327 -0
- package/pan-wizard-core/bin/lib/preview.cjs +0 -1
- package/pan-wizard-core/bin/lib/review-deep.cjs +0 -1
- package/pan-wizard-core/bin/lib/roadmap.cjs +1 -1
- package/pan-wizard-core/bin/lib/state-compact.cjs +339 -0
- package/pan-wizard-core/bin/lib/state.cjs +0 -1
- package/pan-wizard-core/bin/lib/template.cjs +1 -1
- package/pan-wizard-core/bin/lib/utils.cjs +39 -11
- package/pan-wizard-core/bin/lib/verify.cjs +26 -5
- package/pan-wizard-core/bin/lib/whatif.cjs +0 -1
- package/pan-wizard-core/bin/pan-tools.cjs +58 -4
- package/pan-wizard-core/mcp/server.cjs +92 -8
- package/pan-wizard-core/mcp/tool-registry.cjs +50 -3
- package/pan-wizard-core/references/model-profiles.md +2 -2
- package/pan-wizard-core/workflows/health.md +1 -0
- package/pan-wizard-core/workflows/milestone-audit.md +35 -6
- package/pan-zcode/README.md +1 -1
- package/scripts/build-agent-plugin.js +220 -0
- package/scripts/build-plugin.js +48 -3
- package/scripts/generate-skills-docs.py +1 -1
- 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 =
|
|
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 =
|
|
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,
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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:
|
|
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 =
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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:
|
|
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 {
|
|
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
|
-
|
|
1014
|
-
|
|
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:
|
|
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-
|
|
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
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
'claude-
|
|
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)
|
|
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
|
|
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-
|
|
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
|
-
|
|
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
|
|
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)
|
|
310
|
+
if (content) out[path.relative(cwd, f)] = content;
|
|
311
311
|
}
|
|
312
312
|
return out;
|
|
313
313
|
}
|