pan-wizard 3.12.5 → 3.14.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 (53) hide show
  1. package/README.md +5 -5
  2. package/agents/pan-hardener.md +1 -0
  3. package/agents/pan-meta-reviewer.md +1 -0
  4. package/agents/pan-planner.md +16 -0
  5. package/agents/pan-reviewer.md +1 -0
  6. package/bin/install-lib.cjs +8 -0
  7. package/commands/pan/audit-deployment.md +8 -8
  8. package/commands/pan/focus-auto.md +10 -6
  9. package/commands/pan/hygiene.md +69 -0
  10. package/commands/pan/milestone-done.md +3 -2
  11. package/hooks/dist/pan-context-monitor.js +24 -12
  12. package/hooks/dist/pan-statusline.js +7 -1
  13. package/package.json +5 -5
  14. package/pan-wizard-core/bin/lib/config.cjs +14 -1
  15. package/pan-wizard-core/bin/lib/constants.cjs +40 -0
  16. package/pan-wizard-core/bin/lib/core.cjs +6 -2
  17. package/pan-wizard-core/bin/lib/doc-lint.cjs +86 -1
  18. package/pan-wizard-core/bin/lib/focus.cjs +48 -2
  19. package/pan-wizard-core/bin/lib/hud.cjs +19 -3
  20. package/pan-wizard-core/bin/lib/hygiene.cjs +447 -0
  21. package/pan-wizard-core/bin/lib/knowledge.cjs +30 -14
  22. package/pan-wizard-core/bin/lib/learn-index.cjs +17 -0
  23. package/pan-wizard-core/bin/lib/memory.cjs +146 -3
  24. package/pan-wizard-core/bin/lib/optimize.cjs +2 -2
  25. package/pan-wizard-core/bin/lib/phase-remove.cjs +1 -1
  26. package/pan-wizard-core/bin/lib/phase.cjs +4 -4
  27. package/pan-wizard-core/bin/lib/review-deep.cjs +3 -1
  28. package/pan-wizard-core/bin/lib/skill-align.cjs +364 -0
  29. package/pan-wizard-core/bin/lib/verify.cjs +182 -61
  30. package/pan-wizard-core/bin/pan-tools.cjs +57 -1
  31. package/pan-wizard-core/learnings/index.json +262 -10
  32. package/pan-wizard-core/learnings/internal/external-research.md +13 -1
  33. package/pan-wizard-core/learnings/universal/adversarial-verification.md +45 -0
  34. package/pan-wizard-core/learnings/universal/audit-convergence.md +33 -0
  35. package/pan-wizard-core/learnings/universal/autonomous-loop.md +4 -4
  36. package/pan-wizard-core/learnings/universal/external-tool-truth.md +21 -0
  37. package/pan-wizard-core/learnings/universal/fix-campaigns.md +45 -0
  38. package/pan-wizard-core/learnings/universal/flaky-triage.md +33 -0
  39. package/pan-wizard-core/learnings/universal/golden-sets.md +33 -0
  40. package/pan-wizard-core/learnings/universal/harness-isolation.md +21 -0
  41. package/pan-wizard-core/learnings/universal/integration-verification.md +33 -0
  42. package/pan-wizard-core/learnings/universal/live-path-honesty.md +45 -0
  43. package/pan-wizard-core/learnings/universal/mcp-security.md +21 -0
  44. package/pan-wizard-core/learnings/universal/migration-safety.md +21 -0
  45. package/pan-wizard-core/learnings/universal/service-security.md +21 -0
  46. package/pan-wizard-core/learnings/universal/single-source-of-truth.md +33 -0
  47. package/pan-wizard-core/learnings/universal/test-integrity.md +21 -0
  48. package/pan-wizard-core/learnings/universal/workaround-catalog.md +21 -0
  49. package/pan-wizard-core/references/model-profiles.md +4 -1
  50. package/pan-wizard-core/workflows/exec-phase.md +26 -3
  51. package/pan-wizard-core/workflows/plan-phase.md +1 -0
  52. package/scripts/release-check.js +29 -14
  53. package/scripts/run-tests.cjs +44 -0
@@ -8,7 +8,7 @@
8
8
 
