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