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