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.
- package/README.md +5 -5
- package/agents/pan-hardener.md +1 -0
- package/agents/pan-meta-reviewer.md +1 -0
- package/agents/pan-planner.md +16 -0
- package/agents/pan-reviewer.md +1 -0
- package/bin/install-lib.cjs +8 -0
- package/commands/pan/audit-deployment.md +8 -8
- package/commands/pan/focus-auto.md +10 -6
- package/commands/pan/hygiene.md +69 -0
- package/commands/pan/milestone-done.md +3 -2
- package/hooks/dist/pan-context-monitor.js +24 -12
- package/hooks/dist/pan-statusline.js +7 -1
- package/package.json +5 -5
- package/pan-wizard-core/bin/lib/config.cjs +14 -1
- package/pan-wizard-core/bin/lib/constants.cjs +40 -0
- package/pan-wizard-core/bin/lib/core.cjs +6 -2
- package/pan-wizard-core/bin/lib/doc-lint.cjs +86 -1
- package/pan-wizard-core/bin/lib/focus.cjs +48 -2
- package/pan-wizard-core/bin/lib/hud.cjs +19 -3
- package/pan-wizard-core/bin/lib/hygiene.cjs +447 -0
- package/pan-wizard-core/bin/lib/knowledge.cjs +30 -14
- package/pan-wizard-core/bin/lib/learn-index.cjs +17 -0
- package/pan-wizard-core/bin/lib/memory.cjs +146 -3
- package/pan-wizard-core/bin/lib/optimize.cjs +2 -2
- package/pan-wizard-core/bin/lib/phase-remove.cjs +1 -1
- package/pan-wizard-core/bin/lib/phase.cjs +4 -4
- package/pan-wizard-core/bin/lib/review-deep.cjs +3 -1
- package/pan-wizard-core/bin/lib/skill-align.cjs +364 -0
- package/pan-wizard-core/bin/lib/verify.cjs +182 -61
- package/pan-wizard-core/bin/pan-tools.cjs +57 -1
- package/pan-wizard-core/learnings/index.json +262 -10
- package/pan-wizard-core/learnings/internal/external-research.md +13 -1
- package/pan-wizard-core/learnings/universal/adversarial-verification.md +45 -0
- package/pan-wizard-core/learnings/universal/audit-convergence.md +33 -0
- package/pan-wizard-core/learnings/universal/autonomous-loop.md +4 -4
- package/pan-wizard-core/learnings/universal/external-tool-truth.md +21 -0
- package/pan-wizard-core/learnings/universal/fix-campaigns.md +45 -0
- package/pan-wizard-core/learnings/universal/flaky-triage.md +33 -0
- package/pan-wizard-core/learnings/universal/golden-sets.md +33 -0
- package/pan-wizard-core/learnings/universal/harness-isolation.md +21 -0
- package/pan-wizard-core/learnings/universal/integration-verification.md +33 -0
- package/pan-wizard-core/learnings/universal/live-path-honesty.md +45 -0
- package/pan-wizard-core/learnings/universal/mcp-security.md +21 -0
- package/pan-wizard-core/learnings/universal/migration-safety.md +21 -0
- package/pan-wizard-core/learnings/universal/service-security.md +21 -0
- package/pan-wizard-core/learnings/universal/single-source-of-truth.md +33 -0
- package/pan-wizard-core/learnings/universal/test-integrity.md +21 -0
- package/pan-wizard-core/learnings/universal/workaround-catalog.md +21 -0
- package/pan-wizard-core/references/model-profiles.md +4 -1
- package/pan-wizard-core/workflows/exec-phase.md +26 -3
- package/pan-wizard-core/workflows/plan-phase.md +1 -0
- package/scripts/release-check.js +29 -14
- 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
|
|
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', '',
|
|
843
|
+
execFileSync('cmd', ['/c', 'start', '', resolved], { stdio: 'ignore' });
|
|
828
844
|
} else if (process.platform === 'darwin') {
|
|
829
|
-
execFileSync('open', [
|
|
845
|
+
execFileSync('open', [resolved], { stdio: 'ignore' });
|
|
830
846
|
} else {
|
|
831
|
-
execFileSync('xdg-open', [
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
127
|
+
const result = {
|
|
125
128
|
question,
|
|
126
|
-
sources
|
|
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 ────────────
|