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/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/preview.md +2 -2
- package/package.json +5 -2
- package/pan-wizard-core/bin/lib/constants.cjs +22 -0
- package/pan-wizard-core/bin/lib/context-budget.cjs +70 -0
- package/pan-wizard-core/bin/lib/cost.cjs +113 -10
- package/pan-wizard-core/bin/lib/foreign-planning.cjs +56 -0
- package/pan-wizard-core/bin/lib/hygiene.cjs +31 -0
- package/pan-wizard-core/bin/lib/init.cjs +8 -0
- package/pan-wizard-core/bin/lib/verify.cjs +22 -2
- 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-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
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
|
-
//
|
|
569
|
-
//
|
|
570
|
-
//
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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
|
-
|
|
674
|
-
|
|
675
|
-
content =
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
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
|
-
|
|
732
|
-
|
|
733
|
-
const
|
|
734
|
-
if (
|
|
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(
|
|
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
|
-
|
|
2234
|
-
content =
|
|
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) {
|
package/commands/pan/army.md
CHANGED
|
@@ -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 `
|
|
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
|
|
package/commands/pan/preview.md
CHANGED
|
@@ -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/<
|
|
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/<
|
|
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.
|
|
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-
|
|
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
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
'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 },
|
|
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)
|
|
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
|
|
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-
|
|
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
|
-
|
|
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
|