9
9
  const fs = require('fs');
10
10
  const path = require('path');
11
- const { output, error, safeReadFile, loadConfig, scanPendingTodos, scanSourceTodos, toPosix, isGitRepo, execGit } = require('./core.cjs');
11
+ const { output, error, safeReadFile, loadConfig, scanPendingTodos, scanSourceTodos, toPosix, isGitRepo, execGit, escapeRegex } = require('./core.cjs');
12
12
  const {
13
13
  PLANNING_DIR, PHASES_DIR, ROADMAP_FILE, PATTERNS_FILE, EFFORT_POINTS, PRIORITY_LEVELS, EFFORT_SIZES,
14
14
  FOCUS_MODES, FOCUS_TIERS, FOCUS_DIR,
@@ -457,7 +457,7 @@ function checkDocStaleness(cwd, opts) {
457
457
  function checkOldCommandNames(content, file, stale) {
458
458
  for (const [oldName, newName] of Object.entries(COMMAND_RENAME_MAP)) {
459
459
  // Match /pan:old-name or pan:old-name (command references)
460
- const pattern = new RegExp(`pan:${oldName.replace(/-/g, '\\-')}\\b`);
460
+ const pattern = new RegExp(`pan:${escapeRegex(oldName)}\\b`);
461
461
  if (pattern.test(content)) {
462
462
  stale.push({ file, entity: 'renamed_command', old: oldName, new: newName });
463
463
  }
@@ -717,6 +717,45 @@ function focusAutoStop(cwd, raw) {
717
717
  }, raw);
718
718
  }
719
719
 
720
+ // Anti-fake (ADR-0036 review): the regression breaker compares tests_after vs
721
+ // tests_before, both historically agent-supplied CLI args that focus-auto never
722
+ // re-ran — so a fabricated "tests green" passed unchecked. When verification is
723
+ // enabled (config.focus.verify_tests), re-run the node:test suite and use the
724
+ // REAL pass count; otherwise keep the supplied value but mark the cycle
725
+ // tests_verified:false so the loop's trust is visible to the HUD/audit/human.
726
+ //
727
+ // Invocation is a fixed execFile('node', ['--test']) — NO shell and NO
728
+ // configurable command string, so there is no command-injection surface (same
729
+ // safe pattern verify.cjs uses). If no node:test suite is present, fall back to
730
+ // the supplied value rather than mis-firing the regression breaker.
731
+ function verifyTestCount(cwd, config, fallback) {
732
+ const focusCfg = (config && config.focus) || {};
733
+ if (!focusCfg.verify_tests) return { tests_after: fallback, verified: false };
734
+ const { execFileSync } = require('child_process');
735
+ const num = (out, re) => { const m = String(out || '').match(re); return m ? Number(m[1]) : null; };
736
+ const interpret = (out, exitCode) => {
737
+ // node --test summary lines use either the TAP marker ("# tests N") or the
738
+ // spec reporter's info marker ("ℹ tests N"), depending on Node version /
739
+ // reporter — match both so the count is captured regardless.
740
+ const total = num(out, /[#ℹ]\s*tests\s+(\d+)/);
741
+ if (!total) return { tests_after: fallback, verified: false }; // no node:test suite here
742
+ const passed = num(out, /[#ℹ]\s*pass\s+(\d+)/);
743
+ return { tests_after: passed !== null ? passed : (exitCode === 0 ? fallback : 0), verified: true, exit_code: exitCode };
744
+ };
745
+ // Strip any NODE_TEST_* context so the child runs as a standalone runner with
746
+ // predictable spec/TAP output — otherwise, when focus-auto is itself invoked
747
+ // from within a `node --test` process, the child inherits the nested-test
748
+ // context and emits a serialized format we can't parse.
749
+ const env = { ...process.env };
750
+ for (const k of Object.keys(env)) { if (k.startsWith('NODE_TEST')) delete env[k]; }
751
+ try {
752
+ const out = execFileSync('node', ['--test'], { cwd, timeout: 120000, stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8', env });
753
+ return interpret(out, 0);
754
+ } catch (err) {
755
+ return interpret(err && err.stdout, (err && err.status) || 1);
756
+ }
757
+ }
758
+
720
759
  function focusAutoUpdate(cwd, raw, getVal) {
721
760
  const run = readAutoRun(cwd);
722
761
  if (!run) return error('No auto-run in progress. Cannot update.');
@@ -735,6 +774,12 @@ function focusAutoUpdate(cwd, raw, getVal) {
735
774
  timestamp: new Date().toISOString(),
736
775
  };
737
776
 
777
+ // Anti-fake: re-run the suite when verification is enabled; else record that
778
+ // this count was self-reported (tests_verified:false) so the trust is visible.
779
+ const tv = verifyTestCount(cwd, loadConfig(cwd), cycle.tests_after);
780
+ cycle.tests_after = tv.tests_after;
781
+ cycle.tests_verified = tv.verified;
782
+
738
783
  if (!run.cycles) run.cycles = [];
739
784
  run.cycles.push(cycle);
740
785
 
@@ -1016,6 +1061,7 @@ module.exports = {
1016
1061
  writeAutoRun,
1017
1062
  cmdFocusAuto,
1018
1063
  determineStopReason,
1064
+ verifyTestCount,
1019
1065
  // Opus 4.7
1020
1066
  determineContinuation,
1021
1067
  classifyStageDependencies,
@@ -822,13 +822,29 @@ ${body}
822
822
 
823
823
  function openInBrowser(filePath) {
824
824
  const { execFileSync } = require('child_process');
825
+ // Only open a path we can resolve to an existing regular file, and refuse
826
+ // anything carrying shell/cmd metacharacters — on Windows `start` is a cmd
827
+ // builtin that re-parses its command line, so a crafted --out value must not
828
+ // be able to reach it. The allowlist check is the taint barrier; `resolved`
829
+ // is what actually gets opened.
830
+ let resolved;
831
+ try {
832
+ resolved = path.resolve(filePath);
833
+ if (!fs.statSync(resolved).isFile()) return false;
834
+ } catch {
835
+ return false;
836
+ }
837
+ // Allowlist barrier: only ordinary path characters may reach the opener.
838
+ // Anything outside this set (shell/cmd metacharacters, quotes, newlines) is
839
+ // rejected outright, so a crafted --out value cannot reach Windows `start`.
840
+ if (!/^[A-Za-z0-9 _.:\\/()-]+$/.test(resolved)) return false;
825
841
  try {
826
842
  if (process.platform === 'win32') {
827
- execFileSync('cmd', ['/c', 'start', '', filePath], { stdio: 'ignore' });
843
+ execFileSync('cmd', ['/c', 'start', '', resolved], { stdio: 'ignore' });
828
844
  } else if (process.platform === 'darwin') {
829
- execFileSync('open', [filePath], { stdio: 'ignore' });
845
+ execFileSync('open', [resolved], { stdio: 'ignore' });
830
846
  } else {
831
- execFileSync('xdg-open', [filePath], { stdio: 'ignore' });
847
+ execFileSync('xdg-open', [resolved], { stdio: 'ignore' });
832
848
  }
833
849
  return true;
834
850
  } catch {
@@ -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
+ };
@@ -14,7 +14,7 @@
14
14
 
15
15
  const fs = require('fs');
16
16
  const path = require('path');
17
- const { output, error, safeReadFile, toPosix } = require('./core.cjs');
17
+ const { output, error, safeReadFile, toPosix, escapeRegex } = require('./core.cjs');
18
18
  const { PLANNING_DIR } = require('./constants.cjs');
19
19
  const { planningPath } = require('./utils.cjs');
20
20
  const { listMemoryAgents, readMemory } = require('./memory.cjs');
@@ -56,7 +56,7 @@ function scoreRelevance(question, content) {
56
56
  const body = content.toLowerCase();
57
57
  let score = 0;
58
58
  for (const w of words) {
59
- const count = (body.match(new RegExp(`\\b${w.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}\\b`, 'g')) || []).length;
59
+ const count = (body.match(new RegExp(`\\b${escapeRegex(w)}\\b`, 'g')) || []).length;
60
60
  score += count;
61
61
  }
62
62
  return score;
@@ -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 ────────────