pan-wizard 3.12.5 → 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.
- package/README.md +2 -1
- 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/package.json +1 -1
- package/pan-wizard-core/bin/lib/constants.cjs +40 -0
- package/pan-wizard-core/bin/lib/hygiene.cjs +447 -0
- package/pan-wizard-core/bin/lib/knowledge.cjs +28 -12
- 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/skill-align.cjs +364 -0
- package/pan-wizard-core/bin/lib/verify.cjs +10 -0
- package/pan-wizard-core/bin/pan-tools.cjs +47 -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 +12 -3
- package/pan-wizard-core/workflows/plan-phase.md +1 -0
|
@@ -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
|
-
|
|
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 ────────────
|
|
@@ -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' },
|