pan-wizard 3.30.0 → 3.31.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 +2 -2
- package/bin/install-lib.cjs +129 -33
- package/bin/install.js +16 -9
- package/commands/pan/army.md +4 -4
- package/commands/pan/verify-phase.md +8 -9
- package/hooks/dist/pan-check-update.js +53 -2
- package/hooks/dist/pan-context-monitor.js +41 -0
- package/hooks/dist/pan-cost-logger.js +70 -1
- package/hooks/dist/pan-state-reinject.js +158 -0
- package/hooks/dist/pan-stop-guard.js +48 -2
- package/hooks/dist/pan-trace-logger.js +41 -0
- package/package.json +1 -1
- package/pan-wizard-core/bin/lib/commands.cjs +20 -4
- package/pan-wizard-core/bin/lib/config.cjs +2 -0
- package/pan-wizard-core/bin/lib/constants.cjs +3 -1
- package/pan-wizard-core/bin/lib/context-budget.cjs +27 -2
- package/pan-wizard-core/bin/lib/core.cjs +232 -23
- package/pan-wizard-core/bin/lib/cost-rebuild.cjs +11 -0
- package/pan-wizard-core/bin/lib/cost.cjs +132 -43
- package/pan-wizard-core/bin/lib/frontmatter.cjs +5 -2
- package/pan-wizard-core/bin/lib/memory-optimize.cjs +12 -6
- package/pan-wizard-core/bin/lib/memory-rebuild.cjs +9 -2
- package/pan-wizard-core/bin/lib/phase-report.cjs +4 -1
- package/pan-wizard-core/bin/lib/roadmap.cjs +87 -18
- package/pan-wizard-core/bin/lib/state.cjs +86 -22
- package/pan-wizard-core/bin/lib/suggest.cjs +21 -9
- package/pan-wizard-core/bin/lib/template.cjs +6 -3
- package/pan-wizard-core/bin/lib/verify-deploy.cjs +19 -2
- package/pan-wizard-core/bin/pan-tools.cjs +10 -1
- package/pan-wizard-core/mcp/tool-registry.cjs +14 -4
- package/pan-wizard-core/workflows/exec-phase.md +8 -0
- package/pan-wizard-core/workflows/help.md +2 -2
- package/pan-wizard-core/workflows/phase-tests.md +1 -1
- package/pan-wizard-core/workflows/progress.md +1 -1
- package/pan-wizard-core/workflows/todo-add.md +1 -1
- package/pan-wizard-core/workflows/todo-check.md +7 -6
- package/scripts/build-agent-plugin.js +4 -0
- package/scripts/build-hooks.js +2 -1
- package/scripts/build-plugin.js +10 -0
- package/scripts/npm-dist-tag.js +47 -0
- package/scripts/plugin-path.js +6 -1
- package/scripts/test-surface.cjs +1 -0
|
@@ -46,6 +46,14 @@ const TOKENS_FILE = 'tokens.jsonl';
|
|
|
46
46
|
/**
|
|
47
47
|
* Default rate table ($ per million tokens).
|
|
48
48
|
* Override per-model in config.json → cost.rates.
|
|
49
|
+
*
|
|
50
|
+
* `cache_write` is the five-minute cache write. `cache_write_1h` — Anthropic rows
|
|
51
|
+
* only — is the one-hour write, 2x base input on every current Claude model
|
|
52
|
+
* (pricing table "1h cache writes", read 2026-09-26). It prices the
|
|
53
|
+
* `cache_write_1h_tokens` a ledger row carries when the transcript recorded the
|
|
54
|
+
* lifetime split; a row without it, or a rate without `cache_write_1h`, bills every
|
|
55
|
+
* write at `cache_write` (market-ideas queue M13). OpenAI and Google report no
|
|
56
|
+
* lifetime split, so their rows carry none.
|
|
49
57
|
*/
|
|
50
58
|
const DEFAULT_RATES = {
|
|
51
59
|
// Anthropic — platform.claude.com/docs/en/about-claude/pricing, every row read
|
|
@@ -58,12 +66,12 @@ const DEFAULT_RATES = {
|
|
|
58
66
|
// Fable 5.1 is what the `fable`/`best` aliases resolve to (model-config: neither
|
|
59
67
|
// Fable model is any plan's default); cached re-reads are the bulk of PAN's
|
|
60
68
|
// traffic (ADR-0044).
|
|
61
|
-
'claude-fable-5-1': { input: 10.0, output: 50.0, cache_read: 0.25, cache_write: 12.5 },
|
|
62
|
-
'claude-fable-5': { input: 10.0, output: 50.0, cache_read: 1.0, cache_write: 12.5 },
|
|
69
|
+
'claude-fable-5-1': { input: 10.0, output: 50.0, cache_read: 0.25, cache_write: 12.5, cache_write_1h: 20.0 },
|
|
70
|
+
'claude-fable-5': { input: 10.0, output: 50.0, cache_read: 1.0, cache_write: 12.5, cache_write_1h: 20.0 },
|
|
63
71
|
// Mythos 5.1 / Mythos 5 (limited availability): $10/$50; Mythos 5.1 reads at
|
|
64
72
|
// 0.025× input like Fable 5.1, Mythos 5 follows the 0.1× rule (R7).
|
|
65
|
-
'claude-mythos-5-1': { input: 10.0, output: 50.0, cache_read: 0.25, cache_write: 12.5 },
|
|
66
|
-
'claude-mythos-5': { input: 10.0, output: 50.0, cache_read: 1.0, cache_write: 12.5 },
|
|
73
|
+
'claude-mythos-5-1': { input: 10.0, output: 50.0, cache_read: 0.25, cache_write: 12.5, cache_write_1h: 20.0 },
|
|
74
|
+
'claude-mythos-5': { input: 10.0, output: 50.0, cache_read: 1.0, cache_write: 12.5, cache_write_1h: 20.0 },
|
|
67
75
|
// Opus 5.5 (claude-opus-5-5, released 2026-09-22): $4 input / $20 output, 5-minute
|
|
68
76
|
// writes $5, cache hits $0.20 — the page prices its hits at 0.05× input. Claude
|
|
69
77
|
// Code 2.1.280 made it the default model on every plan (model-config, read
|
|
@@ -71,24 +79,24 @@ const DEFAULT_RATES = {
|
|
|
71
79
|
// prefix priced it as Opus 5: cache reads 2.5× high, the rest a quarter high
|
|
72
80
|
// (reality check 2026-09-22, R25). The models overview lists no dated snapshot —
|
|
73
81
|
// from the 4.6 generation on, the dateless id is canonical.
|
|
74
|
-
'claude-opus-5-5': { input: 4.0, output: 20.0, cache_read: 0.20, cache_write: 5.0 },
|
|
75
|
-
'claude-opus-5': { input: 5.0, output: 25.0, cache_read: 0.5, cache_write: 6.25 },
|
|
76
|
-
'claude-opus-4-8': { input: 5.0, output: 25.0, cache_read: 0.5, cache_write: 6.25 },
|
|
77
|
-
'claude-opus-4-7': { input: 5.0, output: 25.0, cache_read: 0.5, cache_write: 6.25 },
|
|
78
|
-
'claude-opus-4-6': { input: 5.0, output: 25.0, cache_read: 0.5, cache_write: 6.25 },
|
|
82
|
+
'claude-opus-5-5': { input: 4.0, output: 20.0, cache_read: 0.20, cache_write: 5.0, cache_write_1h: 8.0 },
|
|
83
|
+
'claude-opus-5': { input: 5.0, output: 25.0, cache_read: 0.5, cache_write: 6.25, cache_write_1h: 10.0 },
|
|
84
|
+
'claude-opus-4-8': { input: 5.0, output: 25.0, cache_read: 0.5, cache_write: 6.25, cache_write_1h: 10.0 },
|
|
85
|
+
'claude-opus-4-7': { input: 5.0, output: 25.0, cache_read: 0.5, cache_write: 6.25, cache_write_1h: 10.0 },
|
|
86
|
+
'claude-opus-4-6': { input: 5.0, output: 25.0, cache_read: 0.5, cache_write: 6.25, cache_write_1h: 10.0 },
|
|
79
87
|
// Opus 4.5 (dated id claude-opus-4-5-20251101): without this row the dated id had
|
|
80
88
|
// no family prefix to land on and priced as null (R7).
|
|
81
|
-
'claude-opus-4-5': { input: 5.0, output: 25.0, cache_read: 0.5, cache_write: 6.25 },
|
|
89
|
+
'claude-opus-4-5': { input: 5.0, output: 25.0, cache_read: 0.5, cache_write: 6.25, cache_write_1h: 10.0 },
|
|
82
90
|
// Sonnet 5 is $2/$10: the launch price announced as introductory through
|
|
83
91
|
// 2026-08-31 was made permanent and the scheduled rise to $3/$15 cancelled.
|
|
84
92
|
// Lesson: never write down a pre-announced price — this row carried the future
|
|
85
93
|
// rate for a month and over-billed by half.
|
|
86
|
-
'claude-sonnet-5': { input: 2.0, output: 10.0, cache_read: 0.20, cache_write: 2.50 },
|
|
87
|
-
'claude-sonnet-4-6': { input: 3.0, output: 15.0, cache_read: 0.3, cache_write: 3.75 },
|
|
94
|
+
'claude-sonnet-5': { input: 2.0, output: 10.0, cache_read: 0.20, cache_write: 2.50, cache_write_1h: 4.0 },
|
|
95
|
+
'claude-sonnet-4-6': { input: 3.0, output: 15.0, cache_read: 0.3, cache_write: 3.75, cache_write_1h: 6.0 },
|
|
88
96
|
// Sonnet 4.5 (dated id claude-sonnet-4-5-20250929): $3/$15/$0.30/$3.75 (R7).
|
|
89
|
-
'claude-sonnet-4-5': { input: 3.0, output: 15.0, cache_read: 0.3, cache_write: 3.75 },
|
|
97
|
+
'claude-sonnet-4-5': { input: 3.0, output: 15.0, cache_read: 0.3, cache_write: 3.75, cache_write_1h: 6.0 },
|
|
90
98
|
// Haiku 4.5: API id claude-haiku-4-5-20251001, alias claude-haiku-4-5.
|
|
91
|
-
'claude-haiku-4-5': { input: 1.0, output: 5.0, cache_read: 0.1, cache_write: 1.25 },
|
|
99
|
+
'claude-haiku-4-5': { input: 1.0, output: 5.0, cache_read: 0.1, cache_write: 1.25, cache_write_1h: 2.0 },
|
|
92
100
|
|
|
93
101
|
// OpenAI — developers.openai.com/api/docs/pricing, Standard tier, short context
|
|
94
102
|
// (≤272K input tokens), read 2026-09-23. On the GPT-5.6 and GPT-6 families a cache
|
|
@@ -149,9 +157,9 @@ const DEFAULT_RATES = {
|
|
|
149
157
|
// (Opus 5.5 since 2026-09-22 — the model the inherit tier runs on for most users),
|
|
150
158
|
// mid the Sonnet the `sonnet` alias resolves to on the Anthropic API (Sonnet 5),
|
|
151
159
|
// fast Haiku 4.5.
|
|
152
|
-
'reasoning': { input: 4.0, output: 20.0, cache_read: 0.20, cache_write: 5.0 },
|
|
153
|
-
'mid': { input: 2.0, output: 10.0, cache_read: 0.20, cache_write: 2.50 },
|
|
154
|
-
'fast': { input: 1.0, output: 5.0, cache_read: 0.1, cache_write: 1.25 },
|
|
160
|
+
'reasoning': { input: 4.0, output: 20.0, cache_read: 0.20, cache_write: 5.0, cache_write_1h: 8.0 },
|
|
161
|
+
'mid': { input: 2.0, output: 10.0, cache_read: 0.20, cache_write: 2.50, cache_write_1h: 4.0 },
|
|
162
|
+
'fast': { input: 1.0, output: 5.0, cache_read: 0.1, cache_write: 1.25, cache_write_1h: 2.0 },
|
|
155
163
|
};
|
|
156
164
|
|
|
157
165
|
function metricsDir(cwd) {
|
|
@@ -175,29 +183,80 @@ function familyPrefixRate(rates, model) {
|
|
|
175
183
|
|
|
176
184
|
// ─── Claude Code `modelPricing` as a rate source (2026-09) ──────────────────
|
|
177
185
|
//
|
|
178
|
-
// Claude Code ≥2.1.
|
|
179
|
-
// MANAGED
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
+
// Claude Code ≥2.1.242 lets an organisation report spend at contracted rates
|
|
187
|
+
// through a MANAGED setting, and prices its own /usage, status line and
|
|
188
|
+
// telemetry with it. Honouring the same block keeps PAN's ledger on the numbers
|
|
189
|
+
// the organisation actually pays. The documented shape (settings-reference
|
|
190
|
+
// `modelPricing`, read raw 2026-09-26):
|
|
191
|
+
//
|
|
192
|
+
// modelPricing: {
|
|
193
|
+
// multiplier: 0.85, // optional, > 0 and ≤ 10
|
|
194
|
+
// overrides: { // optional
|
|
195
|
+
// "claude-sonnet-4-6": { input, output, cacheRead, cacheWrite } // $/1M, each 0–10000, all four required
|
|
196
|
+
// }
|
|
197
|
+
// }
|
|
198
|
+
//
|
|
199
|
+
// `multiplier` "scales every cost Claude Code computes, whether or not an
|
|
200
|
+
// overrides row covers it" — below 1 a discount, above 1 a chargeback markup
|
|
201
|
+
// (2.1.271). It is applied on top of an override row's rates, and to the list
|
|
202
|
+
// price of every model no row covers. Claude Code drops a row or a multiplier it
|
|
203
|
+
// cannot parse and keeps the rest; PAN does the same.
|
|
204
|
+
//
|
|
205
|
+
// Until 2026-09-26 PAN parsed a different shape — `{ "<modelId>":
|
|
206
|
+
// { inputCostPer1MTokens, outputCostPer1MTokens } }` with cache rates derived —
|
|
207
|
+
// that the documentation does not describe; a real policy file matched nothing
|
|
208
|
+
// and PAN priced at list price. (Market-ideas queue M2 found it while adding the
|
|
209
|
+
// multiplier.)
|
|
186
210
|
|
|
187
211
|
const round6 = (n) => Number(n.toFixed(6));
|
|
188
212
|
|
|
213
|
+
const MODEL_PRICING_MAX_MULTIPLIER = 10;
|
|
214
|
+
const MODEL_PRICING_MAX_RATE = 10000;
|
|
215
|
+
|
|
216
|
+
// resolveRate reads this off a rates map to scale its built-in fallback. A
|
|
217
|
+
// symbol, so Object.keys (the family-prefix match, `models check`) never sees it.
|
|
218
|
+
const BUILTIN_MULTIPLIER = Symbol('pan.builtinRateMultiplier');
|
|
219
|
+
|
|
220
|
+
/** The block's multiplier when it is a valid one, otherwise null. */
|
|
221
|
+
function modelPricingMultiplier(modelPricing) {
|
|
222
|
+
if (!modelPricing || typeof modelPricing !== 'object' || !('multiplier' in modelPricing)) return null;
|
|
223
|
+
const raw = modelPricing.multiplier;
|
|
224
|
+
if (raw === null || raw === '' || typeof raw === 'boolean') return null;
|
|
225
|
+
const m = Number(raw);
|
|
226
|
+
return Number.isFinite(m) && m > 0 && m <= MODEL_PRICING_MAX_MULTIPLIER ? m : null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const scaleRate = (rate, m) => ({
|
|
230
|
+
input: round6(rate.input * m),
|
|
231
|
+
output: round6(rate.output * m),
|
|
232
|
+
cache_read: round6(rate.cache_read * m),
|
|
233
|
+
cache_write: round6(rate.cache_write * m),
|
|
234
|
+
...(typeof rate.cache_write_1h === 'number' ? { cache_write_1h: round6(rate.cache_write_1h * m) } : {}),
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* PAN rate rows for a managed `modelPricing` block's `overrides`, with the
|
|
239
|
+
* block's multiplier applied. A row missing any of the four rates, or holding
|
|
240
|
+
* one outside 0–10000, is dropped.
|
|
241
|
+
* @param {object|null} modelPricing - `{ multiplier?, overrides? }`
|
|
242
|
+
* @returns {Object<string, {input:number, output:number, cache_read:number, cache_write:number}>}
|
|
243
|
+
*/
|
|
189
244
|
function ratesFromModelPricing(modelPricing) {
|
|
190
245
|
const out = {};
|
|
191
246
|
if (!modelPricing || typeof modelPricing !== 'object' || Array.isArray(modelPricing)) return out;
|
|
192
|
-
|
|
247
|
+
const overrides = modelPricing.overrides;
|
|
248
|
+
if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) return out;
|
|
249
|
+
const m = modelPricingMultiplier(modelPricing) || 1;
|
|
250
|
+
const valid = (v) => v !== null && v !== '' && typeof v !== 'boolean'
|
|
251
|
+
&& Number.isFinite(Number(v)) && Number(v) >= 0 && Number(v) <= MODEL_PRICING_MAX_RATE;
|
|
252
|
+
for (const [id, p] of Object.entries(overrides)) {
|
|
193
253
|
if (!p || typeof p !== 'object') continue;
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
out[id] = { input, output, cache_read: round6(input * readMult), cache_write: round6(input * writeMult) };
|
|
254
|
+
if (![p.input, p.output, p.cacheRead, p.cacheWrite].every(valid)) continue;
|
|
255
|
+
// "cacheWrite covers both five-minute and one-hour cache writes."
|
|
256
|
+
out[id] = scaleRate({
|
|
257
|
+
input: Number(p.input), output: Number(p.output),
|
|
258
|
+
cache_read: Number(p.cacheRead), cache_write: Number(p.cacheWrite), cache_write_1h: Number(p.cacheWrite),
|
|
259
|
+
}, m);
|
|
201
260
|
}
|
|
202
261
|
return out;
|
|
203
262
|
}
|
|
@@ -229,25 +288,35 @@ function loadManagedModelPricing(dir = managedSettingsDir()) {
|
|
|
229
288
|
.sort()
|
|
230
289
|
.map(f => path.join(dropIns, f)));
|
|
231
290
|
} catch { /* no drop-in directory */ }
|
|
291
|
+
// Later files win: a later `multiplier` replaces an earlier one, and
|
|
292
|
+
// `overrides` merge per model id.
|
|
232
293
|
let merged = null;
|
|
233
294
|
for (const f of files) {
|
|
234
295
|
let parsed;
|
|
235
296
|
try { parsed = JSON.parse(fs.readFileSync(f, 'utf8')); } catch { continue; }
|
|
236
297
|
const mp = parsed && typeof parsed === 'object' ? parsed.modelPricing : null;
|
|
237
|
-
if (mp
|
|
298
|
+
if (!mp || typeof mp !== 'object' || Array.isArray(mp)) continue;
|
|
299
|
+
merged = merged || {};
|
|
300
|
+
if ('multiplier' in mp) merged.multiplier = mp.multiplier;
|
|
301
|
+
if (mp.overrides && typeof mp.overrides === 'object' && !Array.isArray(mp.overrides)) {
|
|
302
|
+
merged.overrides = { ...(merged.overrides || {}), ...mp.overrides };
|
|
303
|
+
}
|
|
238
304
|
}
|
|
239
305
|
return merged;
|
|
240
306
|
}
|
|
241
307
|
|
|
242
308
|
// The rate table a cost computation actually sees. Precedence, highest first:
|
|
243
|
-
// 1. `.planning/config.json → cost.rates` — PAN's explicit per-project override
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
309
|
+
// 1. `.planning/config.json → cost.rates` — PAN's explicit per-project override,
|
|
310
|
+
// used exactly as written (the managed multiplier does not touch it)
|
|
311
|
+
// 2. managed `modelPricing.overrides` — contracted rates, × the multiplier
|
|
312
|
+
// 3. DEFAULT_RATES — resolveRate's own fallback, × the managed multiplier
|
|
313
|
+
// Returns undefined (not {}) when nothing overrides and no multiplier applies, so
|
|
314
|
+
// callers keep the exact pre-2026-09 behaviour of passing no config rates.
|
|
248
315
|
function effectiveRates(config, managedPricing = loadManagedModelPricing()) {
|
|
249
316
|
const rates = { ...ratesFromModelPricing(managedPricing), ...(config?.cost?.rates || {}) };
|
|
250
|
-
|
|
317
|
+
const m = modelPricingMultiplier(managedPricing);
|
|
318
|
+
if (m !== null && m !== 1) rates[BUILTIN_MULTIPLIER] = m;
|
|
319
|
+
return Object.keys(rates).length > 0 || rates[BUILTIN_MULTIPLIER] ? rates : undefined;
|
|
251
320
|
}
|
|
252
321
|
|
|
253
322
|
function resolveRate(model, tier, configRates) {
|
|
@@ -264,6 +333,12 @@ function resolveRate(model, tier, configRates) {
|
|
|
264
333
|
}
|
|
265
334
|
if (tier && configRates[tier]) return configRates[tier];
|
|
266
335
|
}
|
|
336
|
+
const builtin = builtinRate(model, tier);
|
|
337
|
+
const m = configRates ? configRates[BUILTIN_MULTIPLIER] : undefined;
|
|
338
|
+
return builtin && m ? scaleRate(builtin, m) : builtin;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function builtinRate(model, tier) {
|
|
267
342
|
if (model && DEFAULT_RATES[model]) return DEFAULT_RATES[model];
|
|
268
343
|
if (model) {
|
|
269
344
|
const fam = familyPrefixRate(DEFAULT_RATES, model);
|
|
@@ -299,8 +374,13 @@ function computeCost(rec, configRates) {
|
|
|
299
374
|
// outright. On a realistic row (30k input / 5k output / 200k cache read / 12k
|
|
300
375
|
// cache write on Opus-5 rates) it reported $0.30 against a true $0.45 — a 33%
|
|
301
376
|
// understatement, always in the direction of looking cheaper.
|
|
302
|
-
|
|
303
|
-
|
|
377
|
+
// The one-hour share of the writes, when the row recorded the lifetime split (M13),
|
|
378
|
+
// bills at the one-hour rate; the rest — and every write on a row or rate without
|
|
379
|
+
// the split — at `cache_write`.
|
|
380
|
+
const write1h = Math.min(cacheWrite, Math.max(0, Number(rec.cache_write_1h_tokens) || 0));
|
|
381
|
+
const rate1h = typeof rate.cache_write_1h === 'number' ? rate.cache_write_1h : rate.cache_write;
|
|
382
|
+
const usd = (input * rate.input + output * rate.output + cacheRead * rate.cache_read
|
|
383
|
+
+ (cacheWrite - write1h) * rate.cache_write + write1h * rate1h) / 1_000_000;
|
|
304
384
|
return Math.round(usd * 10000) / 10000;
|
|
305
385
|
}
|
|
306
386
|
|
|
@@ -668,7 +748,16 @@ function checkRatesStaleness(now = new Date()) {
|
|
|
668
748
|
function cmdModelsCheck(raw) {
|
|
669
749
|
// Surface the managed rates too: an organisation that pins `modelPricing`
|
|
670
750
|
// should be able to see that PAN found the block, not infer it from totals.
|
|
671
|
-
const
|
|
751
|
+
const managed = loadManagedModelPricing();
|
|
752
|
+
const result = {
|
|
753
|
+
...checkRatesStaleness(),
|
|
754
|
+
managed_model_pricing: Object.keys(ratesFromModelPricing(managed)),
|
|
755
|
+
managed_pricing_multiplier: modelPricingMultiplier(managed),
|
|
756
|
+
};
|
|
757
|
+
// A multiplier PAN cannot apply is reported, not silently dropped.
|
|
758
|
+
if (managed && 'multiplier' in managed && result.managed_pricing_multiplier === null) {
|
|
759
|
+
result.managed_pricing_multiplier_ignored = managed.multiplier;
|
|
760
|
+
}
|
|
672
761
|
const human = result.stale
|
|
673
762
|
? `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`
|
|
674
763
|
: `Rate table verified ${result.rates_verified_at} (${result.age_days} days ago) — OK`;
|
|
@@ -89,8 +89,11 @@ function extractFrontmatter(content) {
|
|
|
89
89
|
const frontmatter = {};
|
|
90
90
|
|
|
91
91
|
// --- Frontmatter delimiter detection ---
|
|
92
|
-
// Match the first YAML block bounded by opening "---\n" and closing "\n---"
|
|
93
|
-
|
|
92
|
+
// Match the first YAML block bounded by opening "---\n" and closing "\n---".
|
|
93
|
+
// CRLF and a leading BOM are normalised first: the LF-only match found no front
|
|
94
|
+
// matter at all in a CRLF file, so every reader saw an empty object.
|
|
95
|
+
const text = String(content == null ? '' : content).replace(/^\uFEFF/, '').replace(/\r\n/g, '\n');
|
|
96
|
+
const match = text.match(/^---\n([\s\S]+?)\n---/);
|
|
94
97
|
if (!match) return frontmatter;
|
|
95
98
|
|
|
96
99
|
const yaml = match[1];
|
|
@@ -214,17 +214,23 @@ function quarantinePath(cwd) {
|
|
|
214
214
|
* restore an entry by hand. The file leads with a warning so it is never loaded
|
|
215
215
|
* as trusted instruction memory.
|
|
216
216
|
*/
|
|
217
|
+
const QUARANTINE_HEADER = '# Quarantined memory (DO NOT auto-load as instructions)\n\nEntries below were pulled out of standing memory during reconcile because they read like\ndirectives aimed at the agent (possible cross-generation prompt injection). They are\nNOT trusted instructions. Review each; restore to state.md by hand only if legitimate.\n';
|
|
218
|
+
|
|
217
219
|
function appendQuarantine(cwd, entries, now) {
|
|
218
220
|
if (!entries.length) return;
|
|
219
221
|
const p = quarantinePath(cwd);
|
|
220
|
-
const fresh = !fs.existsSync(p);
|
|
221
222
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
223
|
+
// Create the file with its warning header atomically (`wx` fails if it exists)
|
|
224
|
+
// instead of checking existsSync first: between that check and the append a
|
|
225
|
+
// concurrent reconcile could write the header twice, or skip it (CodeQL #46,
|
|
226
|
+
// js/file-system-race).
|
|
227
|
+
try {
|
|
228
|
+
fs.writeFileSync(p, QUARANTINE_HEADER, { encoding: 'utf-8', flag: 'wx' });
|
|
229
|
+
} catch (e) {
|
|
230
|
+
if (e.code !== 'EEXIST') throw e;
|
|
231
|
+
}
|
|
222
232
|
const stamp = now || '(undated)';
|
|
223
|
-
|
|
224
|
-
? '# Quarantined memory (DO NOT auto-load as instructions)\n\nEntries below were pulled out of standing memory during reconcile because they read like\ndirectives aimed at the agent (possible cross-generation prompt injection). They are\nNOT trusted instructions. Review each; restore to state.md by hand only if legitimate.\n'
|
|
225
|
-
: '';
|
|
226
|
-
const block = `${header}\n## Quarantined ${stamp}\n\n${entries.join('\n')}\n`;
|
|
227
|
-
fs.appendFileSync(p, block, 'utf-8');
|
|
233
|
+
fs.appendFileSync(p, `\n## Quarantined ${stamp}\n\n${entries.join('\n')}\n`, 'utf-8');
|
|
228
234
|
}
|
|
229
235
|
|
|
230
236
|
/**
|
|
@@ -149,8 +149,15 @@ function cmdMemoryRebuild(cwd, opts = {}, raw) {
|
|
|
149
149
|
const p = path.join(planningPath(cwd), 'state.md');
|
|
150
150
|
const existing = safeReadFile(p);
|
|
151
151
|
if (existing != null) {
|
|
152
|
-
|
|
153
|
-
|
|
152
|
+
let desired = null;
|
|
153
|
+
try {
|
|
154
|
+
desired = syncStateFrontmatter(existing, cwd);
|
|
155
|
+
} catch (err) {
|
|
156
|
+
// A state.md PAN refuses to re-derive (stacked front matter) is reported,
|
|
157
|
+
// never rewritten on a guess.
|
|
158
|
+
targets.push({ file: '.planning/state.md', action: 'refused', reason: err.message });
|
|
159
|
+
}
|
|
160
|
+
if (desired !== null) targets.push({ file: '.planning/state.md', ...rebuildFile(p, existing, desired, apply) });
|
|
154
161
|
}
|
|
155
162
|
}
|
|
156
163
|
|
|
@@ -724,8 +724,11 @@ function cmdReport(cwd, opts = {}, raw) {
|
|
|
724
724
|
if (action === 'all') {
|
|
725
725
|
const res = renderAllToDisk(cwd, { now });
|
|
726
726
|
if (!res) return error('No phases found — nothing to report.');
|
|
727
|
+
// --open was parsed for `all` and never used; open the index it wrote, on the
|
|
728
|
+
// same only-when-written rule as the single-report actions.
|
|
729
|
+
const opened = opts.open && res.index.written ? openInBrowser(res.index.path) : false;
|
|
727
730
|
return output(
|
|
728
|
-
{ action, reports: res.reports, index: res.index },
|
|
731
|
+
{ action, reports: res.reports, index: res.index, opened },
|
|
729
732
|
raw,
|
|
730
733
|
`generated ${res.reports.length} phase report(s)${res.index.path ? ' + index' : ''}`,
|
|
731
734
|
);
|
|
@@ -320,6 +320,78 @@ function cmdRoadmapAnalyze(cwd, raw) {
|
|
|
320
320
|
* @param {boolean} raw - If true, output raw value instead of JSON
|
|
321
321
|
* @returns {void}
|
|
322
322
|
*/
|
|
323
|
+
// ─── Progress table and Plans line, located by name ─────────────────────────
|
|
324
|
+
//
|
|
325
|
+
// `roadmap update-plan-progress` used to rewrite the phase's table row by column
|
|
326
|
+
// POSITION — cells 2 to 4, whatever the header said. On PAN's own milestone
|
|
327
|
+
// variant (`| Phase | Milestone | Plans Complete | Status | Completed |`) that put
|
|
328
|
+
// the plan count in the Milestone column; on a roadmap with other columns it
|
|
329
|
+
// destroyed the phase's goal and requirement ids. And the `**Plans:**` update was a
|
|
330
|
+
// lazy match from the phase heading onward, so a phase without its own Plans line
|
|
331
|
+
// had the NEXT phase's rewritten. Both are now located by name, and a table without
|
|
332
|
+
// Plans and Status columns is left alone and reported.
|
|
333
|
+
|
|
334
|
+
const splitRow = (line) => {
|
|
335
|
+
const inner = line.trim().replace(/^\|/, '').replace(/\|$/, '');
|
|
336
|
+
return inner.split(/(?<!\\)\|/).map((c) => c.trim());
|
|
337
|
+
};
|
|
338
|
+
const isTableLine = (line) => /^\s*\|.*\|\s*$/.test(line);
|
|
339
|
+
const isSeparator = (line) => /^\s*\|(?:\s*:?-{3,}:?\s*\|)+\s*$/.test(line);
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Set the Plans / Status / Completed cells of the phase's row in any table whose
|
|
343
|
+
* header names Plans and Status columns. Pure.
|
|
344
|
+
* @returns {{content: string, updated: boolean, reason: string|null}}
|
|
345
|
+
*/
|
|
346
|
+
function updateProgressTableRow(content, phaseNum, values) {
|
|
347
|
+
const eol = content.includes('\r\n') ? '\r\n' : '\n';
|
|
348
|
+
const lines = content.split(/\r?\n/);
|
|
349
|
+
const phaseRe = new RegExp(`^(?:phase\\s+)?${escapeRegex(String(phaseNum))}\\.?(?=\\s|:|$)`, 'i');
|
|
350
|
+
let reason = null;
|
|
351
|
+
for (let i = 0; i + 1 < lines.length; i++) {
|
|
352
|
+
if (!isTableLine(lines[i]) || !isSeparator(lines[i + 1])) continue;
|
|
353
|
+
const header = splitRow(lines[i]);
|
|
354
|
+
const col = (re) => header.findIndex((h) => re.test(h));
|
|
355
|
+
const plansIdx = col(/\bplans?\b/i);
|
|
356
|
+
const statusIdx = col(/^status$/i);
|
|
357
|
+
const doneIdx = col(/^completed?(?:\s+on|\s+date)?$/i);
|
|
358
|
+
for (let r = i + 2; r < lines.length && isTableLine(lines[r]); r++) {
|
|
359
|
+
const cells = splitRow(lines[r]);
|
|
360
|
+
if (!phaseRe.test(cells[0] || '')) continue;
|
|
361
|
+
if (plansIdx < 0 || statusIdx < 0) {
|
|
362
|
+
reason = `the table holding phase ${phaseNum} has no Plans and Status columns (${header.join(' | ')}) — left unchanged`;
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
while (cells.length < header.length) cells.push('');
|
|
366
|
+
cells[plansIdx] = values.plans;
|
|
367
|
+
cells[statusIdx] = values.status;
|
|
368
|
+
if (doneIdx >= 0) cells[doneIdx] = values.completed || '';
|
|
369
|
+
const indent = lines[r].match(/^\s*/)[0];
|
|
370
|
+
lines[r] = `${indent}| ${cells.join(' | ')} |`;
|
|
371
|
+
return { content: lines.join(eol), updated: true, reason: null };
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return { content, updated: false, reason: reason || `no progress-table row for phase ${phaseNum}` };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Rewrite `**Plans:**` inside the phase's own section only. Pure. */
|
|
378
|
+
function updatePhasePlansLine(content, phaseNum, text) {
|
|
379
|
+
const lines = content.split(/(?<=\n)/);
|
|
380
|
+
const headRe = new RegExp(`^(#{2,4})\\s*Phase\\s+${escapeRegex(String(phaseNum))}(?=[\\s:.]|$)`, 'i');
|
|
381
|
+
const start = lines.findIndex((l) => headRe.test(l));
|
|
382
|
+
if (start < 0) return { content, updated: false };
|
|
383
|
+
const level = lines[start].match(headRe)[1].length;
|
|
384
|
+
const nextHead = new RegExp(`^#{1,${level}}\\s`);
|
|
385
|
+
for (let i = start + 1; i < lines.length && !nextHead.test(lines[i]); i++) {
|
|
386
|
+
const m = lines[i].match(/^(.*?(?:\*\*Plans:\*\*|\*\*Plans\*\*:)\s*)[^\r\n]*(\r?\n?)$/);
|
|
387
|
+
if (m) {
|
|
388
|
+
lines[i] = `${m[1]}${text}${m[2]}`;
|
|
389
|
+
return { content: lines.join(''), updated: true };
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return { content, updated: false };
|
|
393
|
+
}
|
|
394
|
+
|
|
323
395
|
function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
|
|
324
396
|
if (!phaseNum) {
|
|
325
397
|
error('phase number required for roadmap update-plan-progress');
|
|
@@ -356,26 +428,19 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
|
|
|
356
428
|
}
|
|
357
429
|
const phaseEscaped = escapeRegex(phaseNum);
|
|
358
430
|
|
|
359
|
-
// Progress table row:
|
|
360
|
-
const
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
roadmapContent =
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
);
|
|
369
|
-
|
|
370
|
-
// Update plan count in phase detail section
|
|
371
|
-
const planCountPattern = new RegExp(
|
|
372
|
-
`(#{2,4}\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?(?:\\*\\*Plans:\\*\\*|\\*\\*Plans\\*\\*:)\\s*)[^\\n]+`,
|
|
373
|
-
'i'
|
|
374
|
-
);
|
|
431
|
+
// Progress table row: the Plans, Status and Completed cells, found by header.
|
|
432
|
+
const table = updateProgressTableRow(roadmapContent, phaseNum, {
|
|
433
|
+
plans: `${summaryCount}/${planCount}`,
|
|
434
|
+
status,
|
|
435
|
+
completed: isComplete ? today : '',
|
|
436
|
+
});
|
|
437
|
+
roadmapContent = table.content;
|
|
438
|
+
|
|
439
|
+
// Update plan count in the phase's own detail section
|
|
375
440
|
const planCountText = isComplete
|
|
376
441
|
? `${summaryCount}/${planCount} plans complete`
|
|
377
442
|
: `${summaryCount}/${planCount} plans executed`;
|
|
378
|
-
roadmapContent = roadmapContent
|
|
443
|
+
roadmapContent = updatePhasePlansLine(roadmapContent, phaseNum, planCountText).content;
|
|
379
444
|
|
|
380
445
|
// If complete: check checkbox
|
|
381
446
|
if (isComplete) {
|
|
@@ -402,6 +467,8 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
|
|
|
402
467
|
|
|
403
468
|
try {
|
|
404
469
|
fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8');
|
|
470
|
+
// Read back: report success only for the file this command produced.
|
|
471
|
+
if (fs.readFileSync(roadmapPath, 'utf-8') !== roadmapContent) throw new Error('roadmap.md did not read back as written');
|
|
405
472
|
} catch (err) {
|
|
406
473
|
// error key => exit 1: the write failed, so progress was computed and then lost.
|
|
407
474
|
output({ updated: false, reason: 'Failed to write roadmap.md: ' + err.message, error: err.message || 'roadmap_write_failed' }, raw, 'write error');
|
|
@@ -415,7 +482,9 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
|
|
|
415
482
|
summary_count: summaryCount,
|
|
416
483
|
status,
|
|
417
484
|
complete: isComplete,
|
|
418
|
-
|
|
485
|
+
table_updated: table.updated,
|
|
486
|
+
...(table.updated ? {} : { table_reason: table.reason }),
|
|
487
|
+
}, raw, `${summaryCount}/${planCount} ${status}${table.updated ? '' : ' (progress table not updated)'}`);
|
|
419
488
|
}
|
|
420
489
|
|
|
421
490
|
/**
|