pan-wizard 3.12.3 → 3.13.1

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 (46) hide show
  1. package/README.md +2 -1
  2. package/agents/pan-debugger.md +2 -2
  3. package/agents/pan-hardener.md +5 -2
  4. package/agents/pan-meta-reviewer.md +2 -1
  5. package/agents/pan-planner.md +16 -0
  6. package/agents/pan-reviewer.md +2 -1
  7. package/bin/install-lib.cjs +8 -0
  8. package/bin/install.js +3 -2
  9. package/commands/pan/audit-deployment.md +8 -8
  10. package/commands/pan/focus-auto.md +10 -6
  11. package/commands/pan/hygiene.md +69 -0
  12. package/commands/pan/milestone-done.md +3 -2
  13. package/hooks/dist/pan-cost-logger.js +54 -6
  14. package/hooks/dist/pan-trace-logger.js +41 -5
  15. package/package.json +1 -1
  16. package/pan-wizard-core/bin/lib/constants.cjs +40 -0
  17. package/pan-wizard-core/bin/lib/cost.cjs +26 -1
  18. package/pan-wizard-core/bin/lib/hud.cjs +14 -2
  19. package/pan-wizard-core/bin/lib/hygiene.cjs +447 -0
  20. package/pan-wizard-core/bin/lib/knowledge.cjs +28 -12
  21. package/pan-wizard-core/bin/lib/learn-index.cjs +17 -0
  22. package/pan-wizard-core/bin/lib/memory.cjs +146 -3
  23. package/pan-wizard-core/bin/lib/skill-align.cjs +364 -0
  24. package/pan-wizard-core/bin/lib/verify.cjs +10 -0
  25. package/pan-wizard-core/bin/pan-tools.cjs +47 -1
  26. package/pan-wizard-core/learnings/index.json +262 -10
  27. package/pan-wizard-core/learnings/internal/external-research.md +13 -1
  28. package/pan-wizard-core/learnings/universal/adversarial-verification.md +45 -0
  29. package/pan-wizard-core/learnings/universal/audit-convergence.md +33 -0
  30. package/pan-wizard-core/learnings/universal/autonomous-loop.md +4 -4
  31. package/pan-wizard-core/learnings/universal/external-tool-truth.md +21 -0
  32. package/pan-wizard-core/learnings/universal/fix-campaigns.md +45 -0
  33. package/pan-wizard-core/learnings/universal/flaky-triage.md +33 -0
  34. package/pan-wizard-core/learnings/universal/golden-sets.md +33 -0
  35. package/pan-wizard-core/learnings/universal/harness-isolation.md +21 -0
  36. package/pan-wizard-core/learnings/universal/integration-verification.md +33 -0
  37. package/pan-wizard-core/learnings/universal/live-path-honesty.md +45 -0
  38. package/pan-wizard-core/learnings/universal/mcp-security.md +21 -0
  39. package/pan-wizard-core/learnings/universal/migration-safety.md +21 -0
  40. package/pan-wizard-core/learnings/universal/service-security.md +21 -0
  41. package/pan-wizard-core/learnings/universal/single-source-of-truth.md +33 -0
  42. package/pan-wizard-core/learnings/universal/test-integrity.md +21 -0
  43. package/pan-wizard-core/learnings/universal/workaround-catalog.md +21 -0
  44. package/pan-wizard-core/references/model-profiles.md +23 -1
  45. package/pan-wizard-core/workflows/exec-phase.md +12 -3
  46. package/pan-wizard-core/workflows/plan-phase.md +1 -0
