pan-wizard 3.27.0 → 3.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/cost.md +14 -2
- package/commands/pan/preview.md +2 -2
- package/hooks/dist/pan-check-update.js +4 -0
- package/hooks/dist/pan-cost-logger.js +322 -43
- package/hooks/dist/pan-trace-logger.js +275 -32
- package/package.json +8 -2
- package/pan-wizard-core/bin/lib/commands.cjs +3 -1
- package/pan-wizard-core/bin/lib/constants.cjs +39 -0
- package/pan-wizard-core/bin/lib/context-budget.cjs +80 -0
- package/pan-wizard-core/bin/lib/cost-rebuild.cjs +511 -0
- package/pan-wizard-core/bin/lib/cost.cjs +174 -18
- package/pan-wizard-core/bin/lib/foreign-planning.cjs +56 -0
- package/pan-wizard-core/bin/lib/git.cjs +5 -1
- package/pan-wizard-core/bin/lib/hud.cjs +5 -3
- package/pan-wizard-core/bin/lib/hygiene.cjs +52 -24
- package/pan-wizard-core/bin/lib/init.cjs +8 -0
- package/pan-wizard-core/bin/lib/memory.cjs +14 -8
- package/pan-wizard-core/bin/lib/optimize.cjs +78 -2
- package/pan-wizard-core/bin/lib/utils.cjs +22 -0
- package/pan-wizard-core/bin/lib/verify.cjs +46 -12
- package/pan-wizard-core/bin/pan-tools.cjs +8 -1
- 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 +2 -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/coverage-gate.cjs +257 -0
- package/scripts/generate-skills-docs.py +1 -1
- package/scripts/install-git-hooks.js +5 -0
- package/scripts/mutation-probe.cjs +272 -0
- package/scripts/release-check.js +80 -13
- package/scripts/test-quality-lint.cjs +240 -0
- package/scripts/test-surface.cjs +335 -0
|
@@ -47,19 +47,39 @@ const TOKENS_FILE = 'tokens.jsonl';
|
|
|
47
47
|
* Override per-model in config.json → cost.rates.
|
|
48
48
|
*/
|
|
49
49
|
const DEFAULT_RATES = {
|
|
50
|
-
// Anthropic — verified against platform pricing 2026-
|
|
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 });
|
|
@@ -233,30 +330,74 @@ function readRecords(cwd) {
|
|
|
233
330
|
*/
|
|
234
331
|
/**
|
|
235
332
|
* A record is "suspect" when its token counts are physically implausible for a
|
|
236
|
-
* single subagent — the signature
|
|
237
|
-
* (billions of cache-read, cache-read dwarfing input, 100%
|
|
238
|
-
*
|
|
239
|
-
*
|
|
333
|
+
* single subagent — the oversum signature: a session's cumulative usage booked
|
|
334
|
+
* to one subagent row (billions of cache-read, cache-read dwarfing input, 100%
|
|
335
|
+
* cache-hit). Written by pre-v3.12.4 hooks, and by the parent-transcript slice
|
|
336
|
+
* path of every later hook up to v3.28 whenever a slice started at cursor 0 on a
|
|
337
|
+
* long-lived session. Such records are quarantined from aggregates so a
|
|
338
|
+
* poisoned ledger can't report millions of dollars.
|
|
339
|
+
* See docs/FIELD-REPORT-army-2026-06.md.
|
|
240
340
|
* @param {Object} r - a cost record
|
|
241
341
|
* @returns {boolean}
|
|
242
342
|
*/
|
|
343
|
+
// No single subagent runs for six hours — the longest native-workflow phase runs
|
|
344
|
+
// measured in the harness finish inside an hour — while a parent-transcript slice
|
|
345
|
+
// that spans a working day, or the idle night between two stops, does. Mirrored by
|
|
346
|
+
// the hooks' SLICE_MAX_DURATION_MS, which nulls the span on write. Calibrated on
|
|
347
|
+
// eleven field ledgers (2026-09): of the timed rows the old ratio rule flagged,
|
|
348
|
+
// 55 spanned under three hours and 12 spanned six to twenty-four — the latter all
|
|
349
|
+
// parent slices booked to a `general-purpose` or workflow subagent.
|
|
350
|
+
const SUSPECT_MAX_DURATION_MS = 6 * 60 * 60 * 1000;
|
|
351
|
+
|
|
243
352
|
function isSuspectRecord(r) {
|
|
244
353
|
if (!r || typeof r !== 'object') return false;
|
|
354
|
+
// Rows measured from a transcript that belongs to exactly one actor — the
|
|
355
|
+
// subagent's own file (v3.29 hooks, `cost rebuild`) or the main thread's
|
|
356
|
+
// session file (`cost rebuild`) — cannot carry another actor's usage, so the
|
|
357
|
+
// oversum signature does not apply to them: a 24-day main thread with
|
|
358
|
+
// billions of cached reads is simply a long session, measured exactly.
|
|
359
|
+
if (r.token_source === 'agent-transcript' || r.token_source === 'session-transcript') return false;
|
|
245
360
|
const cr = r.cache_read_tokens || 0;
|
|
246
361
|
const io = (r.input_tokens || 0) + (r.output_tokens || 0);
|
|
247
362
|
if (cr > 5e8) return true; // no scoped subagent re-reads >500M cached tokens
|
|
248
|
-
if (cr > 1e7 && cr > 100 * (io + 1)) return true; // cache-read dwarfs input+output
|
|
249
363
|
if ((r.output_tokens || 0) > 1e7) return true; // ~10M output = cumulative oversum
|
|
364
|
+
const dur = typeof r.duration_ms === 'number' ? r.duration_ms : null;
|
|
365
|
+
if (dur != null && dur > SUSPECT_MAX_DURATION_MS) return true; // a six-hour-plus "subagent" is a session's history
|
|
366
|
+
// Cache-read dwarfing input+output is the oversum signature ONLY for a row the
|
|
367
|
+
// hook could not time (pre-v3.20 rows, unreadable transcripts). A timed row
|
|
368
|
+
// with a plausible span is a real agent: under prompt caching every turn
|
|
369
|
+
// re-reads the cached context, so a hundred-turn agent legitimately reads
|
|
370
|
+
// 300× more cached tokens than it writes. Applied to timed rows, this rule
|
|
371
|
+
// had excluded fifty sub-hour agents (~3 billion real cache-read tokens)
|
|
372
|
+
// from eleven field ledgers (2026-09).
|
|
373
|
+
if (dur == null && cr > 1e7 && cr > 100 * (io + 1)) return true;
|
|
250
374
|
return false;
|
|
251
375
|
}
|
|
252
376
|
|
|
377
|
+
/**
|
|
378
|
+
* A record is "empty" when it carries no tokens on any axis and no model: a
|
|
379
|
+
* spawn the hook could not measure — a sibling stop that arrived before the
|
|
380
|
+
* shared parent transcript had grown (the pre-v3.29 slice path), or a payload
|
|
381
|
+
* with neither usage nor a readable transcript. It has no cost and no tokens;
|
|
382
|
+
* counting it as a call inflated call counts by up to 2x in the field (480 of
|
|
383
|
+
* 976 rows across eleven ledgers, 2026-09). A zero-token row that names a model
|
|
384
|
+
* is NOT empty — that is a measured run that happened to use nothing.
|
|
385
|
+
* @param {Object} r - a cost record
|
|
386
|
+
* @returns {boolean}
|
|
387
|
+
*/
|
|
388
|
+
function isEmptyRecord(r) {
|
|
389
|
+
if (!r || typeof r !== 'object') return false;
|
|
390
|
+
if (r.model) return false;
|
|
391
|
+
return !(r.input_tokens || r.output_tokens || r.cache_read_tokens || r.cache_write_tokens);
|
|
392
|
+
}
|
|
393
|
+
|
|
253
394
|
function aggregate(cwd, opts) {
|
|
254
395
|
const records = readRecords(cwd);
|
|
255
396
|
const malformedSkipped = _lastReadMalformed; // captured before any later read
|
|
256
397
|
const since = opts?.since ? new Date(opts.since).getTime() : null;
|
|
257
398
|
const until = opts?.until ? new Date(opts.until).getTime() : null;
|
|
258
399
|
const config = loadConfig(cwd);
|
|
259
|
-
const configRates = config
|
|
400
|
+
const configRates = effectiveRates(config);
|
|
260
401
|
|
|
261
402
|
const filtered = records.filter(r => {
|
|
262
403
|
if (!r.ts) return true;
|
|
@@ -275,6 +416,7 @@ function aggregate(cwd, opts) {
|
|
|
275
416
|
cost_usd: 0,
|
|
276
417
|
cost_unknown: 0,
|
|
277
418
|
suspect_excluded: 0,
|
|
419
|
+
empty_excluded: 0,
|
|
278
420
|
malformed_skipped: malformedSkipped,
|
|
279
421
|
};
|
|
280
422
|
|
|
@@ -296,9 +438,11 @@ function aggregate(cwd, opts) {
|
|
|
296
438
|
}
|
|
297
439
|
|
|
298
440
|
for (const r of filtered) {
|
|
299
|
-
// Quarantine physically-impossible records (
|
|
300
|
-
//
|
|
441
|
+
// Quarantine physically-impossible records (the transcript-oversum
|
|
442
|
+
// signature) so a poisoned ledger doesn't poison the totals / HUD / /pan:cost.
|
|
301
443
|
if (isSuspectRecord(r)) { totals.suspect_excluded += 1; continue; }
|
|
444
|
+
// Skip unmeasured spawns: no tokens, no model, nothing to price or count.
|
|
445
|
+
if (isEmptyRecord(r)) { totals.empty_excluded += 1; continue; }
|
|
302
446
|
totals.calls += 1;
|
|
303
447
|
totals.input_tokens += r.input_tokens || 0;
|
|
304
448
|
totals.output_tokens += r.output_tokens || 0;
|
|
@@ -355,7 +499,12 @@ function renderTable(agg) {
|
|
|
355
499
|
lines.push(window);
|
|
356
500
|
lines.push('');
|
|
357
501
|
lines.push('Totals');
|
|
358
|
-
|
|
502
|
+
const skipped = [
|
|
503
|
+
agg.totals.suspect_excluded > 0 ? `${agg.totals.suspect_excluded} suspect` : null,
|
|
504
|
+
agg.totals.empty_excluded > 0 ? `${agg.totals.empty_excluded} empty` : null,
|
|
505
|
+
agg.totals.malformed_skipped > 0 ? `${agg.totals.malformed_skipped} malformed` : null,
|
|
506
|
+
].filter(Boolean);
|
|
507
|
+
lines.push(` Calls : ${agg.totals.calls}${skipped.length ? ` (excluded: ${skipped.join(', ')})` : ''}`);
|
|
359
508
|
lines.push(` Input tokens : ${agg.totals.input_tokens.toLocaleString()}`);
|
|
360
509
|
lines.push(` Output tokens : ${agg.totals.output_tokens.toLocaleString()}`);
|
|
361
510
|
lines.push(` Cache read : ${agg.totals.cache_read_tokens.toLocaleString()}`);
|
|
@@ -442,7 +591,7 @@ function cmdCostClear(cwd, raw) {
|
|
|
442
591
|
// Bump this whenever the table is re-verified; `models check` flags the table
|
|
443
592
|
// once it is older than RATES_STALE_AFTER_DAYS (provider prices move faster
|
|
444
593
|
// than PAN releases do).
|
|
445
|
-
const RATES_VERIFIED_AT = '2026-
|
|
594
|
+
const RATES_VERIFIED_AT = '2026-09-10';
|
|
446
595
|
const RATES_STALE_AFTER_DAYS = 180;
|
|
447
596
|
const RATE_TIERS = ['reasoning', 'mid', 'fast'];
|
|
448
597
|
|
|
@@ -460,7 +609,9 @@ function checkRatesStaleness(now = new Date()) {
|
|
|
460
609
|
}
|
|
461
610
|
|
|
462
611
|
function cmdModelsCheck(raw) {
|
|
463
|
-
|
|
612
|
+
// Surface the managed rates too: an organisation that pins `modelPricing`
|
|
613
|
+
// should be able to see that PAN found the block, not infer it from totals.
|
|
614
|
+
const result = { ...checkRatesStaleness(), managed_model_pricing: Object.keys(loadManagedModelPricing() || {}) };
|
|
464
615
|
const human = result.stale
|
|
465
616
|
? `Rate table verified ${result.rates_verified_at} (${result.age_days} days ago) — STALE: re-verify provider pricing and bump RATES_VERIFIED_AT in cost.cjs`
|
|
466
617
|
: `Rate table verified ${result.rates_verified_at} (${result.age_days} days ago) — OK`;
|
|
@@ -473,9 +624,14 @@ module.exports = {
|
|
|
473
624
|
readRecords,
|
|
474
625
|
aggregate,
|
|
475
626
|
isSuspectRecord,
|
|
627
|
+
isEmptyRecord,
|
|
476
628
|
renderTable,
|
|
477
629
|
renderChart,
|
|
478
630
|
resolveRate,
|
|
631
|
+
ratesFromModelPricing,
|
|
632
|
+
managedSettingsDir,
|
|
633
|
+
loadManagedModelPricing,
|
|
634
|
+
effectiveRates,
|
|
479
635
|
checkRatesStaleness,
|
|
480
636
|
cmdCostReport,
|
|
481
637
|
cmdCostAppend,
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Foreign planning-tree detection (reality check RC17 / plan item R15, 2026-09-10).
|
|
4
|
+
*
|
|
5
|
+
* gsd-core (open-gsd/gsd-core, the continuation of Get Shit Done) writes a `.planning/`
|
|
6
|
+
* directory with the same core files PAN's pre-v2.2 layout used — STATE.md, ROADMAP.md,
|
|
7
|
+
* PROJECT.md, REQUIREMENTS.md, MILESTONES.md in uppercase. To PAN's hygiene those read
|
|
8
|
+
* as LEGACY PAN files, and `hygiene clean --apply` would rename another tool's state.
|
|
9
|
+
* `validate health` would call the tree broken; `init new-project` would scaffold PAN
|
|
10
|
+
* files into it. This module answers one question — does this tree belong to another
|
|
11
|
+
* tool? — from POSITIVE markers PAN never writes (FOREIGN_PLANNING_MARKERS in
|
|
12
|
+
* constants.cjs, sourced from gsd-core's docs/USER-GUIDE.md, read 2026-09-10).
|
|
13
|
+
*
|
|
14
|
+
* Rule: foreign when any marker FILE exists, or at least two marker DIRECTORIES exist,
|
|
15
|
+
* or config.json carries any of the tool's flat dotted keys (PAN nests `workflow: {}`;
|
|
16
|
+
* gsd-core writes `"workflow.discuss_mode"`). An unreadable config.json is not evidence.
|
|
17
|
+
* Returns { tool, key, evidence[] } or null. Never throws.
|
|
18
|
+
*/
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
const { FOREIGN_PLANNING_MARKERS } = require('./constants.cjs');
|
|
22
|
+
const { planningPath } = require('./utils.cjs');
|
|
23
|
+
|
|
24
|
+
function detectForeignPlanningTree(planningDir) {
|
|
25
|
+
let entries;
|
|
26
|
+
try { entries = fs.readdirSync(planningDir, { withFileTypes: true }); } catch { return null; }
|
|
27
|
+
const names = new Set(entries.map(e => e.name));
|
|
28
|
+
const dirs = new Set(entries.filter(e => e.isDirectory()).map(e => e.name));
|
|
29
|
+
let cfg = null;
|
|
30
|
+
if (names.has('config.json')) {
|
|
31
|
+
try {
|
|
32
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(planningDir, 'config.json'), 'utf8'));
|
|
33
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) cfg = parsed;
|
|
34
|
+
} catch { cfg = null; }
|
|
35
|
+
}
|
|
36
|
+
for (const [key, m] of Object.entries(FOREIGN_PLANNING_MARKERS)) {
|
|
37
|
+
const fileHits = m.files.filter(f => names.has(f));
|
|
38
|
+
const dirHits = m.dirs.filter(d => dirs.has(d));
|
|
39
|
+
const configHits = cfg ? m.configKeys.filter(k => Object.prototype.hasOwnProperty.call(cfg, k)) : [];
|
|
40
|
+
if (fileHits.length > 0 || dirHits.length >= 2 || configHits.length > 0) {
|
|
41
|
+
return {
|
|
42
|
+
tool: m.tool,
|
|
43
|
+
key,
|
|
44
|
+
evidence: [...fileHits, ...dirHits.map(d => d + '/'), ...configHits.map(k => 'config.json:' + k)],
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Same check, addressed by project cwd (honours --planning-dir / --track). */
|
|
52
|
+
function detectForeignPlanningTreeAt(cwd) {
|
|
53
|
+
return detectForeignPlanningTree(planningPath(cwd));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = { detectForeignPlanningTree, detectForeignPlanningTreeAt };
|
|
@@ -82,7 +82,11 @@ function cmdGitCommit(cwd, opts, raw) {
|
|
|
82
82
|
const commitArgs = amend ? ['commit', '--amend', '--no-edit'] : ['commit', '-m', finalMessage];
|
|
83
83
|
const r = execGit(cwd, commitArgs);
|
|
84
84
|
if (r.exitCode !== 0) {
|
|
85
|
-
|
|
85
|
+
// Git says "nothing to commit" for a clean tree and "nothing added to commit but
|
|
86
|
+
// untracked files present" when the only changes are untracked. Both mean no change
|
|
87
|
+
// was NEEDED; only the first was recognised, so the second was reported as a failed
|
|
88
|
+
// commit with "unknown git error" (measured 2026-09-17).
|
|
89
|
+
if ((r.stdout + r.stderr).includes('nothing to commit') || (r.stdout + r.stderr).includes('nothing added to commit')) {
|
|
86
90
|
// No error key, exit 0: nothing to commit means no change was NEEDED, not that
|
|
87
91
|
// a change failed. Pinned as a success in CLI-REFERENCE ("Error Shape").
|
|
88
92
|
output({ committed: false, reason: 'nothing_to_commit' }, raw, 'nothing to commit');
|
|
@@ -424,8 +424,10 @@ function fmtTokens(n) {
|
|
|
424
424
|
/**
|
|
425
425
|
* Assess whether a cost ledger is trustworthy enough to show dollar figures.
|
|
426
426
|
* Two failure modes are treated as "don't quote a number":
|
|
427
|
-
* - legacy: more records were quarantined as implausible than
|
|
428
|
-
*
|
|
427
|
+
* - legacy: more records were quarantined as implausible than were measured
|
|
428
|
+
* (the transcript-oversum signature: a session's cumulative usage booked to
|
|
429
|
+
* one subagent, written by hooks before v3.29) — quarantine advised.
|
|
430
|
+
* Unmeasured spawns (`empty_excluded`) count on neither side.
|
|
429
431
|
* - unresolved: every surviving record lacks a resolvable model→rate, so the
|
|
430
432
|
* computed spend is a misleading $0 even though real tokens were spent.
|
|
431
433
|
* Returns { ok:true } when figures are safe to display.
|
|
@@ -439,7 +441,7 @@ function ledgerReliability(totals) {
|
|
|
439
441
|
const total = suspect + calls;
|
|
440
442
|
return {
|
|
441
443
|
ok: false, kind: 'legacy',
|
|
442
|
-
message: `${suspect} of ${total} cost records are implausible (
|
|
444
|
+
message: `${suspect} of ${total} measured cost records are implausible (a session's usage booked to one subagent — rows written by hooks before v3.29). Rebuild the ledger from the transcripts with <b>pan-tools cost rebuild --apply</b> (dry-run first without the flag); if the transcripts are gone, quarantine it with <b>pan-tools hygiene clean --apply</b>. Rows captured by v3.29+ hooks are attributed per agent.`,
|
|
443
445
|
};
|
|
444
446
|
}
|
|
445
447
|
if (calls > 0 && unknown >= calls) {
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
* campaigns run: runtime installs fall behind the latest version, legacy
|
|
6
6
|
* uppercase planning filenames linger from pre-v2.2 layouts, atomic-write
|
|
7
7
|
* .tmp orphans survive crashes, per-agent memory logs grow past the cap,
|
|
8
|
-
* cost ledgers
|
|
8
|
+
* cost ledgers carry oversum rows (a session's cumulative usage booked to one
|
|
9
|
+
* subagent — pre-v3.12.4 hooks wrote nothing else, and the parent-transcript
|
|
10
|
+
* slice path kept producing them up to v3.28), telemetry
|
|
9
11
|
* trace sessions pile up unboundedly, and stray fragment `.planning/`
|
|
10
12
|
* directories appear where a mapping step once ran.
|
|
11
13
|
*
|
|
@@ -39,9 +41,11 @@ const {
|
|
|
39
41
|
CHARS_PER_TOKEN,
|
|
40
42
|
STATE_FILE,
|
|
41
43
|
} = require('./constants.cjs');
|
|
42
|
-
const { planningPath, planningRel } = require('./utils.cjs');
|
|
44
|
+
const { planningPath, planningRel, detectPlanningModel } = require('./utils.cjs');
|
|
45
|
+
const { detectForeignPlanningTree } = require('./foreign-planning.cjs');
|
|
43
46
|
const { listMemoryAgents, readMemory, compactMemory } = require('./memory.cjs');
|
|
44
47
|
const { readRecords, isSuspectRecord, METRICS_DIR, TOKENS_FILE } = require('./cost.cjs');
|
|
48
|
+
const { assessCacheTtl } = require('./context-budget.cjs');
|
|
45
49
|
const { planningRootRel, planningRoots, withPlanningRoot, describePlanningRoot, TRACKS_DIR } = require('./planning-root.cjs');
|
|
46
50
|
|
|
47
51
|
/** Runtime config dirs a PAN install can live in, relative to project root. */
|
|
@@ -251,7 +255,8 @@ function recordMass(r) {
|
|
|
251
255
|
}
|
|
252
256
|
|
|
253
257
|
/**
|
|
254
|
-
* H-5: cost ledger dominated by physically implausible
|
|
258
|
+
* H-5: cost ledger dominated by physically implausible records — the oversum
|
|
259
|
+
* signature, a session's cumulative usage booked to one subagent row.
|
|
255
260
|
*
|
|
256
261
|
* Gated on token MASS as well as record count. A count-only gate passes a ledger
|
|
257
262
|
* whose few bad rows carry most of the tokens — field case: 24% of rows suspect
|
|
@@ -284,7 +289,7 @@ function checkCostLedger(cwd) {
|
|
|
284
289
|
: 'token mass';
|
|
285
290
|
findings.push(mkFinding('poisoned-ledger', 'critical',
|
|
286
291
|
planningRel(METRICS_DIR, TOKENS_FILE),
|
|
287
|
-
`${suspect}/${records.length} records suspect (${Math.round(ratio * 100)}% of rows, ${Math.round(massRatio * 100)}% of token mass) —
|
|
292
|
+
`${suspect}/${records.length} records suspect (${Math.round(ratio * 100)}% of rows, ${Math.round(massRatio * 100)}% of token mass) — oversum signature (a session's cumulative usage booked to one subagent row), tripped on ${basis}; aggregates quarantine them but the file is dead weight. Run \`cost rebuild\` first while the session transcripts still exist — quarantining moves the whole ledger aside, and a rebuild afterwards has no rows left to keep`,
|
|
288
293
|
{ action: 'quarantine-ledger' }));
|
|
289
294
|
return { findings };
|
|
290
295
|
}
|
|
@@ -456,6 +461,17 @@ function checkCachedContext(cwd) {
|
|
|
456
461
|
`~${fmtTokens(tokens)} tokens re-read on every agent call (warn ${fmtTokens(CACHE_FILE_WARN_TOKENS)})${suffix}`,
|
|
457
462
|
fix));
|
|
458
463
|
}
|
|
464
|
+
|
|
465
|
+
// Lifetime signal (ADR-0046 D5): the ledger shows cache WRITES that followed
|
|
466
|
+
// an idle gap of five to sixty minutes — misses a one-hour subagent cache
|
|
467
|
+
// lifetime would have turned into hits. Informational and never fixable: the
|
|
468
|
+
// remedy is a Claude Code setting the user weighs against the 2× write price.
|
|
469
|
+
try {
|
|
470
|
+
const ttl = assessCacheTtl(readRecords(cwd).filter(r => !isSuspectRecord(r)));
|
|
471
|
+
if (ttl.recommend) {
|
|
472
|
+
findings.push(mkFinding('cache-context', ttl.severity, planningRel(path.join(METRICS_DIR, TOKENS_FILE)), ttl.advice, null));
|
|
473
|
+
}
|
|
474
|
+
} catch { /* no ledger, or unreadable — nothing to say */ }
|
|
459
475
|
return { findings };
|
|
460
476
|
}
|
|
461
477
|
|
|
@@ -481,15 +497,11 @@ function checkPlanningFragment(cwd) {
|
|
|
481
497
|
const dir = planningPath(cwd);
|
|
482
498
|
let entries = [];
|
|
483
499
|
try { entries = fs.readdirSync(dir); } catch { return { findings, planning_exists: false }; }
|
|
484
|
-
|
|
485
|
-
//
|
|
486
|
-
//
|
|
487
|
-
//
|
|
488
|
-
|
|
489
|
-
const SPINE = ['project.md', 'state.md', 'phases', 'roadmap.md', 'requirements.md',
|
|
490
|
-
'milestones', 'focus', 'quick', 'orchestration'];
|
|
491
|
-
const hasSpine = SPINE.some(s => lower.includes(s));
|
|
492
|
-
if (!hasSpine && entries.length > 0) {
|
|
500
|
+
// A spine is whatever marks a deliberate PAN workflow — the phase model, the focus
|
|
501
|
+
// model, or an orchestration campaign (PLANNING_MODEL_MARKERS, shared with
|
|
502
|
+
// `validate health` so the two verbs cannot disagree about a tree). A dir holding
|
|
503
|
+
// only generated artifacts (codebase maps, metrics, traces) is a stray fragment.
|
|
504
|
+
if (detectPlanningModel(dir).model === 'fragment') {
|
|
493
505
|
findings.push(mkFinding('planning-fragment', 'info', planningRootRel(),
|
|
494
506
|
`.planning exists with ${entries.length} entr${entries.length === 1 ? 'y' : 'ies'} (${entries.slice(0, 5).join(', ')}) but no workflow spine (project/state/phases/focus/…) — likely a stray partial run; review and delete manually`,
|
|
495
507
|
null));
|
|
@@ -517,6 +529,18 @@ function checkPlanningFragment(cwd) {
|
|
|
517
529
|
*/
|
|
518
530
|
function scanOneRoot(cwd, root, opts) {
|
|
519
531
|
return withPlanningRoot(root.rel, () => {
|
|
532
|
+
// A .planning/ written by ANOTHER tool (gsd-core shares the directory name and
|
|
533
|
+
// PAN's pre-v2.2 uppercase file names) must never be "repaired": the legacy
|
|
534
|
+
// rename would rename its state files. One warn finding, nothing fixable, and
|
|
535
|
+
// none of the per-tree checks run on it. Reality check R15.
|
|
536
|
+
const foreign = detectForeignPlanningTree(planningPath(cwd));
|
|
537
|
+
if (foreign) {
|
|
538
|
+
const f = mkFinding('foreign-planning-tree', 'warn', planningRel(),
|
|
539
|
+
`planning tree belongs to ${foreign.tool} (${foreign.evidence.join(', ')}) — PAN will not rename or repair its files; run PAN with --planning-dir to give it a tree of its own (ADR-0043)`,
|
|
540
|
+
null);
|
|
541
|
+
f.track = root.name;
|
|
542
|
+
return { findings: [f], planning_exists: true };
|
|
543
|
+
}
|
|
520
544
|
const fragment = checkPlanningFragment(cwd);
|
|
521
545
|
const findings = [
|
|
522
546
|
...fragment.findings,
|
|
@@ -594,6 +618,12 @@ function applyFix(cwd, finding) {
|
|
|
594
618
|
try {
|
|
595
619
|
switch (fix.action) {
|
|
596
620
|
case 'rename-lowercase': {
|
|
621
|
+
// Defence in depth for R15: the scan never emits this fix for a foreign tree,
|
|
622
|
+
// but a stale findings list or a hand-built one must not rename another
|
|
623
|
+
// tool's files either.
|
|
624
|
+
if (detectForeignPlanningTree(path.dirname(abs))) {
|
|
625
|
+
return { applied: false, detail: 'refused: this planning tree belongs to another tool (see the foreign-planning-tree finding)' };
|
|
626
|
+
}
|
|
597
627
|
// Two-step rename: Windows treats case-only renames inconsistently
|
|
598
628
|
// across fs layers, so hop through a temp name.
|
|
599
629
|
const dir = path.dirname(abs);
|
|
@@ -630,16 +660,14 @@ function applyFix(cwd, finding) {
|
|
|
630
660
|
const dest = `${abs}.quarantined-${stamp}`;
|
|
631
661
|
fs.renameSync(abs, dest);
|
|
632
662
|
|
|
633
|
-
// The cursor is a per-
|
|
634
|
-
//
|
|
635
|
-
//
|
|
636
|
-
//
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
cursorNote = ', cursor reset';
|
|
642
|
-
} catch { /* no cursor to reset */ }
|
|
663
|
+
// The cost cursor (COST_CURSOR_FILE) STAYS. It is a per-TRANSCRIPT
|
|
664
|
+
// high-water mark — how many records of each session transcript the
|
|
665
|
+
// hooks have already attributed — not a position in the ledger, so it
|
|
666
|
+
// has nothing to do with the file being moved aside. This step used to
|
|
667
|
+
// delete it as "a fresh ledger must not inherit the old read position",
|
|
668
|
+
// and the next SubagentStop then re-summed every session transcript from
|
|
669
|
+
// line 0: a fresh oversum row the day after each quarantine (field,
|
|
670
|
+
// 2026-08-25 → 08-26). Quarantine and poison had become a loop.
|
|
643
671
|
|
|
644
672
|
// Quarantine leaves a dated copy behind, and nothing else ever removes
|
|
645
673
|
// one. Run hygiene a few times over a year and the metrics dir fills
|
|
@@ -649,7 +677,7 @@ function applyFix(cwd, finding) {
|
|
|
649
677
|
|
|
650
678
|
return {
|
|
651
679
|
applied: true,
|
|
652
|
-
detail: `renamed to ${path.basename(dest)}${
|
|
680
|
+
detail: `renamed to ${path.basename(dest)}${prunedNote} — fresh ledger starts clean; transcript cursor kept`,
|
|
653
681
|
};
|
|
654
682
|
}
|
|
655
683
|
default:
|
|
@@ -10,6 +10,7 @@ const { planningPath, phasesPath, filterPlanFiles, filterSummaryFiles, classifyP
|
|
|
10
10
|
const { classifyPlanTier } = require('./phase.cjs');
|
|
11
11
|
const { extractFrontmatter } = require('./frontmatter.cjs');
|
|
12
12
|
const { detectLanguages } = require('./codebase.cjs');
|
|
13
|
+
const { detectForeignPlanningTreeAt } = require('./foreign-planning.cjs');
|
|
13
14
|
const { planningRootRel, describePlanningRoot, planningRoots, withPlanningRoot } = require('./planning-root.cjs');
|
|
14
15
|
|
|
15
16
|
// ---- Git helpers ----
|
|
@@ -282,6 +283,13 @@ function cmdInitPlanPhase(cwd, phase, raw) {
|
|
|
282
283
|
* @returns {void}
|
|
283
284
|
*/
|
|
284
285
|
function cmdInitNewProject(cwd, raw) {
|
|
286
|
+
// Never scaffold PAN's files into a .planning/ another tool owns (R15). The error
|
|
287
|
+
// key carries the exit code; the fix is a separate tree via --planning-dir.
|
|
288
|
+
const foreign = detectForeignPlanningTreeAt(cwd);
|
|
289
|
+
if (foreign) {
|
|
290
|
+
output({ error: `planning tree belongs to ${foreign.tool}`, evidence: foreign.evidence, fix: 'Run PAN with --planning-dir <dir> to use a separate tree (ADR-0043)' }, raw);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
285
293
|
const config = loadConfig(cwd);
|
|
286
294
|
|
|
287
295
|
// Detect Brave Search API key availability
|
|
@@ -301,11 +301,17 @@ function selectMemory(cwd, agent, opts = {}) {
|
|
|
301
301
|
/**
|
|
302
302
|
* Memory-load telemetry gate (ADR-0036 acceptance signal). Estimates the tokens
|
|
303
303
|
* of memory that would be injected whole (every agent log) and compares to the
|
|
304
|
-
* median per-agent
|
|
304
|
+
* median per-agent PROMPT from the trustworthy cost ledger (suspect records
|
|
305
305
|
* quarantined). Read-only, non-blocking; degrades to an absolute-token check
|
|
306
306
|
* when the ledger is thin.
|
|
307
307
|
*
|
|
308
|
-
*
|
|
308
|
+
* The prompt is `input + cache_read + cache_write`, not `input` alone: under prompt
|
|
309
|
+
* caching the uncached remainder is tens of tokens, so dividing by it reported 1.8k
|
|
310
|
+
* of memory as 8,940% of a "median agent input" and called it critical (field sweep
|
|
311
|
+
* 2026-09-17). Memory is injected into the whole prompt, so the whole prompt is what
|
|
312
|
+
* it must be measured against.
|
|
313
|
+
*
|
|
314
|
+
* @returns {{memory_tokens, agents, median_prompt_tokens, fraction, status, advisory}}
|
|
309
315
|
*/
|
|
310
316
|
function memoryLoadBudget(cwd, opts = {}) {
|
|
311
317
|
const { agents } = listMemoryAgents(cwd);
|
|
@@ -317,12 +323,12 @@ function memoryLoadBudget(cwd, opts = {}) {
|
|
|
317
323
|
let median = null;
|
|
318
324
|
try {
|
|
319
325
|
const cost = require('./cost.cjs');
|
|
320
|
-
const
|
|
321
|
-
.filter(r => !cost.isSuspectRecord(r))
|
|
322
|
-
.map(r => Number(r.input_tokens) || 0)
|
|
326
|
+
const prompts = (cost.readRecords(cwd) || [])
|
|
327
|
+
.filter(r => !cost.isSuspectRecord(r) && !cost.isEmptyRecord(r))
|
|
328
|
+
.map(r => (Number(r.input_tokens) || 0) + (Number(r.cache_read_tokens) || 0) + (Number(r.cache_write_tokens) || 0))
|
|
323
329
|
.filter(n => n > 0)
|
|
324
330
|
.sort((a, b) => a - b);
|
|
325
|
-
if (
|
|
331
|
+
if (prompts.length) median = prompts[Math.floor(prompts.length / 2)];
|
|
326
332
|
} catch { /* thin/absent ledger — absolute-token check only */ }
|
|
327
333
|
|
|
328
334
|
const fraction = median ? memoryTokens / median : null;
|
|
@@ -335,9 +341,9 @@ function memoryLoadBudget(cwd, opts = {}) {
|
|
|
335
341
|
const advisory = status === 'ok'
|
|
336
342
|
? 'Memory-load within budget.'
|
|
337
343
|
: `Memory injection is ~${memoryTokens} tokens across ${agents.length} agent log(s)` +
|
|
338
|
-
(fraction != null ? ` (~${Math.round(fraction * 100)}% of median agent
|
|
344
|
+
(fraction != null ? ` (~${Math.round(fraction * 100)}% of a median agent prompt)` : '') +
|
|
339
345
|
`. Bound it with cue-scoped 'memory select' or trim with 'memory compact <agent>'.`;
|
|
340
|
-
return { memory_tokens: memoryTokens, agents: agents.length,
|
|
346
|
+
return { memory_tokens: memoryTokens, agents: agents.length, median_prompt_tokens: median, fraction, status, advisory };
|
|
341
347
|
}
|
|
342
348
|
|
|
343
349
|
// ─── CLI command wrappers ────────────────────────────────────────────────────
|