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.
Files changed (42) hide show
  1. package/README.md +2 -2
  2. package/bin/install-lib.cjs +129 -33
  3. package/bin/install.js +16 -9
  4. package/commands/pan/army.md +4 -4
  5. package/commands/pan/verify-phase.md +8 -9
  6. package/hooks/dist/pan-check-update.js +53 -2
  7. package/hooks/dist/pan-context-monitor.js +41 -0
  8. package/hooks/dist/pan-cost-logger.js +70 -1
  9. package/hooks/dist/pan-state-reinject.js +158 -0
  10. package/hooks/dist/pan-stop-guard.js +48 -2
  11. package/hooks/dist/pan-trace-logger.js +41 -0
  12. package/package.json +1 -1
  13. package/pan-wizard-core/bin/lib/commands.cjs +20 -4
  14. package/pan-wizard-core/bin/lib/config.cjs +2 -0
  15. package/pan-wizard-core/bin/lib/constants.cjs +3 -1
  16. package/pan-wizard-core/bin/lib/context-budget.cjs +27 -2
  17. package/pan-wizard-core/bin/lib/core.cjs +232 -23
  18. package/pan-wizard-core/bin/lib/cost-rebuild.cjs +11 -0
  19. package/pan-wizard-core/bin/lib/cost.cjs +132 -43
  20. package/pan-wizard-core/bin/lib/frontmatter.cjs +5 -2
  21. package/pan-wizard-core/bin/lib/memory-optimize.cjs +12 -6
  22. package/pan-wizard-core/bin/lib/memory-rebuild.cjs +9 -2
  23. package/pan-wizard-core/bin/lib/phase-report.cjs +4 -1
  24. package/pan-wizard-core/bin/lib/roadmap.cjs +87 -18
  25. package/pan-wizard-core/bin/lib/state.cjs +86 -22
  26. package/pan-wizard-core/bin/lib/suggest.cjs +21 -9
  27. package/pan-wizard-core/bin/lib/template.cjs +6 -3
  28. package/pan-wizard-core/bin/lib/verify-deploy.cjs +19 -2
  29. package/pan-wizard-core/bin/pan-tools.cjs +10 -1
  30. package/pan-wizard-core/mcp/tool-registry.cjs +14 -4
  31. package/pan-wizard-core/workflows/exec-phase.md +8 -0
  32. package/pan-wizard-core/workflows/help.md +2 -2
  33. package/pan-wizard-core/workflows/phase-tests.md +1 -1
  34. package/pan-wizard-core/workflows/progress.md +1 -1
  35. package/pan-wizard-core/workflows/todo-add.md +1 -1
  36. package/pan-wizard-core/workflows/todo-check.md +7 -6
  37. package/scripts/build-agent-plugin.js +4 -0
  38. package/scripts/build-hooks.js +2 -1
  39. package/scripts/build-plugin.js +10 -0
  40. package/scripts/npm-dist-tag.js +47 -0
  41. package/scripts/plugin-path.js +6 -1
  42. 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.243 lets an organisation pin contracted per-model rates in
179
- // MANAGED settings — `modelPricing: { "<modelId>": { inputCostPer1MTokens,
180
- // outputCostPer1MTokens } }` (settings-reference, read 2026-09-10) — and prices
181
- // its own /usage with them. Honouring the same block keeps PAN's ledger on the
182
- // numbers the organisation actually pays. The shape carries no cache fields, so
183
- // cache rates are DERIVED: the family's own multipliers when DEFAULT_RATES knows
184
- // the family (Fable 5.1 reads bill at 0.025× input, not 0.1×), otherwise the
185
- // Anthropic convention (read 0.1×, write 1.25×).
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
- for (const [id, p] of Object.entries(modelPricing)) {
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
- const input = Number(p.inputCostPer1MTokens);
195
- const output = Number(p.outputCostPer1MTokens);
196
- if (!Number.isFinite(input) || !Number.isFinite(output) || input < 0 || output < 0) continue;
197
- const fam = DEFAULT_RATES[id] || familyPrefixRate(DEFAULT_RATES, id);
198
- const readMult = fam && fam.input > 0 ? fam.cache_read / fam.input : 0.1;
199
- const writeMult = fam && fam.input > 0 ? fam.cache_write / fam.input : 1.25;
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 && typeof mp === 'object' && !Array.isArray(mp)) merged = { ...(merged || {}), ...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
- // 2. managed `modelPricing` — the organisation's contracted rates
245
- // 3. DEFAULT_RATES — resolveRate's own fallback when neither names the model
246
- // Returns undefined (not {}) when nothing overrides, so callers keep the exact
247
- // pre-2026-09 behaviour of passing no config rates.
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
- return Object.keys(rates).length > 0 ? rates : undefined;
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
- const usd = (input * rate.input + output * rate.output
303
- + cacheRead * rate.cache_read + cacheWrite * rate.cache_write) / 1_000_000;
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 result = { ...checkRatesStaleness(), managed_model_pricing: Object.keys(loadManagedModelPricing() || {}) };
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
- const match = content.match(/^---\n([\s\S]+?)\n---/);
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
- const header = fresh
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
- const desired = syncStateFrontmatter(existing, cwd);
153
- targets.push({ file: '.planning/state.md', ...rebuildFile(p, existing, desired, apply) });
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: update Plans column (summaries/plans) and Status column
360
- const tablePattern = new RegExp(
361
- `(\\|\\s*${phaseEscaped}\\.?\\s[^|]*\\|)[^|]*(\\|)\\s*[^|]*(\\|)\\s*[^|]*(\\|)`,
362
- 'i'
363
- );
364
- const dateField = isComplete ? ` ${today} ` : ' ';
365
- roadmapContent = roadmapContent.replace(
366
- tablePattern,
367
- `$1 ${summaryCount}/${planCount} $2 ${status.padEnd(11)}$3${dateField}$4`
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.replace(planCountPattern, `$1${planCountText}`);
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
- }, raw, `${summaryCount}/${planCount} ${status}`);
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
  /**