pan-wizard 3.26.0 → 3.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +48 -48
  2. package/agents/pan-previewer.md +1 -1
  3. package/bin/install-lib.cjs +580 -18
  4. package/bin/install.js +25 -44
  5. package/commands/pan/army.md +1 -1
  6. package/commands/pan/hygiene.md +14 -8
  7. package/commands/pan/milestone-audit.md +10 -4
  8. package/commands/pan/preview.md +2 -2
  9. package/hooks/dist/pan-cost-logger.js +69 -5
  10. package/hooks/dist/pan-stop-guard.js +32 -1
  11. package/hooks/dist/pan-trace-logger.js +35 -2
  12. package/package.json +5 -2
  13. package/pan-wizard-core/bin/lib/bridge.cjs +0 -1
  14. package/pan-wizard-core/bin/lib/bus.cjs +0 -1
  15. package/pan-wizard-core/bin/lib/campaign.cjs +3 -2
  16. package/pan-wizard-core/bin/lib/commands-learnings.cjs +8 -8
  17. package/pan-wizard-core/bin/lib/commands.cjs +15 -14
  18. package/pan-wizard-core/bin/lib/config.cjs +5 -5
  19. package/pan-wizard-core/bin/lib/constants.cjs +49 -0
  20. package/pan-wizard-core/bin/lib/context-budget.cjs +98 -0
  21. package/pan-wizard-core/bin/lib/core.cjs +190 -26
  22. package/pan-wizard-core/bin/lib/cost.cjs +113 -11
  23. package/pan-wizard-core/bin/lib/distill.cjs +3 -3
  24. package/pan-wizard-core/bin/lib/focus.cjs +16 -16
  25. package/pan-wizard-core/bin/lib/foreign-planning.cjs +56 -0
  26. package/pan-wizard-core/bin/lib/hud.cjs +1 -1
  27. package/pan-wizard-core/bin/lib/hygiene.cjs +428 -37
  28. package/pan-wizard-core/bin/lib/init.cjs +98 -13
  29. package/pan-wizard-core/bin/lib/knowledge.cjs +0 -1
  30. package/pan-wizard-core/bin/lib/memory.cjs +1 -1
  31. package/pan-wizard-core/bin/lib/milestone.cjs +3 -3
  32. package/pan-wizard-core/bin/lib/optimize.cjs +3 -3
  33. package/pan-wizard-core/bin/lib/phase.cjs +4 -4
  34. package/pan-wizard-core/bin/lib/planning-root.cjs +327 -0
  35. package/pan-wizard-core/bin/lib/preview.cjs +0 -1
  36. package/pan-wizard-core/bin/lib/review-deep.cjs +0 -1
  37. package/pan-wizard-core/bin/lib/roadmap.cjs +1 -1
  38. package/pan-wizard-core/bin/lib/state-compact.cjs +339 -0
  39. package/pan-wizard-core/bin/lib/state.cjs +0 -1
  40. package/pan-wizard-core/bin/lib/template.cjs +1 -1
  41. package/pan-wizard-core/bin/lib/utils.cjs +39 -11
  42. package/pan-wizard-core/bin/lib/verify.cjs +26 -5
  43. package/pan-wizard-core/bin/lib/whatif.cjs +0 -1
  44. package/pan-wizard-core/bin/pan-tools.cjs +58 -4
  45. package/pan-wizard-core/mcp/server.cjs +92 -8
  46. package/pan-wizard-core/mcp/tool-registry.cjs +50 -3
  47. package/pan-wizard-core/references/model-profiles.md +2 -2
  48. package/pan-wizard-core/workflows/health.md +1 -0
  49. package/pan-wizard-core/workflows/milestone-audit.md +35 -6
  50. package/pan-zcode/README.md +1 -1
  51. package/scripts/build-agent-plugin.js +220 -0
  52. package/scripts/build-plugin.js +48 -3
  53. package/scripts/generate-skills-docs.py +1 -1
  54. package/scripts/release-check.js +58 -12
@@ -24,18 +24,27 @@
24
24
 
25
25
  const fs = require('fs');
26
26
  const path = require('path');
27
- const { output, safeReadFile, toPosix } = require('./core.cjs');
27
+ const { output, safeReadFile, toPosix, buildCachedContext } = require('./core.cjs');
28
28
  const {
29
- PLANNING_DIR,
30
29
  HYGIENE_TRACE_RETENTION_DAYS,
31
30
  HYGIENE_TRACE_KEEP_MIN,
32
31
  HYGIENE_LEDGER_SUSPECT_RATIO,
32
+ HYGIENE_LEDGER_SUSPECT_MASS_RATIO,
33
+ HYGIENE_REPORT_KEEP_MIN,
34
+ CACHE_BLOCK_WARN_TOKENS,
35
+ CACHE_BLOCK_CRIT_TOKENS,
36
+ CACHE_FILE_WARN_TOKENS,
33
37
  HYGIENE_LEDGER_MIN_RECORDS,
34
38
  HYGIENE_TMP_AGE_MS,
39
+ CHARS_PER_TOKEN,
40
+ STATE_FILE,
35
41
  } = require('./constants.cjs');