@@ -186,6 +186,25 @@ function readRecords(cwd) {
186
186
  * @param {Object} [opts] - {since, until, group_by}
187
187
  * @returns {Object} Aggregation
188
188
  */
189
+ /**
190
+ * A record is "suspect" when its token counts are physically implausible for a
191
+ * single subagent — the signature of the pre-v3.12.4 transcript-oversum bug
192
+ * (billions of cache-read, cache-read dwarfing input, 100% cache-hit). Such
193
+ * records are quarantined from aggregates so a poisoned ledger can't report
194
+ * millions of dollars. See docs/FIELD-REPORT-army-2026-06.md.
195
+ * @param {Object} r - a cost record
196
+ * @returns {boolean}
197
+ */
198
+ function isSuspectRecord(r) {
199
+ if (!r || typeof r !== 'object') return false;
200
+ const cr = r.cache_read_tokens || 0;
201
+ const io = (r.input_tokens || 0) + (r.output_tokens || 0);
202
+ if (cr > 5e8) return true; // no scoped subagent re-reads >500M cached tokens
203
+ if (cr > 1e7 && cr > 100 * (io + 1)) return true; // cache-read dwarfs input+output
204
+ if ((r.output_tokens || 0) > 1e7) return true; // ~10M output = cumulative oversum
205
+ return false;
206
+ }
207
+
189
208
  function aggregate(cwd, opts) {
190
209
  const records = readRecords(cwd);
191
210
  const since = opts?.since ? new Date(opts.since).getTime() : null;
@@ -202,13 +221,14 @@ function aggregate(cwd, opts) {
202
221
  });
203
222
 
204
223
  const totals = {
205
- calls: filtered.length,
224
+ calls: 0,
206
225
  input_tokens: 0,
207
226
  output_tokens: 0,
208
227
  cache_read_tokens: 0,
209
228
  cache_write_tokens: 0,
210
229
  cost_usd: 0,
211
230
  cost_unknown: 0,
231
+ suspect_excluded: 0,
212
232
  };
213
233
 
214
234
  const byAgent = {};
@@ -229,6 +249,10 @@ function aggregate(cwd, opts) {
229
249
  }
230
250
 
231
251
  for (const r of filtered) {
252
+ // Quarantine physically-impossible records (pre-v3.12.4 transcript-oversum
253
+ // bug) so a poisoned ledger doesn't poison the totals / HUD / /pan:cost.
254
+ if (isSuspectRecord(r)) { totals.suspect_excluded += 1; continue; }
255
+ totals.calls += 1;
232
256
  totals.input_tokens += r.input_tokens || 0;
233
257
  totals.output_tokens += r.output_tokens || 0;
234
258
  totals.cache_read_tokens += r.cache_read_tokens || 0;
@@ -398,6 +422,7 @@ module.exports = {
398
422
  appendRecord,
399
423
  readRecords,
400
424
  aggregate,
425
+ isSuspectRecord,
401
426
  renderTable,
402
427
  renderChart,
403
428
  resolveRate,
@@ -267,7 +267,7 @@ function collectHudData(cwd, opts = {}) {
267
267
  return {
268
268
  generated_at: now.toISOString(),
269
269
  army_active: armyActive,
270
- project: { name, version, milestone: milestone ? { version: milestone.version, name: milestone.name } : null, core_value: coreValue },
270
+ project: { name, version, dir_name: path.basename(cwd.replace(/[\\/]+$/, '')), milestone: milestone ? { version: milestone.version, name: milestone.name } : null, core_value: coreValue },
271
271
  state,
272
272
  progress: phaseScan,
273
273
  army,
@@ -425,7 +425,7 @@ function renderMission(d) {
425
425
  <div class="mhead">
426
426
  <div>
427
427
  <div class="kicker">pan army · mission control</div>
428
- <div class="title">${esc(p.name || 'Untitled project')}</div>
428
+ <div class="title">${esc(p.name || p.dir_name || 'Untitled project')}</div>
429
429
  ${p.core_value ? `<div class="sub">${esc(p.core_value)}</div>` : ''}
430
430
  </div>
431
431
  <div class="mmeta">
@@ -586,6 +586,17 @@ function renderHarness(d) {
586
586
 
587
587
  function renderTelemetry(d) {
588
588
  const t = d.telemetry;
589
+ // A ledger where most records are implausible is the pre-v3.12.4 capture bug —
590
+ // don't present salvaged numbers as if trustworthy; tell the user to reset it.
591
+ if (t.totals.suspect_excluded > t.totals.calls) {
592
+ const total = t.totals.suspect_excluded + t.totals.calls;
593
+ return `
594
+ <section class="panel">
595
+ <div class="ph">telemetry</div>
596
+ <div class="row noborder"><span class="rl">Status</span>${pill('legacy ledger — unreliable', 'warn')}</div>
597
+ <div class="amono dim" style="margin-top:10px;line-height:1.6;">${t.totals.suspect_excluded} of ${total} cost records are implausible (the pre-v3.12.4 telemetry capture bug). Reset the ledger with <b>pan-tools cost clear</b> — records captured after the fix are accurate.</div>
598
+ </section>`;
599
+ }
589
600
  const keys = Object.keys(t.by_squad).sort((a, b) => t.by_squad[b].cost - t.by_squad[a].cost);
590
601
  const max = keys.reduce((m, k) => Math.max(m, t.by_squad[k].cost), 0) || 1;
591
602
  const bars = keys.length
@@ -602,6 +613,7 @@ function renderTelemetry(d) {
602
613
  <div class="row"><span class="rl">Total spend</span><span class="amono">${fmtUsd(t.totals.cost_usd)}</span></div>
603
614
  <div class="row"><span class="rl">Tokens</span><span class="amono">${fmtTokens(t.totals.input_tokens + t.totals.output_tokens)}</span></div>
604
615
  <div class="row"><span class="rl">Cache hit</span><span class="amono okc">${t.cache_hit_rate_pct == null ? 'n/a' : t.cache_hit_rate_pct + '%'}</span></div>
616
+ ${t.totals.suspect_excluded ? `<div class="row"><span class="rl">Excluded</span>${pill(t.totals.suspect_excluded + ' implausible records', 'warn')}</div>` : ''}
605
617
  <div class="sqbars">${bars}</div>
606
618
  </section>`;
607
619
  }
@@ -0,0 +1,447 @@
1
+ /**
2
+ * Hygiene — project cleanup + version alignment (ADR: docs/FIELD-HARVEST-2026-07.md follow-ups).
3
+ *
4
+ * A PAN-managed project accumulates drift as PAN versions advance and
5
+ * campaigns run: runtime installs fall behind the latest version, legacy
6
+ * uppercase planning filenames linger from pre-v2.2 layouts, atomic-write
7
+ * .tmp orphans survive crashes, per-agent memory logs grow past the cap,
8
+ * cost ledgers written by pre-v3.12.4 hooks are 100% poisoned, telemetry
9
+ * trace sessions pile up unboundedly, and stray fragment `.planning/`
10
+ * directories appear where a mapping step once ran.
11
+ *
12
+ * Two commands, one module:
13
+ * - scan — detect all of the above, report findings (read-only)
14
+ * - clean — apply the SAFE fixes (case renames, tmp removal, memory
15
+ * compaction, ledger quarantine-by-rename, trace pruning);
16
+ * dry-run by default, `--apply` to execute. Version drift and
17
+ * fragment dirs are never auto-fixed — they get remediation
18
+ * text instead (installer re-run / manual delete).
19
+ *
20
+ * Nothing here deletes user content: the poisoned ledger is renamed in
21
+ * place (quarantined-<date> suffix), and only derived/ephemeral artifacts
22
+ * (.tmp orphans, aged trace sessions) are removed outright.
23
+ */
24
+
25
+ const fs = require('fs');
26
+ const path = require('path');
27
+ const { output, safeReadFile, toPosix } = require('./core.cjs');
28
+ const {
29
+ PLANNING_DIR,
30
+ HYGIENE_TRACE_RETENTION_DAYS,
31
+ HYGIENE_TRACE_KEEP_MIN,
32
+ HYGIENE_LEDGER_SUSPECT_RATIO,
33
+ HYGIENE_LEDGER_MIN_RECORDS,
34
+ HYGIENE_TMP_AGE_MS,
35
+ } = require('./constants.cjs');
36
+ const { planningPath } = require('./utils.cjs');
37
+ const { listMemoryAgents, readMemory, compactMemory } = require('./memory.cjs');
38
+ const { readRecords, isSuspectRecord, METRICS_DIR, TOKENS_FILE } = require('./cost.cjs');
39
+
40
+ /** Runtime config dirs a PAN install can live in, relative to project root. */
41
+ const RUNTIME_DIRS = [
42
+ { runtime: 'claude', dir: '.claude' },
43
+ { runtime: 'codex', dir: '.codex' },
44
+ { runtime: 'gemini', dir: '.gemini' },
45
+ { runtime: 'opencode', dir: '.opencode' },
46
+ { runtime: 'copilot', dir: '.github' },
47
+ ];
48
+
49
+ const MANIFEST_NAME = 'pan-file-manifest.json';
50
+
51
+ /** Pre-v2.2 uppercase planning filenames whose canonical form is lowercase. */
52
+ const LEGACY_UPPERCASE_FILES = [
53
+ 'STATE.md', 'ROADMAP.md', 'PROJECT.md', 'REQUIREMENTS.md',
54
+ 'MILESTONES.md', 'STANDARDS.md', 'PAUSE.md',
55
+ ];
56
+
57
+ const MEMORY_ENTRY_CAP = (() => {
58
+ try { return require('./memory.cjs').DEFAULT_MAX_ENTRIES || 500; } catch { return 500; }
59
+ })();
60
+
61
+ // ─── Small helpers ──────────────────────────────────────────────────────────
62
+
63
+ /** Compare dotted versions; returns -1/0/1. Tolerates missing segments. */
64
+ function compareVersions(a, b) {
65
+ const pa = String(a || '0').split('.').map(n => parseInt(n, 10) || 0);
66
+ const pb = String(b || '0').split('.').map(n => parseInt(n, 10) || 0);
67
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
68
+ const d = (pa[i] || 0) - (pb[i] || 0);
69
+ if (d !== 0) return d < 0 ? -1 : 1;
70
+ }
71
+ return 0;
72
+ }
73
+
74
+ /** Version of the pan-wizard-core copy executing this code (install or source root). */
75
+ function ownVersion() {
76
+ const pkg = safeReadFile(path.resolve(__dirname, '..', '..', '..', 'package.json'));
77
+ if (!pkg) return null;
78
+ try { return JSON.parse(pkg).version || null; } catch { return null; }
79
+ }
80
+
81
+ function mkFinding(check, severity, relPath, detail, fix) {
82
+ return { check, severity, path: toPosix(relPath), detail, fix: fix || null, fixable: !!fix };
83
+ }
84
+
85
+ // ─── Checks ─────────────────────────────────────────────────────────────────
86
+
87
+ /**
88
+ * H-1: version alignment across runtime installs. Report-only — the fix is
89
+ * re-running the installer, which hygiene must not do on its own.
90
+ */
91
+ function checkVersionAlignment(cwd) {
92
+ const findings = [];
93
+ const installs = [];
94
+ for (const { runtime, dir } of RUNTIME_DIRS) {
95
+ const manifestPath = path.join(cwd, dir, MANIFEST_NAME);
96
+ const raw = safeReadFile(manifestPath);
97
+ if (raw === null) {
98
+ // A pan-wizard-core copy without a manifest is an untracked install.
99
+ let hasCore = false;
100
+ try { fs.accessSync(path.join(cwd, dir, 'pan-wizard-core')); hasCore = true; } catch { /* absent */ }
101
+ if (hasCore) {
102
+ findings.push(mkFinding('version-alignment', 'warn', path.join(dir),
103
+ `${runtime}: pan-wizard-core present but no ${MANIFEST_NAME} — untracked install`,
104
+ null));
105
+ }
106
+ continue;
107
+ }
108
+ let version = null;
109
+ try { version = JSON.parse(raw).version || null; } catch { /* malformed */ }
110
+ installs.push({ runtime, dir, version });
111
+ }
112
+
113
+ const own = ownVersion();
114
+ const latest = [own, ...installs.map(i => i.version)]
115
+ .filter(Boolean)
116
+ .sort(compareVersions)
117
+ .pop() || null;
118
+
119
+ for (const i of installs) {
120
+ if (i.version && latest && compareVersions(i.version, latest) < 0) {
121
+ findings.push(mkFinding('version-alignment', 'warn', i.dir,
122
+ `${i.runtime}: installed ${i.version}, latest ${latest} — re-run the installer to align`,
123
+ null));
124
+ }
125
+ if (!i.version) {
126
+ findings.push(mkFinding('version-alignment', 'warn', i.dir,
127
+ `${i.runtime}: manifest has no version field — re-run the installer`,
128
+ null));
129
+ }
130
+ }
131
+ return { findings, installs, latest_version: latest };
132
+ }
133
+
134
+ /** H-2: legacy uppercase planning filenames (pre-v2.2 layout). */
135
+ function checkLegacyUppercase(cwd) {
136
+ const findings = [];
137
+ const dir = planningPath(cwd);
138
+ let entries = [];
139
+ try { entries = fs.readdirSync(dir); } catch { return { findings }; }
140
+ for (const name of entries) {
141
+ if (!LEGACY_UPPERCASE_FILES.includes(name)) continue;
142
+ const lower = name.toLowerCase();
143
+ // Case-sensitive twin check via literal directory listing (existsSync is
144
+ // case-insensitive on Windows and would always be true here).
145
+ const twin = entries.includes(lower);
146
+ if (twin) {
147
+ findings.push(mkFinding('legacy-filenames', 'warn', path.join(PLANNING_DIR, name),
148
+ `legacy ${name} coexists with ${lower} — merge manually, auto-rename would clobber`,
149
+ null));
150
+ } else {
151
+ findings.push(mkFinding('legacy-filenames', 'warn', path.join(PLANNING_DIR, name),
152
+ `legacy uppercase filename — canonical form is ${lower}`,
153
+ { action: 'rename-lowercase', from: name, to: lower }));
154
+ }
155
+ }
156
+ return { findings };
157
+ }
158
+
159
+ /** Bounded recursive walk of .planning collecting file paths. */
160
+ function walkPlanning(cwd, maxDepth = 5) {
161
+ const root = planningPath(cwd);
162
+ const out = [];
163
+ const walk = (dir, depth) => {
164
+ if (depth > maxDepth) return;
165
+ let entries = [];
166
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
167
+ for (const e of entries) {
168
+ const abs = path.join(dir, e.name);
169
+ if (e.isDirectory()) walk(abs, depth + 1);
170
+ else out.push(abs);
171
+ }
172
+ };
173
+ walk(root, 0);
174
+ return out;
175
+ }
176
+
177
+ /** H-3: orphaned atomic-write .tmp files older than the age threshold. */
178
+ function checkTmpOrphans(cwd, now = Date.now()) {
179
+ const findings = [];
180
+ for (const abs of walkPlanning(cwd)) {
181
+ if (!abs.endsWith('.tmp')) continue;
182
+ let stat;
183
+ try { stat = fs.statSync(abs); } catch { continue; }
184
+ if (now - stat.mtimeMs < HYGIENE_TMP_AGE_MS) continue;
185
+ findings.push(mkFinding('tmp-orphans', 'info', path.relative(cwd, abs),
186
+ `orphaned atomic-write temp file (age ${Math.round((now - stat.mtimeMs) / 3600000)}h)`,
187
+ { action: 'delete' }));
188
+ }
189
+ return { findings };
190
+ }
191
+
192
+ /** H-4: per-agent memory logs past the entry cap (compaction never ran). */
193
+ function checkMemoryLogs(cwd) {
194
+ const findings = [];
195
+ const { agents } = listMemoryAgents(cwd);
196
+ for (const a of agents) {
197
+ const mem = readMemory(cwd, a.agent);
198
+ if (!mem || !Array.isArray(mem.entries)) continue;
199
+ if (mem.entries.length <= MEMORY_ENTRY_CAP) continue;
200
+ findings.push(mkFinding('memory-bloat', 'warn',
201
+ path.join(PLANNING_DIR, 'memory', `${a.agent}.md`),
202
+ `${mem.entries.length} entries exceeds cap ${MEMORY_ENTRY_CAP} — whole-file reads flood context`,
203
+ { action: 'compact-memory', agent: a.agent }));
204
+ }
205
+ return { findings };
206
+ }
207
+
208
+ /** H-5: cost ledger dominated by physically implausible (pre-v3.12.4) records. */
209
+ function checkCostLedger(cwd) {
210
+ const findings = [];
211
+ let records = [];
212
+ try { records = readRecords(cwd) || []; } catch { return { findings }; }
213
+ if (records.length < HYGIENE_LEDGER_MIN_RECORDS) return { findings };
214
+ const suspect = records.filter(r => isSuspectRecord(r)).length;
215
+ const ratio = suspect / records.length;
216
+ if (ratio < HYGIENE_LEDGER_SUSPECT_RATIO) return { findings };
217
+ 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`,
220
+ { action: 'quarantine-ledger' }));
221
+ return { findings };
222
+ }
223
+
224
+ /** H-6: telemetry trace sessions beyond retention (always keep the newest few). */
225
+ function checkStaleTraces(cwd, opts, now = Date.now()) {
226
+ const findings = [];
227
+ const retentionDays = Number(opts?.traceAgeDays) || HYGIENE_TRACE_RETENTION_DAYS;
228
+ const tracesDir = path.join(planningPath(cwd), 'optimization', 'traces');
229
+ let entries = [];
230
+ try { entries = fs.readdirSync(tracesDir, { withFileTypes: true }); } catch { return { findings }; }
231
+ const sessions = [];
232
+ for (const e of entries) {
233
+ if (!e.isDirectory()) continue;
234
+ const abs = path.join(tracesDir, e.name);
235
+ let stat;
236
+ try { stat = fs.statSync(abs); } catch { continue; }
237
+ sessions.push({ name: e.name, abs, mtime: stat.mtimeMs });
238
+ }
239
+ sessions.sort((a, b) => b.mtime - a.mtime);
240
+ const cutoff = now - retentionDays * 24 * 3600 * 1000;
241
+ for (const s of sessions.slice(HYGIENE_TRACE_KEEP_MIN)) {
242
+ if (s.mtime >= cutoff) continue;
243
+ findings.push(mkFinding('stale-traces', 'info',
244
+ path.relative(cwd, s.abs),
245
+ `trace session older than ${retentionDays}d retention (and not among newest ${HYGIENE_TRACE_KEEP_MIN})`,
246
+ { action: 'delete-dir' }));
247
+ }
248
+ return { findings };
249
+ }
250
+
251
+ /** H-7: fragment .planning — artifacts present but no project spine. Report-only. */
252
+ function checkPlanningFragment(cwd) {
253
+ const findings = [];
254
+ const dir = planningPath(cwd);
255
+ let entries = [];
256
+ try { entries = fs.readdirSync(dir); } catch { return { findings, planning_exists: false }; }
257
+ const lower = entries.map(e => e.toLowerCase());
258
+ // Spine = anything that marks a deliberate PAN workflow: the phase model
259
+ // (project/state/phases/roadmap/requirements/milestones) OR the focus model
260
+ // (focus/quick) OR an orchestration campaign. A dir holding only generated
261
+ // artifacts (codebase maps, metrics, traces) is a stray fragment.
262
+ const SPINE = ['project.md', 'state.md', 'phases', 'roadmap.md', 'requirements.md',
263
+ 'milestones', 'focus', 'quick', 'orchestration'];
264
+ const hasSpine = SPINE.some(s => lower.includes(s));
265
+ if (!hasSpine && entries.length > 0) {
266
+ findings.push(mkFinding('planning-fragment', 'info', PLANNING_DIR,
267
+ `.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
+ null));
269
+ }
270
+ return { findings, planning_exists: true };
271
+ }
272
+
273
+ // ─── Scan ───────────────────────────────────────────────────────────────────
274
+
275
+ /**
276
+ * Run all hygiene checks. Read-only.
277
+ *
278
+ * @param {string} cwd - project root
279
+ * @param {Object} [opts] - {traceAgeDays}
280
+ * @returns {Object} {findings, installs, latest_version, planning_exists, summary}
281
+ */
282
+ function scanHygiene(cwd, opts) {
283
+ 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
+ ];
294
+ const byCheck = {};
295
+ for (const f of findings) byCheck[f.check] = (byCheck[f.check] || 0) + 1;
296
+ return {
297
+ findings,
298
+ installs: version.installs,
299
+ latest_version: version.latest_version,
300
+ planning_exists: fragment.planning_exists !== false,
301
+ summary: {
302
+ total: findings.length,
303
+ fixable: findings.filter(f => f.fixable).length,
304
+ by_check: byCheck,
305
+ by_severity: findings.reduce((m, f) => { m[f.severity] = (m[f.severity] || 0) + 1; return m; }, {}),
306
+ },
307
+ };
308
+ }
309
+
310
+ // ─── Clean ──────────────────────────────────────────────────────────────────
311
+
312
+ function applyFix(cwd, finding) {
313
+ const fix = finding.fix;
314
+ const abs = path.join(cwd, finding.path);
315
+ try {
316
+ switch (fix.action) {
317
+ case 'rename-lowercase': {
318
+ // Two-step rename: Windows treats case-only renames inconsistently
319
+ // across fs layers, so hop through a temp name.
320
+ const dir = path.dirname(abs);
321
+ const hop = path.join(dir, `${fix.to}.case-hop`);
322
+ fs.renameSync(abs, hop);
323
+ fs.renameSync(hop, path.join(dir, fix.to));
324
+ return { applied: true, detail: `renamed ${fix.from} -> ${fix.to}` };
325
+ }
326
+ case 'delete':
327
+ fs.unlinkSync(abs);
328
+ return { applied: true, detail: 'deleted' };
329
+ case 'delete-dir':
330
+ fs.rmSync(abs, { recursive: true, force: true });
331
+ return { applied: true, detail: 'removed directory' };
332
+ case 'compact-memory': {
333
+ const r = compactMemory(cwd, fix.agent);
334
+ if (r.error) return { applied: false, detail: r.error };
335
+ return { applied: true, detail: `compacted to ${r.kept ?? r.entries ?? 'cap'} entries` };
336
+ }
337
+ case 'quarantine-ledger': {
338
+ const stamp = new Date().toISOString().slice(0, 10);
339
+ const dest = `${abs}.quarantined-${stamp}`;
340
+ fs.renameSync(abs, dest);
341
+ return { applied: true, detail: `renamed to ${path.basename(dest)} — fresh ledger starts clean` };
342
+ }
343
+ default:
344
+ return { applied: false, detail: `unknown fix action ${fix.action}` };
345
+ }
346
+ } catch (e) {
347
+ return { applied: false, detail: `fix failed: ${e.message}` };
348
+ }
349
+ }
350
+
351
+ /**
352
+ * Apply safe fixes for fixable findings. Dry-run unless opts.apply.
353
+ *
354
+ * @param {string} cwd
355
+ * @param {Object} [opts] - {apply, traceAgeDays}
356
+ * @returns {Object} {dry_run, applied, skipped, remaining, summary}
357
+ */
358
+ function cleanHygiene(cwd, opts) {
359
+ const scan = scanHygiene(cwd, opts);
360
+ const apply = !!opts?.apply;
361
+ const applied = [];
362
+ const skipped = [];
363
+ for (const f of scan.findings) {
364
+ if (!f.fixable) {
365
+ skipped.push({ check: f.check, path: f.path, reason: 'no safe auto-fix — see detail', detail: f.detail });
366
+ continue;
367
+ }
368
+ if (!apply) {
369
+ applied.push({ check: f.check, path: f.path, action: f.fix.action, applied: false, detail: 'dry-run' });
370
+ continue;
371
+ }
372
+ const result = applyFix(cwd, f);
373
+ applied.push({ check: f.check, path: f.path, action: f.fix.action, ...result });
374
+ }
375
+ return {
376
+ dry_run: !apply,
377
+ applied,
378
+ skipped,
379
+ summary: {
380
+ fixable: applied.length,
381
+ executed: applied.filter(a => a.applied).length,
382
+ failed: apply ? applied.filter(a => !a.applied).length : 0,
383
+ manual: skipped.length,
384
+ },
385
+ };
386
+ }
387
+
388
+ // ─── CLI wrappers ───────────────────────────────────────────────────────────
389
+
390
+ function renderFindings(findings) {
391
+ const lines = [];
392
+ for (const f of findings) {
393
+ lines.push(` [${f.severity.toUpperCase().padEnd(8)}] ${f.check.padEnd(18)} ${f.path}`);
394
+ lines.push(` ${f.detail}${f.fixable ? ' (auto-fixable)' : ''}`);
395
+ }
396
+ return lines;
397
+ }
398
+
399
+ function cmdHygieneScan(cwd, opts, raw) {
400
+ const result = scanHygiene(cwd, opts);
401
+ if (raw) {
402
+ const lines = [`Hygiene scan: ${result.summary.total} finding(s), ${result.summary.fixable} auto-fixable`];
403
+ if (result.latest_version) {
404
+ lines.push(`Latest version seen: ${result.latest_version}; installs: ${result.installs.map(i => `${i.runtime}@${i.version || '?'}`).join(', ') || 'none'}`);
405
+ }
406
+ lines.push('', ...renderFindings(result.findings));
407
+ if (result.findings.length === 0) lines.push(' Clean — nothing to do.');
408
+ output(result, true, lines.join('\n'));
409
+ } else {
410
+ output(result, false);
411
+ }
412
+ }
413
+
414
+ function cmdHygieneClean(cwd, opts, raw) {
415
+ const result = cleanHygiene(cwd, opts);
416
+ if (raw) {
417
+ const mode = result.dry_run ? 'DRY-RUN (pass --apply to execute)' : 'APPLIED';
418
+ const lines = [`Hygiene clean — ${mode}`, ''];
419
+ for (const a of result.applied) {
420
+ lines.push(` ${a.applied ? '✓' : (result.dry_run ? '·' : '✗')} ${a.action.padEnd(18)} ${a.path} ${a.detail}`);
421
+ }
422
+ for (const s of result.skipped) {
423
+ lines.push(` ! manual ${s.path} ${s.detail}`);
424
+ }
425
+ lines.push('', `fixable: ${result.summary.fixable}, executed: ${result.summary.executed}, failed: ${result.summary.failed}, manual: ${result.summary.manual}`);
426
+ output(result, true, lines.join('\n'));
427
+ } else {
428
+ output(result, false);
429
+ }
430
+ }
431
+
432
+ module.exports = {
433
+ scanHygiene,
434
+ cleanHygiene,
435
+ checkVersionAlignment,
436
+ checkLegacyUppercase,
437
+ checkTmpOrphans,
438
+ checkMemoryLogs,
439
+ checkCostLedger,
440
+ checkStaleTraces,
441
+ checkPlanningFragment,
442
+ compareVersions,
443
+ cmdHygieneScan,
444
+ cmdHygieneClean,
445
+ RUNTIME_DIRS,
446
+ LEGACY_UPPERCASE_FILES,
447
+ };
@@ -66,15 +66,20 @@ function scoreRelevance(question, content) {
66
66
  * Walk a path (file or directory, 1 level deep for .md files) and return
67
67
  * {file, score} entries.
68
68
  */
69
- function gatherCandidates(cwd, question) {
69
+ function gatherCandidates(cwd, question, recallCue) {
70
70
  const candidates = [];
71
+ const mk = (rel, content) => ({
72
+ file: toPosix(rel),
73
+ score: scoreRelevance(question, content),
74
+ recall_score: recallCue ? scoreRelevance(recallCue, content) : 0,
75
+ bytes: Buffer.byteLength(content || '', 'utf-8'),
76
+ });
71
77
  for (const rel of CITATION_ROOTS) {
72
78
  const abs = path.join(cwd, rel);
73
79
  let stat;
74
80
  try { stat = fs.statSync(abs); } catch { continue; }
75
81
  if (stat.isFile()) {
76
- const content = safeReadFile(abs);
77
- candidates.push({ file: toPosix(rel), score: scoreRelevance(question, content), bytes: Buffer.byteLength(content || '', 'utf-8') });
82
+ candidates.push(mk(rel, safeReadFile(abs)));
78
83
  } else if (stat.isDirectory()) {
79
84
  let entries = [];
80
85
  try { entries = fs.readdirSync(abs); } catch { continue; }
@@ -83,18 +88,14 @@ function gatherCandidates(cwd, question) {
83
88
  let entryStat;
84
89
  try { entryStat = fs.statSync(entryAbs); } catch { continue; }
85
90
  if (entryStat.isFile() && entry.endsWith('.md')) {
86
- const entryRel = toPosix(path.join(rel, entry));
87
- const content = safeReadFile(entryAbs);
88
- candidates.push({ file: entryRel, score: scoreRelevance(question, content), bytes: Buffer.byteLength(content || '', 'utf-8') });
91
+ candidates.push(mk(path.join(rel, entry), safeReadFile(entryAbs)));
89
92
  } else if (entryStat.isDirectory()) {
90
93
  // One more level for phases/<NN>/ and milestones/
91
94
  let sub = [];
92
95
  try { sub = fs.readdirSync(entryAbs); } catch { continue; }
93
96
  for (const s of sub) {
94
97
  if (!s.endsWith('.md')) continue;
95
- const subRel = toPosix(path.join(rel, entry, s));
96
- const content = safeReadFile(path.join(entryAbs, s));
97
- candidates.push({ file: subRel, score: scoreRelevance(question, content), bytes: Buffer.byteLength(content || '', 'utf-8') });
98
+ candidates.push(mk(path.join(rel, entry, s), safeReadFile(path.join(entryAbs, s))));
98
99
  }
99
100
  }
100
101
  }
@@ -116,17 +117,32 @@ function ask(cwd, question, opts) {
116
117
  return { error: 'question must be a non-empty string' };
117
118
  }
118
119
  const max = Math.max(1, Math.min(100, Number(opts?.max_sources) || DEFAULT_MAX_SOURCES));
119
- const all = gatherCandidates(cwd, question);
120
+ const recallCue = opts && typeof opts.recall_cue === 'string' && opts.recall_cue.trim()
121
+ ? opts.recall_cue.trim() : null;
122
+ const all = gatherCandidates(cwd, question, recallCue);
120
123
  const ranked = all
121
124
  .filter(c => c.score > 0 || c.file.endsWith('project.md') || c.file.endsWith('requirements.md'))
122
125
  .sort((a, b) => b.score - a.score || a.file.localeCompare(b.file))
123
126
  .slice(0, max);
124
- return {
127
+ const result = {
125
128
  question,
126
- sources: ranked,
129
+ // Strip the internal recall_score so `sources` keeps its {file, score, bytes} shape.
130
+ sources: ranked.map(({ recall_score, ...rest }) => rest),
127
131
  total_candidates: all.length,
128
132
  returned: ranked.length,
129
133
  };
134
+ if (recallCue) {
135
+ // FW-1 (minimal, ADR-0036): re-score the SAME already-gathered candidates
136
+ // against a follow-up cue and return a tighter recall slice — no second
137
+ // filesystem walk, no new dependency, still distill-and-select.
138
+ result.recall_cue = recallCue;
139
+ result.recall_sources = all
140
+ .filter(c => c.recall_score > 0)
141
+ .sort((a, b) => b.recall_score - a.recall_score || a.file.localeCompare(b.file))
142
+ .slice(0, max)
143
+ .map(c => ({ file: c.file, recall_score: c.recall_score, bytes: c.bytes }));
144
+ }
145
+ return result;
130
146
  }
131
147
 
132
148
  // ─── Mode: discuss — session state for multi-turn conversations ────────────
@@ -69,6 +69,23 @@ const RELEVANCE = {
69
69
  'test-patterns': { planner: 'medium', executor: 'high', verifier: 'high', reviewer: 'high' },
70
70
  'test-strategy': { planner: 'high', executor: 'medium', verifier: 'high', reviewer: 'high' },
71
71
 
72
+ // Field-harvest 2026-07 (docs/FIELD-HARVEST-2026-07.md)
73
+ 'live-path-honesty': { planner: 'medium', executor: 'high', verifier: 'high', reviewer: 'high' },
74
+ 'test-integrity': { planner: 'medium', executor: 'high', verifier: 'high', reviewer: 'high' },
75
+ 'adversarial-verification': { planner: 'medium', executor: 'low', verifier: 'high', reviewer: 'high' },
76
+ 'integration-verification': { planner: 'high', executor: 'low', verifier: 'high', reviewer: 'medium' },
77
+ 'single-source-of-truth': { planner: 'high', executor: 'high', verifier: 'medium', reviewer: 'high' },
78
+ 'migration-safety': { planner: 'high', executor: 'high', verifier: 'medium', reviewer: 'high' },
79
+ 'flaky-triage': { planner: 'low', executor: 'medium', verifier: 'high', reviewer: 'medium' },
80
+ 'external-tool-truth': { planner: 'low', executor: 'high', verifier: 'high', reviewer: 'low' },
81
+ 'golden-sets': { planner: 'high', executor: 'medium', verifier: 'high', reviewer: 'medium' },
82
+ 'harness-isolation': { planner: 'high', executor: 'medium', verifier: 'low', reviewer: 'low' },
83
+ 'workaround-catalog': { planner: 'low', executor: 'high', verifier: 'medium', reviewer: 'low' },
84
+ 'service-security': { planner: 'medium', executor: 'high', verifier: 'high', reviewer: 'high' },
85
+ 'mcp-security': { planner: 'medium', executor: 'medium', verifier: 'medium', reviewer: 'high' },
86
+ 'audit-convergence': { planner: 'high', executor: 'low', verifier: 'high', reviewer: 'high' },
87
+ 'fix-campaigns': { planner: 'high', executor: 'medium', verifier: 'high', reviewer: 'high' },
88
+
72
89
  // PAN-internal (loaded only by PAN's own dev sessions)
73
90
  'experiment-runner': { planner: 'low', executor: 'low', verifier: 'low', reviewer: 'low' },
74
91
  'external-research': { planner: 'medium', executor: 'low', verifier: 'low', reviewer: 'medium' },