pan-wizard 3.26.0 → 3.28.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 +48 -48
- package/agents/pan-previewer.md +1 -1
- package/bin/install-lib.cjs +580 -18
- package/bin/install.js +25 -44
- package/commands/pan/army.md +1 -1
- package/commands/pan/hygiene.md +14 -8
- package/commands/pan/milestone-audit.md +10 -4
- package/commands/pan/preview.md +2 -2
- package/hooks/dist/pan-cost-logger.js +69 -5
- package/hooks/dist/pan-stop-guard.js +32 -1
- package/hooks/dist/pan-trace-logger.js +35 -2
- package/package.json +5 -2
- package/pan-wizard-core/bin/lib/bridge.cjs +0 -1
- package/pan-wizard-core/bin/lib/bus.cjs +0 -1
- package/pan-wizard-core/bin/lib/campaign.cjs +3 -2
- package/pan-wizard-core/bin/lib/commands-learnings.cjs +8 -8
- package/pan-wizard-core/bin/lib/commands.cjs +15 -14
- package/pan-wizard-core/bin/lib/config.cjs +5 -5
- package/pan-wizard-core/bin/lib/constants.cjs +49 -0
- package/pan-wizard-core/bin/lib/context-budget.cjs +98 -0
- package/pan-wizard-core/bin/lib/core.cjs +190 -26
- package/pan-wizard-core/bin/lib/cost.cjs +113 -11
- package/pan-wizard-core/bin/lib/distill.cjs +3 -3
- package/pan-wizard-core/bin/lib/focus.cjs +16 -16
- package/pan-wizard-core/bin/lib/foreign-planning.cjs +56 -0
- package/pan-wizard-core/bin/lib/hud.cjs +1 -1
- package/pan-wizard-core/bin/lib/hygiene.cjs +428 -37
- package/pan-wizard-core/bin/lib/init.cjs +98 -13
- package/pan-wizard-core/bin/lib/knowledge.cjs +0 -1
- package/pan-wizard-core/bin/lib/memory.cjs +1 -1
- package/pan-wizard-core/bin/lib/milestone.cjs +3 -3
- package/pan-wizard-core/bin/lib/optimize.cjs +3 -3
- package/pan-wizard-core/bin/lib/phase.cjs +4 -4
- package/pan-wizard-core/bin/lib/planning-root.cjs +327 -0
- package/pan-wizard-core/bin/lib/preview.cjs +0 -1
- package/pan-wizard-core/bin/lib/review-deep.cjs +0 -1
- package/pan-wizard-core/bin/lib/roadmap.cjs +1 -1
- package/pan-wizard-core/bin/lib/state-compact.cjs +339 -0
- package/pan-wizard-core/bin/lib/state.cjs +0 -1
- package/pan-wizard-core/bin/lib/template.cjs +1 -1
- package/pan-wizard-core/bin/lib/utils.cjs +39 -11
- package/pan-wizard-core/bin/lib/verify.cjs +26 -5
- package/pan-wizard-core/bin/lib/whatif.cjs +0 -1
- package/pan-wizard-core/bin/pan-tools.cjs +58 -4
- package/pan-wizard-core/mcp/server.cjs +92 -8
- package/pan-wizard-core/mcp/tool-registry.cjs +50 -3
- package/pan-wizard-core/references/model-profiles.md +2 -2
- package/pan-wizard-core/workflows/health.md +1 -0
- package/pan-wizard-core/workflows/milestone-audit.md +35 -6
- package/pan-zcode/README.md +1 -1
- package/scripts/build-agent-plugin.js +220 -0
- package/scripts/build-plugin.js +48 -3
- package/scripts/generate-skills-docs.py +1 -1
- package/scripts/release-check.js +58 -12
|
@@ -24,18 +24,27 @@
|
|
|
24
24
|
|
|
25
25
|
const fs = require('fs');
|
|
26
26
|
const path = require('path');
|
|
27
|
-
const { output, safeReadFile, toPosix } = require('./core.cjs');
|
|
27
|
+
const { output, safeReadFile, toPosix, buildCachedContext } = require('./core.cjs');
|
|
28
28
|
const {
|
|
29
|
-
PLANNING_DIR,
|
|
30
29
|
HYGIENE_TRACE_RETENTION_DAYS,
|
|
31
30
|
HYGIENE_TRACE_KEEP_MIN,
|
|
32
31
|
HYGIENE_LEDGER_SUSPECT_RATIO,
|
|
32
|
+
HYGIENE_LEDGER_SUSPECT_MASS_RATIO,
|
|
33
|
+
HYGIENE_REPORT_KEEP_MIN,
|
|
34
|
+
CACHE_BLOCK_WARN_TOKENS,
|
|
35
|
+
CACHE_BLOCK_CRIT_TOKENS,
|
|
36
|
+
CACHE_FILE_WARN_TOKENS,
|
|
33
37
|
HYGIENE_LEDGER_MIN_RECORDS,
|
|
34
38
|
HYGIENE_TMP_AGE_MS,
|
|
39
|
+
CHARS_PER_TOKEN,
|
|
40
|
+
STATE_FILE,
|
|
35
41
|
} = require('./constants.cjs');
|
|
36
|
-
const { planningPath } = require('./utils.cjs');
|
|
42
|
+
const { planningPath, planningRel } = require('./utils.cjs');
|
|
43
|
+
const { detectForeignPlanningTree } = require('./foreign-planning.cjs');
|
|
37
44
|
const { listMemoryAgents, readMemory, compactMemory } = require('./memory.cjs');
|
|
38
45
|
const { readRecords, isSuspectRecord, METRICS_DIR, TOKENS_FILE } = require('./cost.cjs');
|
|
46
|
+
const { assessCacheTtl } = require('./context-budget.cjs');
|
|
47
|
+
const { planningRootRel, planningRoots, withPlanningRoot, describePlanningRoot, TRACKS_DIR } = require('./planning-root.cjs');
|
|
39
48
|
|
|
40
49
|
/** Runtime config dirs a PAN install can live in, relative to project root. */
|
|
41
50
|
const RUNTIME_DIRS = [
|
|
@@ -48,6 +57,14 @@ const RUNTIME_DIRS = [
|
|
|
48
57
|
|
|
49
58
|
const MANIFEST_NAME = 'pan-file-manifest.json';
|
|
50
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Per-transcript read cursor written beside the ledger by hooks/pan-cost-logger.js.
|
|
62
|
+
* Mirrored here by name because hooks are standalone and export nothing importable
|
|
63
|
+
* into the core; quarantining a ledger without clearing this leaves the fresh
|
|
64
|
+
* ledger inheriting the old one's read position.
|
|
65
|
+
*/
|
|
66
|
+
const COST_CURSOR_FILE = '.cost-cursor.json';
|
|
67
|
+
|
|
51
68
|
/** Pre-v2.2 uppercase planning filenames whose canonical form is lowercase. */
|
|
52
69
|
const LEGACY_UPPERCASE_FILES = [
|
|
53
70
|
'STATE.md', 'ROADMAP.md', 'PROJECT.md', 'REQUIREMENTS.md',
|
|
@@ -79,7 +96,9 @@ function ownVersion() {
|
|
|
79
96
|
}
|
|
80
97
|
|
|
81
98
|
function mkFinding(check, severity, relPath, detail, fix) {
|
|
82
|
-
|
|
99
|
+
// `track` is stamped by scanHygiene once it knows which tree produced the
|
|
100
|
+
// finding; null means the project root tree (or a project-wide check).
|
|
101
|
+
return { check, severity, path: toPosix(relPath), detail, fix: fix || null, fixable: !!fix, track: null };
|
|
83
102
|
}
|
|
84
103
|
|
|
85
104
|
// ─── Checks ─────────────────────────────────────────────────────────────────
|
|
@@ -144,11 +163,11 @@ function checkLegacyUppercase(cwd) {
|
|
|
144
163
|
// case-insensitive on Windows and would always be true here).
|
|
145
164
|
const twin = entries.includes(lower);
|
|
146
165
|
if (twin) {
|
|
147
|
-
findings.push(mkFinding('legacy-filenames', 'warn',
|
|
166
|
+
findings.push(mkFinding('legacy-filenames', 'warn', planningRel(name),
|
|
148
167
|
`legacy ${name} coexists with ${lower} — merge manually, auto-rename would clobber`,
|
|
149
168
|
null));
|
|
150
169
|
} else {
|
|
151
|
-
findings.push(mkFinding('legacy-filenames', 'warn',
|
|
170
|
+
findings.push(mkFinding('legacy-filenames', 'warn', planningRel(name),
|
|
152
171
|
`legacy uppercase filename — canonical form is ${lower}`,
|
|
153
172
|
{ action: 'rename-lowercase', from: name, to: lower }));
|
|
154
173
|
}
|
|
@@ -156,9 +175,18 @@ function checkLegacyUppercase(cwd) {
|
|
|
156
175
|
return { findings };
|
|
157
176
|
}
|
|
158
177
|
|
|
159
|
-
/**
|
|
178
|
+
/**
|
|
179
|
+
* Bounded recursive walk of the ACTIVE planning tree, collecting file paths.
|
|
180
|
+
*
|
|
181
|
+
* Never descends into `<root>/tracks/`: those are sibling planning trees, each
|
|
182
|
+
* scanned in its own pass with its own root. Without this the root scan absorbs
|
|
183
|
+
* every track's files — attributing their `.tmp` orphans and docs to `.planning`
|
|
184
|
+
* — and `--all-tracks` counts them twice, once under the root and once under
|
|
185
|
+
* the track they actually belong to.
|
|
186
|
+
*/
|
|
160
187
|
function walkPlanning(cwd, maxDepth = 5) {
|
|
161
188
|
const root = planningPath(cwd);
|
|
189
|
+
const tracksDir = path.join(root, TRACKS_DIR);
|
|
162
190
|
const out = [];
|
|
163
191
|
const walk = (dir, depth) => {
|
|
164
192
|
if (depth > maxDepth) return;
|
|
@@ -166,8 +194,12 @@ function walkPlanning(cwd, maxDepth = 5) {
|
|
|
166
194
|
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
167
195
|
for (const e of entries) {
|
|
168
196
|
const abs = path.join(dir, e.name);
|
|
169
|
-
if (e.isDirectory())
|
|
170
|
-
|
|
197
|
+
if (e.isDirectory()) {
|
|
198
|
+
if (abs === tracksDir) continue;
|
|
199
|
+
walk(abs, depth + 1);
|
|
200
|
+
} else {
|
|
201
|
+
out.push(abs);
|
|
202
|
+
}
|
|
171
203
|
}
|
|
172
204
|
};
|
|
173
205
|
walk(root, 0);
|
|
@@ -198,25 +230,63 @@ function checkMemoryLogs(cwd) {
|
|
|
198
230
|
if (!mem || !Array.isArray(mem.entries)) continue;
|
|
199
231
|
if (mem.entries.length <= MEMORY_ENTRY_CAP) continue;
|
|
200
232
|
findings.push(mkFinding('memory-bloat', 'warn',
|
|
201
|
-
|
|
233
|
+
planningRel('memory', `${a.agent}.md`),
|
|
202
234
|
`${mem.entries.length} entries exceeds cap ${MEMORY_ENTRY_CAP} — whole-file reads flood context`,
|
|
203
235
|
{ action: 'compact-memory', agent: a.agent }));
|
|
204
236
|
}
|
|
205
237
|
return { findings };
|
|
206
238
|
}
|
|
207
239
|
|
|
208
|
-
/**
|
|
240
|
+
/**
|
|
241
|
+
* Thousands separator that does not depend on the host locale. `toLocaleString()`
|
|
242
|
+
* emits a narrow no-break space in some locales and a comma in others, which
|
|
243
|
+
* makes finding text vary by machine and any test asserting on it flaky.
|
|
244
|
+
*/
|
|
245
|
+
function fmtTokens(n) {
|
|
246
|
+
return String(Math.round(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Token mass of a ledger record across all four axes. */
|
|
250
|
+
function recordMass(r) {
|
|
251
|
+
return (r.input_tokens || 0) + (r.output_tokens || 0)
|
|
252
|
+
+ (r.cache_read_tokens || 0) + (r.cache_write_tokens || 0);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* H-5: cost ledger dominated by physically implausible (pre-v3.12.4) records.
|
|
257
|
+
*
|
|
258
|
+
* Gated on token MASS as well as record count. A count-only gate passes a ledger
|
|
259
|
+
* whose few bad rows carry most of the tokens — field case: 24% of rows suspect
|
|
260
|
+
* (under the 50% count gate, so "clean") holding 90% of the token mass, which
|
|
261
|
+
* makes every aggregate read off that file wrong by an order of magnitude. Mass
|
|
262
|
+
* is what `aggregate` actually sums, so mass is what the gate has to watch.
|
|
263
|
+
*/
|
|
209
264
|
function checkCostLedger(cwd) {
|
|
210
265
|
const findings = [];
|
|
211
266
|
let records = [];
|
|
212
267
|
try { records = readRecords(cwd) || []; } catch { return { findings }; }
|
|
213
268
|
if (records.length < HYGIENE_LEDGER_MIN_RECORDS) return { findings };
|
|
214
|
-
|
|
269
|
+
|
|
270
|
+
const suspectRecords = records.filter(r => isSuspectRecord(r));
|
|
271
|
+
const suspect = suspectRecords.length;
|
|
215
272
|
const ratio = suspect / records.length;
|
|
216
|
-
|
|
273
|
+
|
|
274
|
+
const totalMass = records.reduce((sum, r) => sum + recordMass(r), 0);
|
|
275
|
+
const suspectMass = suspectRecords.reduce((sum, r) => sum + recordMass(r), 0);
|
|
276
|
+
const massRatio = totalMass > 0 ? suspectMass / totalMass : 0;
|
|
277
|
+
|
|
278
|
+
const byCount = ratio >= HYGIENE_LEDGER_SUSPECT_RATIO;
|
|
279
|
+
const byMass = massRatio >= HYGIENE_LEDGER_SUSPECT_MASS_RATIO;
|
|
280
|
+
if (!byCount && !byMass) return { findings };
|
|
281
|
+
|
|
282
|
+
// Name whichever gate fired, so the remediation is not mistaken for a
|
|
283
|
+
// false positive when the record count looks healthy.
|
|
284
|
+
const basis = byCount && byMass ? 'record count and token mass'
|
|
285
|
+
: byCount ? 'record count'
|
|
286
|
+
: 'token mass';
|
|
217
287
|
findings.push(mkFinding('poisoned-ledger', 'critical',
|
|
218
|
-
|
|
219
|
-
`${suspect}/${records.length} records
|
|
288
|
+
planningRel(METRICS_DIR, TOKENS_FILE),
|
|
289
|
+
`${suspect}/${records.length} records suspect (${Math.round(ratio * 100)}% of rows, ${Math.round(massRatio * 100)}% of token mass) — pre-v3.12.4 oversum signature, tripped on ${basis}; aggregates quarantine them but the file is dead weight`,
|
|
220
290
|
{ action: 'quarantine-ledger' }));
|
|
221
291
|
return { findings };
|
|
222
292
|
}
|
|
@@ -248,6 +318,176 @@ function checkStaleTraces(cwd, opts, now = Date.now()) {
|
|
|
248
318
|
return { findings };
|
|
249
319
|
}
|
|
250
320
|
|
|
321
|
+
/**
|
|
322
|
+
* H-8: optimization reports past retention.
|
|
323
|
+
*
|
|
324
|
+
* `checkStaleTraces` pruned `optimization/traces/` but nothing ever pruned
|
|
325
|
+
* `optimization/reports/`, which is where the analysis JSON lands — in the field
|
|
326
|
+
* the single largest file in a planning tree was a 92 KB analysis report from a
|
|
327
|
+
* session whose trace had long since been pruned. Same retention, same
|
|
328
|
+
* keep-newest floor, so the two halves of one subsystem age together.
|
|
329
|
+
*/
|
|
330
|
+
function checkStaleReports(cwd, opts, now = Date.now()) {
|
|
331
|
+
const findings = [];
|
|
332
|
+
const retentionDays = Number(opts?.traceAgeDays) || HYGIENE_TRACE_RETENTION_DAYS;
|
|
333
|
+
const reportsDir = planningPath(cwd, 'optimization', 'reports');
|
|
334
|
+
let entries = [];
|
|
335
|
+
try { entries = fs.readdirSync(reportsDir, { withFileTypes: true }); } catch { return { findings }; }
|
|
336
|
+
|
|
337
|
+
const reports = [];
|
|
338
|
+
for (const e of entries) {
|
|
339
|
+
if (!e.isFile()) continue;
|
|
340
|
+
const abs = path.join(reportsDir, e.name);
|
|
341
|
+
let stat;
|
|
342
|
+
try { stat = fs.statSync(abs); } catch { continue; }
|
|
343
|
+
reports.push({ name: e.name, abs, mtime: stat.mtimeMs, size: stat.size });
|
|
344
|
+
}
|
|
345
|
+
reports.sort((a, b) => b.mtime - a.mtime);
|
|
346
|
+
|
|
347
|
+
const cutoff = now - retentionDays * 24 * 3600 * 1000;
|
|
348
|
+
for (const r of reports.slice(HYGIENE_REPORT_KEEP_MIN)) {
|
|
349
|
+
if (r.mtime >= cutoff) continue;
|
|
350
|
+
findings.push(mkFinding('stale-reports', 'info',
|
|
351
|
+
path.relative(cwd, r.abs),
|
|
352
|
+
`optimization report older than ${retentionDays}d retention (and not among newest ${HYGIENE_REPORT_KEEP_MIN}) — ${(r.size / 1024).toFixed(1)} KB`,
|
|
353
|
+
{ action: 'delete' }));
|
|
354
|
+
}
|
|
355
|
+
return { findings };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* H-9: cached prompt context bloat.
|
|
360
|
+
*
|
|
361
|
+
* The files in CACHEABLE_CONTEXT_FILES are re-read into EVERY agent call, so
|
|
362
|
+
* their combined size is the dominant recurring cost of a PAN project — cache
|
|
363
|
+
* reads outweigh generated tokens by roughly two orders of magnitude. Nothing
|
|
364
|
+
* used to watch this: `context-budget` measured the block and reported it
|
|
365
|
+
* without any threshold, so a state.md that had grown to ~14k tokens of mostly
|
|
366
|
+
* closed history was re-read for months without a single warning.
|
|
367
|
+
*
|
|
368
|
+
* Report-only for the block as a whole; the per-file finding carries the
|
|
369
|
+
* `state compact` remediation because state.md is the file that actually grows.
|
|
370
|
+
*/
|
|
371
|
+
/**
|
|
372
|
+
* Remove superseded quarantined ledgers, keeping only the one just written.
|
|
373
|
+
*
|
|
374
|
+
* Quarantine is deliberately non-destructive — the poisoned rows are evidence,
|
|
375
|
+
* not garbage — but keeping EVERY quarantine forever turns the cure into the
|
|
376
|
+
* disease. The newest is retained so the most recent evidence survives; older
|
|
377
|
+
* ones have already been superseded by it.
|
|
378
|
+
*
|
|
379
|
+
* @param {string} metricsDirAbs - directory holding the ledger
|
|
380
|
+
* @param {string} keepAbs - the quarantine file to preserve
|
|
381
|
+
* @returns {number} how many were removed
|
|
382
|
+
*/
|
|
383
|
+
function pruneOldQuarantines(metricsDirAbs, keepAbs) {
|
|
384
|
+
let removed = 0;
|
|
385
|
+
let entries = [];
|
|
386
|
+
try { entries = fs.readdirSync(metricsDirAbs); } catch { return 0; }
|
|
387
|
+
const keepName = path.basename(keepAbs);
|
|
388
|
+
for (const name of entries) {
|
|
389
|
+
if (name === keepName) continue;
|
|
390
|
+
if (!name.startsWith(`${TOKENS_FILE}.quarantined-`)) continue;
|
|
391
|
+
try { fs.unlinkSync(path.join(metricsDirAbs, name)); removed++; } catch { /* leave it */ }
|
|
392
|
+
}
|
|
393
|
+
return removed;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** How many markdown docs the planning tree holds — "is there anything to cache?". */
|
|
397
|
+
function planningDocCount(cwd) {
|
|
398
|
+
return walkPlanning(cwd).filter(p => p.endsWith('.md')).length;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function checkCachedContext(cwd) {
|
|
402
|
+
const findings = [];
|
|
403
|
+
let cached;
|
|
404
|
+
try { cached = buildCachedContext(cwd); } catch { return { findings }; }
|
|
405
|
+
if (!cached || !Array.isArray(cached.blocks)) return { findings };
|
|
406
|
+
|
|
407
|
+
// An empty block is not "small" — it means this project gets NO prompt
|
|
408
|
+
// caching at all, which is worth saying out loud rather than reporting as a
|
|
409
|
+
// healthy zero. But only for a tree that HAS planning content: a freshly
|
|
410
|
+
// scaffolded `.planning/phases/` has nothing to cache yet, and reporting that
|
|
411
|
+
// as a finding is noise on every new project. The signal is "you have
|
|
412
|
+
// planning docs and none of them are cached", not "you have no docs".
|
|
413
|
+
if (cached.blocks.length === 0) {
|
|
414
|
+
if (planningDocCount(cwd) > 0) {
|
|
415
|
+
findings.push(mkFinding('cache-context', 'info', planningRel(),
|
|
416
|
+
'planning docs exist but none are cacheable — every agent call re-sends its context uncached. '
|
|
417
|
+
+ 'Add project.md/standards.md, or list stable docs under config.json cache.extra_files',
|
|
418
|
+
null));
|
|
419
|
+
}
|
|
420
|
+
return { findings };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const blockTokens = Math.ceil(cached.total_bytes / CHARS_PER_TOKEN);
|
|
424
|
+
if (blockTokens >= CACHE_BLOCK_WARN_TOKENS) {
|
|
425
|
+
const severity = blockTokens >= CACHE_BLOCK_CRIT_TOKENS ? 'critical' : 'warn';
|
|
426
|
+
findings.push(mkFinding('cache-context', severity, planningRel(),
|
|
427
|
+
`cached context block is ~${fmtTokens(blockTokens)} tokens across ${cached.blocks.length} file(s) `
|
|
428
|
+
+ `(warn ${fmtTokens(CACHE_BLOCK_WARN_TOKENS)}, critical ${fmtTokens(CACHE_BLOCK_CRIT_TOKENS)}) — `
|
|
429
|
+
+ 're-read on every agent call, so this is the project\'s largest recurring cost',
|
|
430
|
+
null));
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
for (const b of cached.blocks) {
|
|
434
|
+
const tokens = Math.ceil((b.content || '').length / CHARS_PER_TOKEN);
|
|
435
|
+
if (tokens < CACHE_FILE_WARN_TOKENS) continue;
|
|
436
|
+
const isState = String(b.path).endsWith(STATE_FILE);
|
|
437
|
+
|
|
438
|
+
// A finding may only advertise `auto-fixable` when running the fix would
|
|
439
|
+
// actually change something. state.md stays over the threshold once its
|
|
440
|
+
// settled history has already been archived — the remaining bulk is LIVE
|
|
441
|
+
// content, and no amount of re-running `clean` will shrink it. Claiming
|
|
442
|
+
// otherwise makes `clean --apply` report a permanent `failed: 1` and the
|
|
443
|
+
// project never converges to clean.
|
|
444
|
+
let fix = null;
|
|
445
|
+
let suffix = '';
|
|
446
|
+
if (isState) {
|
|
447
|
+
suffix = ' — state.md section writers only append; closed history is still being re-read';
|
|
448
|
+
const archivable = stateCompactionAvailable(cwd);
|
|
449
|
+
if (archivable > 0) {
|
|
450
|
+
fix = { action: 'compact-state' };
|
|
451
|
+
} else {
|
|
452
|
+
suffix = ' — already compacted; the remaining bulk is LIVE content, so trim it by hand'
|
|
453
|
+
+ ' (or widen the window with `state compact --keep-days N`)';
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
findings.push(mkFinding('cache-context', 'warn', b.path,
|
|
458
|
+
`~${fmtTokens(tokens)} tokens re-read on every agent call (warn ${fmtTokens(CACHE_FILE_WARN_TOKENS)})${suffix}`,
|
|
459
|
+
fix));
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Lifetime signal (ADR-0046 D5): the ledger shows cache WRITES that followed
|
|
463
|
+
// an idle gap of five to sixty minutes — misses a one-hour subagent cache
|
|
464
|
+
// lifetime would have turned into hits. Informational and never fixable: the
|
|
465
|
+
// remedy is a Claude Code setting the user weighs against the 2× write price.
|
|
466
|
+
try {
|
|
467
|
+
const ttl = assessCacheTtl(readRecords(cwd).filter(r => !isSuspectRecord(r)));
|
|
468
|
+
if (ttl.recommend) {
|
|
469
|
+
findings.push(mkFinding('cache-context', 'info', planningRel(path.join(METRICS_DIR, TOKENS_FILE)), ttl.advice, null));
|
|
470
|
+
}
|
|
471
|
+
} catch { /* no ledger, or unreadable — nothing to say */ }
|
|
472
|
+
return { findings };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* How many state.md sections `state compact` could archive right now.
|
|
477
|
+
*
|
|
478
|
+
* Consulted before offering the `compact-state` fix so hygiene never advertises
|
|
479
|
+
* a remedy that would no-op. Returns 0 on any failure — an unavailable planner
|
|
480
|
+
* must make the finding manual, never falsely fixable.
|
|
481
|
+
*/
|
|
482
|
+
function stateCompactionAvailable(cwd) {
|
|
483
|
+
try {
|
|
484
|
+
const { planStateCompaction } = require('./state-compact.cjs');
|
|
485
|
+
return planStateCompaction(cwd).archivable.length;
|
|
486
|
+
} catch {
|
|
487
|
+
return 0;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
251
491
|
/** H-7: fragment .planning — artifacts present but no project spine. Report-only. */
|
|
252
492
|
function checkPlanningFragment(cwd) {
|
|
253
493
|
const findings = [];
|
|
@@ -263,7 +503,7 @@ function checkPlanningFragment(cwd) {
|
|
|
263
503
|
'milestones', 'focus', 'quick', 'orchestration'];
|
|
264
504
|
const hasSpine = SPINE.some(s => lower.includes(s));
|
|
265
505
|
if (!hasSpine && entries.length > 0) {
|
|
266
|
-
findings.push(mkFinding('planning-fragment', 'info',
|
|
506
|
+
findings.push(mkFinding('planning-fragment', 'info', planningRootRel(),
|
|
267
507
|
`.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
508
|
null));
|
|
269
509
|
}
|
|
@@ -279,29 +519,93 @@ function checkPlanningFragment(cwd) {
|
|
|
279
519
|
* @param {Object} [opts] - {traceAgeDays}
|
|
280
520
|
* @returns {Object} {findings, installs, latest_version, planning_exists, summary}
|
|
281
521
|
*/
|
|
522
|
+
/**
|
|
523
|
+
* Run every planning-tree check against one root, tagging each finding with
|
|
524
|
+
* the track it came from.
|
|
525
|
+
*
|
|
526
|
+
* @param {string} cwd - project root
|
|
527
|
+
* @param {{name: string|null, rel: string}} root - the tree to scan
|
|
528
|
+
* @param {Object} [opts]
|
|
529
|
+
* @returns {{findings: Array, planning_exists: boolean}}
|
|
530
|
+
*/
|
|
531
|
+
function scanOneRoot(cwd, root, opts) {
|
|
532
|
+
return withPlanningRoot(root.rel, () => {
|
|
533
|
+
// A .planning/ written by ANOTHER tool (gsd-core shares the directory name and
|
|
534
|
+
// PAN's pre-v2.2 uppercase file names) must never be "repaired": the legacy
|
|
535
|
+
// rename would rename its state files. One warn finding, nothing fixable, and
|
|
536
|
+
// none of the per-tree checks run on it. Reality check R15.
|
|
537
|
+
const foreign = detectForeignPlanningTree(planningPath(cwd));
|
|
538
|
+
if (foreign) {
|
|
539
|
+
const f = mkFinding('foreign-planning-tree', 'warn', planningRel(),
|
|
540
|
+
`planning tree belongs to ${foreign.tool} (${foreign.evidence.join(', ')}) — PAN will not rename or repair its files; run PAN with --planning-dir to give it a tree of its own (ADR-0043)`,
|
|
541
|
+
null);
|
|
542
|
+
f.track = root.name;
|
|
543
|
+
return { findings: [f], planning_exists: true };
|
|
544
|
+
}
|
|
545
|
+
const fragment = checkPlanningFragment(cwd);
|
|
546
|
+
const findings = [
|
|
547
|
+
...fragment.findings,
|
|
548
|
+
...checkLegacyUppercase(cwd).findings,
|
|
549
|
+
...checkTmpOrphans(cwd).findings,
|
|
550
|
+
...checkMemoryLogs(cwd).findings,
|
|
551
|
+
...checkCostLedger(cwd).findings,
|
|
552
|
+
...checkStaleTraces(cwd, opts).findings,
|
|
553
|
+
...checkStaleReports(cwd, opts).findings,
|
|
554
|
+
...checkCachedContext(cwd).findings,
|
|
555
|
+
];
|
|
556
|
+
for (const f of findings) f.track = root.name;
|
|
557
|
+
return { findings, planning_exists: fragment.planning_exists !== false };
|
|
558
|
+
}, root.name);
|
|
559
|
+
}
|
|
560
|
+
|
|
282
561
|
function scanHygiene(cwd, opts) {
|
|
562
|
+
// Version alignment is a property of the PROJECT (which runtimes are
|
|
563
|
+
// installed, at what version), not of any planning tree — run it once no
|
|
564
|
+
// matter how many trees we sweep, or a four-track repo reports the same
|
|
565
|
+
// drift four times.
|
|
283
566
|
const version = checkVersionAlignment(cwd);
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
567
|
+
const roots = planningRoots(cwd, { allTracks: !!opts?.allTracks });
|
|
568
|
+
|
|
569
|
+
const findings = [...version.findings];
|
|
570
|
+
const scanned = [];
|
|
571
|
+
let planningExists = false;
|
|
572
|
+
|
|
573
|
+
for (const root of roots) {
|
|
574
|
+
const result = scanOneRoot(cwd, root, opts);
|
|
575
|
+
if (result.planning_exists) planningExists = true;
|
|
576
|
+
scanned.push({
|
|
577
|
+
track: root.name,
|
|
578
|
+
planning_root: root.rel,
|
|
579
|
+
planning_exists: result.planning_exists,
|
|
580
|
+
findings: result.findings.length,
|
|
581
|
+
});
|
|
582
|
+
findings.push(...result.findings);
|
|
583
|
+
}
|
|
584
|
+
|
|
294
585
|
const byCheck = {};
|
|
295
586
|
for (const f of findings) byCheck[f.check] = (byCheck[f.check] || 0) + 1;
|
|
587
|
+
const byTrack = {};
|
|
588
|
+
for (const f of findings) {
|
|
589
|
+
const key = f.track || '(root)';
|
|
590
|
+
byTrack[key] = (byTrack[key] || 0) + 1;
|
|
591
|
+
}
|
|
592
|
+
|
|
296
593
|
return {
|
|
297
594
|
findings,
|
|
298
595
|
installs: version.installs,
|
|
299
596
|
latest_version: version.latest_version,
|
|
300
|
-
planning_exists:
|
|
597
|
+
planning_exists: planningExists,
|
|
598
|
+
// What was actually looked at. Present in every scan, not just --all-tracks:
|
|
599
|
+
// a scan that reports "clean" must always say which tree it read, so a
|
|
600
|
+
// wrong target is visible instead of passing for a clean bill of health.
|
|
601
|
+
...describePlanningRoot(cwd),
|
|
602
|
+
all_tracks: !!opts?.allTracks,
|
|
603
|
+
roots_scanned: scanned,
|
|
301
604
|
summary: {
|
|
302
605
|
total: findings.length,
|
|
303
606
|
fixable: findings.filter(f => f.fixable).length,
|
|
304
607
|
by_check: byCheck,
|
|
608
|
+
by_track: byTrack,
|
|
305
609
|
by_severity: findings.reduce((m, f) => { m[f.severity] = (m[f.severity] || 0) + 1; return m; }, {}),
|
|
306
610
|
},
|
|
307
611
|
};
|
|
@@ -315,6 +619,12 @@ function applyFix(cwd, finding) {
|
|
|
315
619
|
try {
|
|
316
620
|
switch (fix.action) {
|
|
317
621
|
case 'rename-lowercase': {
|
|
622
|
+
// Defence in depth for R15: the scan never emits this fix for a foreign tree,
|
|
623
|
+
// but a stale findings list or a hand-built one must not rename another
|
|
624
|
+
// tool's files either.
|
|
625
|
+
if (detectForeignPlanningTree(path.dirname(abs))) {
|
|
626
|
+
return { applied: false, detail: 'refused: this planning tree belongs to another tool (see the foreign-planning-tree finding)' };
|
|
627
|
+
}
|
|
318
628
|
// Two-step rename: Windows treats case-only renames inconsistently
|
|
319
629
|
// across fs layers, so hop through a temp name.
|
|
320
630
|
const dir = path.dirname(abs);
|
|
@@ -334,11 +644,44 @@ function applyFix(cwd, finding) {
|
|
|
334
644
|
if (r.error) return { applied: false, detail: r.error };
|
|
335
645
|
return { applied: true, detail: `compacted to ${r.kept ?? r.entries ?? 'cap'} entries` };
|
|
336
646
|
}
|
|
647
|
+
case 'compact-state': {
|
|
648
|
+
// Required lazily: state-compact pulls in state.cjs, which pulls in core
|
|
649
|
+
// — importing it at module load would put hygiene on that cycle.
|
|
650
|
+
const { compactState } = require('./state-compact.cjs');
|
|
651
|
+
const r = compactState(cwd, { apply: true });
|
|
652
|
+
if (!r.found) return { applied: false, detail: 'state.md not found' };
|
|
653
|
+
if (!r.applied) return { applied: false, detail: 'nothing past the retention window' };
|
|
654
|
+
return {
|
|
655
|
+
applied: true,
|
|
656
|
+
detail: `archived ${r.archived.length} section(s) to ${r.history_path} — saves ~${r.tokens_saved_per_call} tokens per agent call`,
|
|
657
|
+
};
|
|
658
|
+
}
|
|
337
659
|
case 'quarantine-ledger': {
|
|
338
660
|
const stamp = new Date().toISOString().slice(0, 10);
|
|
339
661
|
const dest = `${abs}.quarantined-${stamp}`;
|
|
340
662
|
fs.renameSync(abs, dest);
|
|
341
|
-
|
|
663
|
+
|
|
664
|
+
// The cursor is a per-transcript high-water mark INTO the ledger we just
|
|
665
|
+
// moved aside. Left behind it points at rows that are no longer there,
|
|
666
|
+
// so the fresh ledger starts mid-stream and the next slice is undercounted.
|
|
667
|
+
// A "fresh ledger" that inherits the old ledger's read position is not fresh.
|
|
668
|
+
let cursorNote = '';
|
|
669
|
+
try {
|
|
670
|
+
const cursor = path.join(path.dirname(abs), COST_CURSOR_FILE);
|
|
671
|
+
fs.unlinkSync(cursor);
|
|
672
|
+
cursorNote = ', cursor reset';
|
|
673
|
+
} catch { /* no cursor to reset */ }
|
|
674
|
+
|
|
675
|
+
// Quarantine leaves a dated copy behind, and nothing else ever removes
|
|
676
|
+
// one. Run hygiene a few times over a year and the metrics dir fills
|
|
677
|
+
// with dead ledgers — the very bloat this command exists to remove.
|
|
678
|
+
const pruned = pruneOldQuarantines(path.dirname(abs), dest);
|
|
679
|
+
const prunedNote = pruned > 0 ? `, ${pruned} older quarantine(s) removed` : '';
|
|
680
|
+
|
|
681
|
+
return {
|
|
682
|
+
applied: true,
|
|
683
|
+
detail: `renamed to ${path.basename(dest)}${cursorNote}${prunedNote} — fresh ledger starts clean`,
|
|
684
|
+
};
|
|
342
685
|
}
|
|
343
686
|
default:
|
|
344
687
|
return { applied: false, detail: `unknown fix action ${fix.action}` };
|
|
@@ -360,22 +703,40 @@ function cleanHygiene(cwd, opts) {
|
|
|
360
703
|
const apply = !!opts?.apply;
|
|
361
704
|
const applied = [];
|
|
362
705
|
const skipped = [];
|
|
706
|
+
|
|
707
|
+
// Which tree each finding came from. Most fixes act on finding.path, which is
|
|
708
|
+
// already track-correct — but compact-memory delegates to memory.cjs, which
|
|
709
|
+
// resolves the root itself. Without this map a track's bloated memory log
|
|
710
|
+
// would be "fixed" by compacting the root tree's log instead.
|
|
711
|
+
const rootByTrack = new Map();
|
|
712
|
+
for (const r of scan.roots_scanned || []) rootByTrack.set(r.track, r.planning_root);
|
|
713
|
+
|
|
363
714
|
for (const f of scan.findings) {
|
|
364
715
|
if (!f.fixable) {
|
|
365
|
-
skipped.push({ check: f.check, path: f.path, reason: 'no safe auto-fix — see detail', detail: f.detail });
|
|
716
|
+
skipped.push({ check: f.check, path: f.path, track: f.track, reason: 'no safe auto-fix — see detail', detail: f.detail });
|
|
366
717
|
continue;
|
|
367
718
|
}
|
|
368
719
|
if (!apply) {
|
|
369
|
-
applied.push({ check: f.check, path: f.path, action: f.fix.action, applied: false, detail: 'dry-run' });
|
|
720
|
+
applied.push({ check: f.check, path: f.path, track: f.track, action: f.fix.action, applied: false, detail: 'dry-run' });
|
|
370
721
|
continue;
|
|
371
722
|
}
|
|
372
|
-
const
|
|
373
|
-
|
|
723
|
+
const rootRel = rootByTrack.get(f.track);
|
|
724
|
+
const result = rootRel
|
|
725
|
+
? withPlanningRoot(rootRel, () => applyFix(cwd, f))
|
|
726
|
+
: applyFix(cwd, f);
|
|
727
|
+
applied.push({ check: f.check, path: f.path, track: f.track, action: f.fix.action, ...result });
|
|
374
728
|
}
|
|
729
|
+
|
|
375
730
|
return {
|
|
376
731
|
dry_run: !apply,
|
|
377
732
|
applied,
|
|
378
733
|
skipped,
|
|
734
|
+
planning_root: scan.planning_root,
|
|
735
|
+
track: scan.track,
|
|
736
|
+
planning_root_source: scan.planning_root_source,
|
|
737
|
+
planning_root_exists: scan.planning_root_exists,
|
|
738
|
+
all_tracks: scan.all_tracks,
|
|
739
|
+
roots_scanned: scan.roots_scanned,
|
|
379
740
|
summary: {
|
|
380
741
|
fixable: applied.length,
|
|
381
742
|
executed: applied.filter(a => a.applied).length,
|
|
@@ -390,21 +751,47 @@ function cleanHygiene(cwd, opts) {
|
|
|
390
751
|
function renderFindings(findings) {
|
|
391
752
|
const lines = [];
|
|
392
753
|
for (const f of findings) {
|
|
393
|
-
|
|
754
|
+
const where = f.track ? `[${f.track}] ` : '';
|
|
755
|
+
lines.push(` [${f.severity.toUpperCase().padEnd(8)}] ${f.check.padEnd(18)} ${where}${f.path}`);
|
|
394
756
|
lines.push(` ${f.detail}${f.fixable ? ' (auto-fixable)' : ''}`);
|
|
395
757
|
}
|
|
396
758
|
return lines;
|
|
397
759
|
}
|
|
398
760
|
|
|
761
|
+
/**
|
|
762
|
+
* One line naming exactly which tree(s) were read.
|
|
763
|
+
*
|
|
764
|
+
* Printed on every scan, including clean ones. "Clean" is only meaningful
|
|
765
|
+
* alongside "…and here is what I looked at" — the original defect was a scan
|
|
766
|
+
* reporting no findings because it had read the wrong directory, which is
|
|
767
|
+
* indistinguishable from a healthy project unless the target is stated.
|
|
768
|
+
*/
|
|
769
|
+
function renderScope(result) {
|
|
770
|
+
if (result.all_tracks) {
|
|
771
|
+
const names = (result.roots_scanned || []).map(r => r.track || '(root)');
|
|
772
|
+
return `Scanned ${names.length} planning tree(s): ${names.join(', ')}`;
|
|
773
|
+
}
|
|
774
|
+
const via = result.planning_root_source && result.planning_root_source !== 'default'
|
|
775
|
+
? ` (via ${result.planning_root_source})` : '';
|
|
776
|
+
const missing = result.planning_root_exists === false ? ' — DIRECTORY NOT FOUND' : '';
|
|
777
|
+
return `Scanned planning root: ${result.planning_root}${via}${missing}`;
|
|
778
|
+
}
|
|
779
|
+
|
|
399
780
|
function cmdHygieneScan(cwd, opts, raw) {
|
|
400
781
|
const result = scanHygiene(cwd, opts);
|
|
401
782
|
if (raw) {
|
|
402
783
|
const lines = [`Hygiene scan: ${result.summary.total} finding(s), ${result.summary.fixable} auto-fixable`];
|
|
784
|
+
lines.push(renderScope(result));
|
|
403
785
|
if (result.latest_version) {
|
|
404
786
|
lines.push(`Latest version seen: ${result.latest_version}; installs: ${result.installs.map(i => `${i.runtime}@${i.version || '?'}`).join(', ') || 'none'}`);
|
|
405
787
|
}
|
|
406
788
|
lines.push('', ...renderFindings(result.findings));
|
|
407
|
-
if (result.findings.length === 0)
|
|
789
|
+
if (result.findings.length === 0) {
|
|
790
|
+
lines.push(' Clean — nothing to do.');
|
|
791
|
+
if (!result.all_tracks) {
|
|
792
|
+
lines.push(' (one tree only — pass --all-tracks to include .planning/tracks/*)');
|
|
793
|
+
}
|
|
794
|
+
}
|
|
408
795
|
output(result, true, lines.join('\n'));
|
|
409
796
|
} else {
|
|
410
797
|
output(result, false);
|
|
@@ -415,12 +802,14 @@ function cmdHygieneClean(cwd, opts, raw) {
|
|
|
415
802
|
const result = cleanHygiene(cwd, opts);
|
|
416
803
|
if (raw) {
|
|
417
804
|
const mode = result.dry_run ? 'DRY-RUN (pass --apply to execute)' : 'APPLIED';
|
|
418
|
-
const lines = [`Hygiene clean — ${mode}`, ''];
|
|
805
|
+
const lines = [`Hygiene clean — ${mode}`, renderScope(result), ''];
|
|
419
806
|
for (const a of result.applied) {
|
|
420
|
-
|
|
807
|
+
const where = a.track ? `[${a.track}] ` : '';
|
|
808
|
+
lines.push(` ${a.applied ? '✓' : (result.dry_run ? '·' : '✗')} ${a.action.padEnd(18)} ${where}${a.path} ${a.detail}`);
|
|
421
809
|
}
|
|
422
810
|
for (const s of result.skipped) {
|
|
423
|
-
|
|
811
|
+
const where = s.track ? `[${s.track}] ` : '';
|
|
812
|
+
lines.push(` ! manual ${where}${s.path} ${s.detail}`);
|
|
424
813
|
}
|
|
425
814
|
lines.push('', `fixable: ${result.summary.fixable}, executed: ${result.summary.executed}, failed: ${result.summary.failed}, manual: ${result.summary.manual}`);
|
|
426
815
|
output(result, true, lines.join('\n'));
|
|
@@ -438,6 +827,8 @@ module.exports = {
|
|
|
438
827
|
checkMemoryLogs,
|
|
439
828
|
checkCostLedger,
|
|
440
829
|
checkStaleTraces,
|
|
830
|
+
checkStaleReports,
|
|
831
|
+
checkCachedContext,
|
|
441
832
|
checkPlanningFragment,
|
|
442
833
|
compareVersions,
|
|
443
834
|
cmdHygieneScan,
|