36
- const { planningPath } = require('./utils.cjs');
42
+ const { planningPath, planningRel } = require('./utils.cjs');
43
+ const { detectForeignPlanningTree } = require('./foreign-planning.cjs');
37
44
  const { listMemoryAgents, readMemory, compactMemory } = require('./memory.cjs');
38
45
  const { readRecords, isSuspectRecord, METRICS_DIR, TOKENS_FILE } = require('./cost.cjs');
46
+ const { assessCacheTtl } = require('./context-budget.cjs');
47
+ const { planningRootRel, planningRoots, withPlanningRoot, describePlanningRoot, TRACKS_DIR } = require('./planning-root.cjs');
39
48
 
40
49
  /** Runtime config dirs a PAN install can live in, relative to project root. */
41
50
  const RUNTIME_DIRS = [
@@ -48,6 +57,14 @@ const RUNTIME_DIRS = [
48
57
 
49
58
  const MANIFEST_NAME = 'pan-file-manifest.json';
50
59
 
60
+ /**
61
+ * Per-transcript read cursor written beside the ledger by hooks/pan-cost-logger.js.
62
+ * Mirrored here by name because hooks are standalone and export nothing importable
63
+ * into the core; quarantining a ledger without clearing this leaves the fresh
64
+ * ledger inheriting the old one's read position.
65
+ */
66
+ const COST_CURSOR_FILE = '.cost-cursor.json';
67
+
51
68
  /** Pre-v2.2 uppercase planning filenames whose canonical form is lowercase. */
52
69
  const LEGACY_UPPERCASE_FILES = [
53
70
  'STATE.md', 'ROADMAP.md', 'PROJECT.md', 'REQUIREMENTS.md',
@@ -79,7 +96,9 @@ function ownVersion() {
79
96
  }
80
97
 
81
98
  function mkFinding(check, severity, relPath, detail, fix) {
82
- return { check, severity, path: toPosix(relPath), detail, fix: fix || null, fixable: !!fix };
99
+ // `track` is stamped by scanHygiene once it knows which tree produced the
100
+ // finding; null means the project root tree (or a project-wide check).
101
+ return { check, severity, path: toPosix(relPath), detail, fix: fix || null, fixable: !!fix, track: null };
83
102
  }
84
103
 
85
104
  // ─── Checks ─────────────────────────────────────────────────────────────────
@@ -144,11 +163,11 @@ function checkLegacyUppercase(cwd) {
144
163
  // case-insensitive on Windows and would always be true here).
145
164
  const twin = entries.includes(lower);
146
165
  if (twin) {
147
- findings.push(mkFinding('legacy-filenames', 'warn', path.join(PLANNING_DIR, name),
166
+ findings.push(mkFinding('legacy-filenames', 'warn', planningRel(name),
148
167
  `legacy ${name} coexists with ${lower} — merge manually, auto-rename would clobber`,
149
168
  null));
150
169
  } else {
151
- findings.push(mkFinding('legacy-filenames', 'warn', path.join(PLANNING_DIR, name),
170
+ findings.push(mkFinding('legacy-filenames', 'warn', planningRel(name),
152
171
  `legacy uppercase filename — canonical form is ${lower}`,
153
172
  { action: 'rename-lowercase', from: name, to: lower }));
154
173
  }
@@ -156,9 +175,18 @@ function checkLegacyUppercase(cwd) {
156
175
  return { findings };
157
176
  }
158
177
 
159
- /** Bounded recursive walk of .planning collecting file paths. */
178
+ /**
179
+ * Bounded recursive walk of the ACTIVE planning tree, collecting file paths.
180
+ *
181
+ * Never descends into `<root>/tracks/`: those are sibling planning trees, each
182
+ * scanned in its own pass with its own root. Without this the root scan absorbs
183
+ * every track's files — attributing their `.tmp` orphans and docs to `.planning`
184
+ * — and `--all-tracks` counts them twice, once under the root and once under
185
+ * the track they actually belong to.
186
+ */
160
187
  function walkPlanning(cwd, maxDepth = 5) {
161
188
  const root = planningPath(cwd);
189
+ const tracksDir = path.join(root, TRACKS_DIR);
162
190
  const out = [];
163
191
  const walk = (dir, depth) => {
164
192
  if (depth > maxDepth) return;
@@ -166,8 +194,12 @@ function walkPlanning(cwd, maxDepth = 5) {
166
194
  try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
167
195
  for (const e of entries) {
168
196
  const abs = path.join(dir, e.name);
169
- if (e.isDirectory()) walk(abs, depth + 1);
170
- else out.push(abs);
197
+ if (e.isDirectory()) {
198
+ if (abs === tracksDir) continue;
199
+ walk(abs, depth + 1);
200
+ } else {
201
+ out.push(abs);
202
+ }
171
203
  }
172
204
  };
173
205
  walk(root, 0);
@@ -198,25 +230,63 @@ function checkMemoryLogs(cwd) {
198
230
  if (!mem || !Array.isArray(mem.entries)) continue;
199
231
  if (mem.entries.length <= MEMORY_ENTRY_CAP) continue;
200
232
  findings.push(mkFinding('memory-bloat', 'warn',
201
- path.join(PLANNING_DIR, 'memory', `${a.agent}.md`),
233
+ planningRel('memory', `${a.agent}.md`),
202
234
  `${mem.entries.length} entries exceeds cap ${MEMORY_ENTRY_CAP} — whole-file reads flood context`,
203
235
  { action: 'compact-memory', agent: a.agent }));
204
236
  }
205
237
  return { findings };
206
238
  }
207
239
 
208
- /** H-5: cost ledger dominated by physically implausible (pre-v3.12.4) records. */
240
+ /**
241
+ * Thousands separator that does not depend on the host locale. `toLocaleString()`
242
+ * emits a narrow no-break space in some locales and a comma in others, which
243
+ * makes finding text vary by machine and any test asserting on it flaky.
244
+ */
245
+ function fmtTokens(n) {
246
+ return String(Math.round(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
247
+ }
248
+
249
+ /** Token mass of a ledger record across all four axes. */
250
+ function recordMass(r) {
251
+ return (r.input_tokens || 0) + (r.output_tokens || 0)
252
+ + (r.cache_read_tokens || 0) + (r.cache_write_tokens || 0);
253
+ }
254
+
255
+ /**
256
+ * H-5: cost ledger dominated by physically implausible (pre-v3.12.4) records.
257
+ *
258
+ * Gated on token MASS as well as record count. A count-only gate passes a ledger
259
+ * whose few bad rows carry most of the tokens — field case: 24% of rows suspect
260
+ * (under the 50% count gate, so "clean") holding 90% of the token mass, which
261
+ * makes every aggregate read off that file wrong by an order of magnitude. Mass
262
+ * is what `aggregate` actually sums, so mass is what the gate has to watch.
263
+ */
209
264
  function checkCostLedger(cwd) {
210
265
  const findings = [];
211
266
  let records = [];
212
267
  try { records = readRecords(cwd) || []; } catch { return { findings }; }
213
268
  if (records.length < HYGIENE_LEDGER_MIN_RECORDS) return { findings };
214
- const suspect = records.filter(r => isSuspectRecord(r)).length;
269
+
270
+ const suspectRecords = records.filter(r => isSuspectRecord(r));
271
+ const suspect = suspectRecords.length;
215
272
  const ratio = suspect / records.length;
216
- if (ratio < HYGIENE_LEDGER_SUSPECT_RATIO) return { findings };
273
+
274
+ const totalMass = records.reduce((sum, r) => sum + recordMass(r), 0);
275
+ const suspectMass = suspectRecords.reduce((sum, r) => sum + recordMass(r), 0);
276
+ const massRatio = totalMass > 0 ? suspectMass / totalMass : 0;
277
+
278
+ const byCount = ratio >= HYGIENE_LEDGER_SUSPECT_RATIO;
279
+ const byMass = massRatio >= HYGIENE_LEDGER_SUSPECT_MASS_RATIO;
280
+ if (!byCount && !byMass) return { findings };
281
+
282
+ // Name whichever gate fired, so the remediation is not mistaken for a
283
+ // false positive when the record count looks healthy.
284
+ const basis = byCount && byMass ? 'record count and token mass'
285
+ : byCount ? 'record count'
286
+ : 'token mass';
217
287
  findings.push(mkFinding('poisoned-ledger', 'critical',
218
- path.join(PLANNING_DIR, METRICS_DIR, TOKENS_FILE),
219
- `${suspect}/${records.length} records are suspect (${Math.round(ratio * 100)}%) — pre-v3.12.4 oversum signature; aggregates quarantine them but the file is dead weight`,
288
+ planningRel(METRICS_DIR, TOKENS_FILE),
289
+ `${suspect}/${records.length} records suspect (${Math.round(ratio * 100)}% of rows, ${Math.round(massRatio * 100)}% of token mass) — pre-v3.12.4 oversum signature, tripped on ${basis}; aggregates quarantine them but the file is dead weight`,
220
290
  { action: 'quarantine-ledger' }));
221
291
  return { findings };
222
292
  }
@@ -248,6 +318,176 @@ function checkStaleTraces(cwd, opts, now = Date.now()) {
248
318
  return { findings };
249
319
  }
250
320
 
321
+ /**
322
+ * H-8: optimization reports past retention.
323
+ *
324
+ * `checkStaleTraces` pruned `optimization/traces/` but nothing ever pruned
325
+ * `optimization/reports/`, which is where the analysis JSON lands — in the field
326
+ * the single largest file in a planning tree was a 92 KB analysis report from a
327
+ * session whose trace had long since been pruned. Same retention, same
328
+ * keep-newest floor, so the two halves of one subsystem age together.
329
+ */
330
+ function checkStaleReports(cwd, opts, now = Date.now()) {
331
+ const findings = [];
332
+ const retentionDays = Number(opts?.traceAgeDays) || HYGIENE_TRACE_RETENTION_DAYS;
333
+ const reportsDir = planningPath(cwd, 'optimization', 'reports');
334
+ let entries = [];
335
+ try { entries = fs.readdirSync(reportsDir, { withFileTypes: true }); } catch { return { findings }; }
336
+
337
+ const reports = [];
338
+ for (const e of entries) {
339
+ if (!e.isFile()) continue;
340
+ const abs = path.join(reportsDir, e.name);
341
+ let stat;
342
+ try { stat = fs.statSync(abs); } catch { continue; }
343
+ reports.push({ name: e.name, abs, mtime: stat.mtimeMs, size: stat.size });
344
+ }
345
+ reports.sort((a, b) => b.mtime - a.mtime);
346
+
347
+ const cutoff = now - retentionDays * 24 * 3600 * 1000;
348
+ for (const r of reports.slice(HYGIENE_REPORT_KEEP_MIN)) {
349
+ if (r.mtime >= cutoff) continue;
350
+ findings.push(mkFinding('stale-reports', 'info',
351
+ path.relative(cwd, r.abs),
352
+ `optimization report older than ${retentionDays}d retention (and not among newest ${HYGIENE_REPORT_KEEP_MIN}) — ${(r.size / 1024).toFixed(1)} KB`,
353
+ { action: 'delete' }));
354
+ }
355
+ return { findings };
356
+ }
357
+
358
+ /**
359
+ * H-9: cached prompt context bloat.
360
+ *
361
+ * The files in CACHEABLE_CONTEXT_FILES are re-read into EVERY agent call, so
362
+ * their combined size is the dominant recurring cost of a PAN project — cache
363
+ * reads outweigh generated tokens by roughly two orders of magnitude. Nothing
364
+ * used to watch this: `context-budget` measured the block and reported it
365
+ * without any threshold, so a state.md that had grown to ~14k tokens of mostly
366
+ * closed history was re-read for months without a single warning.
367
+ *
368
+ * Report-only for the block as a whole; the per-file finding carries the
369
+ * `state compact` remediation because state.md is the file that actually grows.
370
+ */
371
+ /**
372
+ * Remove superseded quarantined ledgers, keeping only the one just written.
373
+ *
374
+ * Quarantine is deliberately non-destructive — the poisoned rows are evidence,
375
+ * not garbage — but keeping EVERY quarantine forever turns the cure into the
376
+ * disease. The newest is retained so the most recent evidence survives; older
377
+ * ones have already been superseded by it.
378
+ *
379
+ * @param {string} metricsDirAbs - directory holding the ledger
380
+ * @param {string} keepAbs - the quarantine file to preserve
381
+ * @returns {number} how many were removed
382
+ */
383
+ function pruneOldQuarantines(metricsDirAbs, keepAbs) {
384
+ let removed = 0;
385
+ let entries = [];
386
+ try { entries = fs.readdirSync(metricsDirAbs); } catch { return 0; }
387
+ const keepName = path.basename(keepAbs);
388
+ for (const name of entries) {
389
+ if (name === keepName) continue;
390
+ if (!name.startsWith(`${TOKENS_FILE}.quarantined-`)) continue;
391
+ try { fs.unlinkSync(path.join(metricsDirAbs, name)); removed++; } catch { /* leave it */ }
392
+ }
393
+ return removed;
394
+ }
395
+
396
+ /** How many markdown docs the planning tree holds — "is there anything to cache?". */
397
+ function planningDocCount(cwd) {
398
+ return walkPlanning(cwd).filter(p => p.endsWith('.md')).length;
399
+ }
400
+
401
+ function checkCachedContext(cwd) {
402
+ const findings = [];
403
+ let cached;
404
+ try { cached = buildCachedContext(cwd); } catch { return { findings }; }
405
+ if (!cached || !Array.isArray(cached.blocks)) return { findings };
406
+
407
+ // An empty block is not "small" — it means this project gets NO prompt
408
+ // caching at all, which is worth saying out loud rather than reporting as a
409
+ // healthy zero. But only for a tree that HAS planning content: a freshly
410
+ // scaffolded `.planning/phases/` has nothing to cache yet, and reporting that
411
+ // as a finding is noise on every new project. The signal is "you have
412
+ // planning docs and none of them are cached", not "you have no docs".
413
+ if (cached.blocks.length === 0) {
414
+ if (planningDocCount(cwd) > 0) {
415
+ findings.push(mkFinding('cache-context', 'info', planningRel(),
416
+ 'planning docs exist but none are cacheable — every agent call re-sends its context uncached. '
417
+ + 'Add project.md/standards.md, or list stable docs under config.json cache.extra_files',
418
+ null));
419
+ }
420
+ return { findings };
421
+ }
422
+
423
+ const blockTokens = Math.ceil(cached.total_bytes / CHARS_PER_TOKEN);
424
+ if (blockTokens >= CACHE_BLOCK_WARN_TOKENS) {
425
+ const severity = blockTokens >= CACHE_BLOCK_CRIT_TOKENS ? 'critical' : 'warn';
426
+ findings.push(mkFinding('cache-context', severity, planningRel(),
427
+ `cached context block is ~${fmtTokens(blockTokens)} tokens across ${cached.blocks.length} file(s) `
428
+ + `(warn ${fmtTokens(CACHE_BLOCK_WARN_TOKENS)}, critical ${fmtTokens(CACHE_BLOCK_CRIT_TOKENS)}) — `
429
+ + 're-read on every agent call, so this is the project\'s largest recurring cost',
430
+ null));
431
+ }
432
+
433
+ for (const b of cached.blocks) {
434
+ const tokens = Math.ceil((b.content || '').length / CHARS_PER_TOKEN);
435
+ if (tokens < CACHE_FILE_WARN_TOKENS) continue;
436
+ const isState = String(b.path).endsWith(STATE_FILE);
437
+
438
+ // A finding may only advertise `auto-fixable` when running the fix would
439
+ // actually change something. state.md stays over the threshold once its
440
+ // settled history has already been archived — the remaining bulk is LIVE
441
+ // content, and no amount of re-running `clean` will shrink it. Claiming
442
+ // otherwise makes `clean --apply` report a permanent `failed: 1` and the
443
+ // project never converges to clean.
444
+ let fix = null;
445
+ let suffix = '';
446
+ if (isState) {
447
+ suffix = ' — state.md section writers only append; closed history is still being re-read';
448
+ const archivable = stateCompactionAvailable(cwd);
449
+ if (archivable > 0) {
450
+ fix = { action: 'compact-state' };
451
+ } else {
452
+ suffix = ' — already compacted; the remaining bulk is LIVE content, so trim it by hand'
453
+ + ' (or widen the window with `state compact --keep-days N`)';
454
+ }
455
+ }
456
+
457
+ findings.push(mkFinding('cache-context', 'warn', b.path,
458
+ `~${fmtTokens(tokens)} tokens re-read on every agent call (warn ${fmtTokens(CACHE_FILE_WARN_TOKENS)})${suffix}`,
459
+ fix));
460
+ }
461
+
462
+ // Lifetime signal (ADR-0046 D5): the ledger shows cache WRITES that followed
463
+ // an idle gap of five to sixty minutes — misses a one-hour subagent cache
464
+ // lifetime would have turned into hits. Informational and never fixable: the
465
+ // remedy is a Claude Code setting the user weighs against the 2× write price.
466
+ try {
467
+ const ttl = assessCacheTtl(readRecords(cwd).filter(r => !isSuspectRecord(r)));
468
+ if (ttl.recommend) {
469
+ findings.push(mkFinding('cache-context', 'info', planningRel(path.join(METRICS_DIR, TOKENS_FILE)), ttl.advice, null));
470
+ }
471
+ } catch { /* no ledger, or unreadable — nothing to say */ }
472
+ return { findings };
473
+ }
474
+
475
+ /**
476
+ * How many state.md sections `state compact` could archive right now.
477
+ *
478
+ * Consulted before offering the `compact-state` fix so hygiene never advertises
479
+ * a remedy that would no-op. Returns 0 on any failure — an unavailable planner
480
+ * must make the finding manual, never falsely fixable.
481
+ */
482
+ function stateCompactionAvailable(cwd) {
483
+ try {
484
+ const { planStateCompaction } = require('./state-compact.cjs');
485
+ return planStateCompaction(cwd).archivable.length;
486
+ } catch {
487
+ return 0;
488
+ }
489
+ }
490
+
251
491
  /** H-7: fragment .planning — artifacts present but no project spine. Report-only. */
252
492
  function checkPlanningFragment(cwd) {
253
493
  const findings = [];
@@ -263,7 +503,7 @@ function checkPlanningFragment(cwd) {
263
503
  'milestones', 'focus', 'quick', 'orchestration'];
264
504
  const hasSpine = SPINE.some(s => lower.includes(s));
265
505
  if (!hasSpine && entries.length > 0) {
266
- findings.push(mkFinding('planning-fragment', 'info', PLANNING_DIR,
506
+ findings.push(mkFinding('planning-fragment', 'info', planningRootRel(),
267
507
  `.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`,
268
508
  null));
269
509
  }
@@ -279,29 +519,93 @@ function checkPlanningFragment(cwd) {
279
519
  * @param {Object} [opts] - {traceAgeDays}
280
520
  * @returns {Object} {findings, installs, latest_version, planning_exists, summary}
281
521
  */
522
+ /**
523
+ * Run every planning-tree check against one root, tagging each finding with
524
+ * the track it came from.
525
+ *
526
+ * @param {string} cwd - project root
527
+ * @param {{name: string|null, rel: string}} root - the tree to scan
528
+ * @param {Object} [opts]
529
+ * @returns {{findings: Array, planning_exists: boolean}}
530
+ */
531
+ function scanOneRoot(cwd, root, opts) {
532
+ return withPlanningRoot(root.rel, () => {
533
+ // A .planning/ written by ANOTHER tool (gsd-core shares the directory name and
534
+ // PAN's pre-v2.2 uppercase file names) must never be "repaired": the legacy
535
+ // rename would rename its state files. One warn finding, nothing fixable, and
536
+ // none of the per-tree checks run on it. Reality check R15.
537
+ const foreign = detectForeignPlanningTree(planningPath(cwd));
538
+ if (foreign) {
539
+ const f = mkFinding('foreign-planning-tree', 'warn', planningRel(),
540
+ `planning tree belongs to ${foreign.tool} (${foreign.evidence.join(', ')}) — PAN will not rename or repair its files; run PAN with --planning-dir to give it a tree of its own (ADR-0043)`,
541
+ null);
542
+ f.track = root.name;
543
+ return { findings: [f], planning_exists: true };
544
+ }
545
+ const fragment = checkPlanningFragment(cwd);
546
+ const findings = [
547
+ ...fragment.findings,
548
+ ...checkLegacyUppercase(cwd).findings,
549
+ ...checkTmpOrphans(cwd).findings,
550
+ ...checkMemoryLogs(cwd).findings,
551
+ ...checkCostLedger(cwd).findings,
552
+ ...checkStaleTraces(cwd, opts).findings,
553
+ ...checkStaleReports(cwd, opts).findings,
554
+ ...checkCachedContext(cwd).findings,
555
+ ];
556
+ for (const f of findings) f.track = root.name;
557
+ return { findings, planning_exists: fragment.planning_exists !== false };
558
+ }, root.name);
559
+ }
560
+
282
561
  function scanHygiene(cwd, opts) {
562
+ // Version alignment is a property of the PROJECT (which runtimes are
563
+ // installed, at what version), not of any planning tree — run it once no
564
+ // matter how many trees we sweep, or a four-track repo reports the same
565
+ // drift four times.
283
566
  const version = checkVersionAlignment(cwd);
284
- const fragment = checkPlanningFragment(cwd);
285
- const findings = [
286
- ...version.findings,
287
- ...fragment.findings,
288
- ...checkLegacyUppercase(cwd).findings,
289
- ...checkTmpOrphans(cwd).findings,
290
- ...checkMemoryLogs(cwd).findings,
291
- ...checkCostLedger(cwd).findings,
292
- ...checkStaleTraces(cwd, opts).findings,
293
- ];
567
+ const roots = planningRoots(cwd, { allTracks: !!opts?.allTracks });
568
+
569
+ const findings = [...version.findings];
570
+ const scanned = [];
571
+ let planningExists = false;
572
+
573
+ for (const root of roots) {
574
+ const result = scanOneRoot(cwd, root, opts);
575
+ if (result.planning_exists) planningExists = true;
576
+ scanned.push({
577
+ track: root.name,
578
+ planning_root: root.rel,
579
+ planning_exists: result.planning_exists,
580
+ findings: result.findings.length,
581
+ });
582
+ findings.push(...result.findings);
583
+ }
584
+
294
585
  const byCheck = {};
295
586
  for (const f of findings) byCheck[f.check] = (byCheck[f.check] || 0) + 1;
587
+ const byTrack = {};
588
+ for (const f of findings) {
589
+ const key = f.track || '(root)';
590
+ byTrack[key] = (byTrack[key] || 0) + 1;
591
+ }
592
+
296
593
  return {
297
594
  findings,
298
595
  installs: version.installs,
299
596
  latest_version: version.latest_version,
300
- planning_exists: fragment.planning_exists !== false,
597
+ planning_exists: planningExists,
598
+ // What was actually looked at. Present in every scan, not just --all-tracks:
599
+ // a scan that reports "clean" must always say which tree it read, so a
600
+ // wrong target is visible instead of passing for a clean bill of health.
601
+ ...describePlanningRoot(cwd),
602
+ all_tracks: !!opts?.allTracks,
603
+ roots_scanned: scanned,
301
604
  summary: {
302
605
  total: findings.length,
303
606
  fixable: findings.filter(f => f.fixable).length,
304
607
  by_check: byCheck,
608
+ by_track: byTrack,
305
609
  by_severity: findings.reduce((m, f) => { m[f.severity] = (m[f.severity] || 0) + 1; return m; }, {}),
306
610
  },
307
611
  };
@@ -315,6 +619,12 @@ function applyFix(cwd, finding) {
315
619
  try {
316
620
  switch (fix.action) {
317
621
  case 'rename-lowercase': {
622
+ // Defence in depth for R15: the scan never emits this fix for a foreign tree,
623
+ // but a stale findings list or a hand-built one must not rename another
624
+ // tool's files either.
625
+ if (detectForeignPlanningTree(path.dirname(abs))) {
626
+ return { applied: false, detail: 'refused: this planning tree belongs to another tool (see the foreign-planning-tree finding)' };
627
+ }
318
628
  // Two-step rename: Windows treats case-only renames inconsistently
319
629
  // across fs layers, so hop through a temp name.
320
630
  const dir = path.dirname(abs);
@@ -334,11 +644,44 @@ function applyFix(cwd, finding) {
334
644
  if (r.error) return { applied: false, detail: r.error };
335
645
  return { applied: true, detail: `compacted to ${r.kept ?? r.entries ?? 'cap'} entries` };
336
646
  }
647
+ case 'compact-state': {
648
+ // Required lazily: state-compact pulls in state.cjs, which pulls in core
649
+ // — importing it at module load would put hygiene on that cycle.
650
+ const { compactState } = require('./state-compact.cjs');
651
+ const r = compactState(cwd, { apply: true });
652
+ if (!r.found) return { applied: false, detail: 'state.md not found' };
653
+ if (!r.applied) return { applied: false, detail: 'nothing past the retention window' };
654
+ return {
655
+ applied: true,
656
+ detail: `archived ${r.archived.length} section(s) to ${r.history_path} — saves ~${r.tokens_saved_per_call} tokens per agent call`,
657
+ };
658
+ }
337
659
  case 'quarantine-ledger': {
338
660
  const stamp = new Date().toISOString().slice(0, 10);
339
661
  const dest = `${abs}.quarantined-${stamp}`;
340
662
  fs.renameSync(abs, dest);
341
- return { applied: true, detail: `renamed to ${path.basename(dest)} — fresh ledger starts clean` };
663
+
664
+ // The cursor is a per-transcript high-water mark INTO the ledger we just
665
+ // moved aside. Left behind it points at rows that are no longer there,
666
+ // so the fresh ledger starts mid-stream and the next slice is undercounted.
667
+ // A "fresh ledger" that inherits the old ledger's read position is not fresh.
668
+ let cursorNote = '';
669
+ try {
670
+ const cursor = path.join(path.dirname(abs), COST_CURSOR_FILE);
671
+ fs.unlinkSync(cursor);
672
+ cursorNote = ', cursor reset';
673
+ } catch { /* no cursor to reset */ }
674
+
675
+ // Quarantine leaves a dated copy behind, and nothing else ever removes
676
+ // one. Run hygiene a few times over a year and the metrics dir fills
677
+ // with dead ledgers — the very bloat this command exists to remove.
678
+ const pruned = pruneOldQuarantines(path.dirname(abs), dest);
679
+ const prunedNote = pruned > 0 ? `, ${pruned} older quarantine(s) removed` : '';
680
+
681
+ return {
682
+ applied: true,
683
+ detail: `renamed to ${path.basename(dest)}${cursorNote}${prunedNote} — fresh ledger starts clean`,
684
+ };
342
685
  }
343
686
  default:
344
687
  return { applied: false, detail: `unknown fix action ${fix.action}` };
@@ -360,22 +703,40 @@ function cleanHygiene(cwd, opts) {
360
703
  const apply = !!opts?.apply;
361
704
  const applied = [];
362
705
  const skipped = [];
706
+
707
+ // Which tree each finding came from. Most fixes act on finding.path, which is
708
+ // already track-correct — but compact-memory delegates to memory.cjs, which
709
+ // resolves the root itself. Without this map a track's bloated memory log
710
+ // would be "fixed" by compacting the root tree's log instead.
711
+ const rootByTrack = new Map();
712
+ for (const r of scan.roots_scanned || []) rootByTrack.set(r.track, r.planning_root);
713
+
363
714
  for (const f of scan.findings) {
364
715
  if (!f.fixable) {
365
- skipped.push({ check: f.check, path: f.path, reason: 'no safe auto-fix — see detail', detail: f.detail });
716
+ skipped.push({ check: f.check, path: f.path, track: f.track, reason: 'no safe auto-fix — see detail', detail: f.detail });
366
717
  continue;
367
718
  }
368
719
  if (!apply) {
369
- applied.push({ check: f.check, path: f.path, action: f.fix.action, applied: false, detail: 'dry-run' });
720
+ applied.push({ check: f.check, path: f.path, track: f.track, action: f.fix.action, applied: false, detail: 'dry-run' });
370
721
  continue;
371
722
  }
372
- const result = applyFix(cwd, f);
373
- applied.push({ check: f.check, path: f.path, action: f.fix.action, ...result });
723
+ const rootRel = rootByTrack.get(f.track);
724
+ const result = rootRel
725
+ ? withPlanningRoot(rootRel, () => applyFix(cwd, f))
726
+ : applyFix(cwd, f);
727
+ applied.push({ check: f.check, path: f.path, track: f.track, action: f.fix.action, ...result });
374
728
  }
729
+
375
730
  return {
376
731
  dry_run: !apply,
377
732
  applied,
378
733
  skipped,
734
+ planning_root: scan.planning_root,
735
+ track: scan.track,
736
+ planning_root_source: scan.planning_root_source,
737
+ planning_root_exists: scan.planning_root_exists,
738
+ all_tracks: scan.all_tracks,
739
+ roots_scanned: scan.roots_scanned,
379
740
  summary: {
380
741
  fixable: applied.length,
381
742
  executed: applied.filter(a => a.applied).length,
@@ -390,21 +751,47 @@ function cleanHygiene(cwd, opts) {
390
751
  function renderFindings(findings) {
391
752
  const lines = [];
392
753
  for (const f of findings) {
393
- lines.push(` [${f.severity.toUpperCase().padEnd(8)}] ${f.check.padEnd(18)} ${f.path}`);
754
+ const where = f.track ? `[${f.track}] ` : '';
755
+ lines.push(` [${f.severity.toUpperCase().padEnd(8)}] ${f.check.padEnd(18)} ${where}${f.path}`);
394
756
  lines.push(` ${f.detail}${f.fixable ? ' (auto-fixable)' : ''}`);
395
757
  }
396
758
  return lines;
397
759
  }
398
760
 
761
+ /**
762
+ * One line naming exactly which tree(s) were read.
763
+ *
764
+ * Printed on every scan, including clean ones. "Clean" is only meaningful
765
+ * alongside "…and here is what I looked at" — the original defect was a scan
766
+ * reporting no findings because it had read the wrong directory, which is
767
+ * indistinguishable from a healthy project unless the target is stated.
768
+ */
769
+ function renderScope(result) {
770
+ if (result.all_tracks) {
771
+ const names = (result.roots_scanned || []).map(r => r.track || '(root)');
772
+ return `Scanned ${names.length} planning tree(s): ${names.join(', ')}`;
773
+ }
774
+ const via = result.planning_root_source && result.planning_root_source !== 'default'
775
+ ? ` (via ${result.planning_root_source})` : '';
776
+ const missing = result.planning_root_exists === false ? ' — DIRECTORY NOT FOUND' : '';
777
+ return `Scanned planning root: ${result.planning_root}${via}${missing}`;
778
+ }
779
+
399
780
  function cmdHygieneScan(cwd, opts, raw) {
400
781
  const result = scanHygiene(cwd, opts);
401
782
  if (raw) {
402
783
  const lines = [`Hygiene scan: ${result.summary.total} finding(s), ${result.summary.fixable} auto-fixable`];
784
+ lines.push(renderScope(result));
403
785
  if (result.latest_version) {
404
786
  lines.push(`Latest version seen: ${result.latest_version}; installs: ${result.installs.map(i => `${i.runtime}@${i.version || '?'}`).join(', ') || 'none'}`);
405
787
  }
406
788
  lines.push('', ...renderFindings(result.findings));
407
- if (result.findings.length === 0) lines.push(' Clean — nothing to do.');
789
+ if (result.findings.length === 0) {
790
+ lines.push(' Clean — nothing to do.');
791
+ if (!result.all_tracks) {
792
+ lines.push(' (one tree only — pass --all-tracks to include .planning/tracks/*)');
793
+ }
794
+ }
408
795
  output(result, true, lines.join('\n'));
409
796
  } else {
410
797
  output(result, false);
@@ -415,12 +802,14 @@ function cmdHygieneClean(cwd, opts, raw) {
415
802
  const result = cleanHygiene(cwd, opts);
416
803
  if (raw) {
417
804
  const mode = result.dry_run ? 'DRY-RUN (pass --apply to execute)' : 'APPLIED';
418
- const lines = [`Hygiene clean — ${mode}`, ''];
805
+ const lines = [`Hygiene clean — ${mode}`, renderScope(result), ''];
419
806
  for (const a of result.applied) {
420
- lines.push(` ${a.applied ? '✓' : (result.dry_run ? '·' : '✗')} ${a.action.padEnd(18)} ${a.path} ${a.detail}`);
807
+ const where = a.track ? `[${a.track}] ` : '';
808
+ lines.push(` ${a.applied ? '✓' : (result.dry_run ? '·' : '✗')} ${a.action.padEnd(18)} ${where}${a.path} ${a.detail}`);
421
809
  }
422
810
  for (const s of result.skipped) {
423
- lines.push(` ! manual ${s.path} ${s.detail}`);
811
+ const where = s.track ? `[${s.track}] ` : '';
812
+ lines.push(` ! manual ${where}${s.path} ${s.detail}`);
424
813
  }
425
814
  lines.push('', `fixable: ${result.summary.fixable}, executed: ${result.summary.executed}, failed: ${result.summary.failed}, manual: ${result.summary.manual}`);
426
815
  output(result, true, lines.join('\n'));
@@ -438,6 +827,8 @@ module.exports = {
438
827
  checkMemoryLogs,
439
828
  checkCostLedger,
440
829
  checkStaleTraces,
830
+ checkStaleReports,
831
+ checkCachedContext,
441
832
  checkPlanningFragment,
442
833
  compareVersions,
443
834
  cmdHygieneScan,