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