pan-wizard 3.28.0 → 3.29.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/commands/pan/cost.md +14 -2
- package/hooks/dist/pan-check-update.js +4 -0
- package/hooks/dist/pan-cost-logger.js +322 -43
- package/hooks/dist/pan-trace-logger.js +275 -32
- package/package.json +4 -1
- package/pan-wizard-core/bin/lib/commands.cjs +3 -1
- package/pan-wizard-core/bin/lib/constants.cjs +17 -0
- package/pan-wizard-core/bin/lib/context-budget.cjs +10 -0
- package/pan-wizard-core/bin/lib/cost-rebuild.cjs +511 -0
- package/pan-wizard-core/bin/lib/cost.cjs +61 -8
- package/pan-wizard-core/bin/lib/git.cjs +5 -1
- package/pan-wizard-core/bin/lib/hud.cjs +5 -3
- package/pan-wizard-core/bin/lib/hygiene.cjs +22 -25
- package/pan-wizard-core/bin/lib/memory.cjs +14 -8
- package/pan-wizard-core/bin/lib/optimize.cjs +78 -2
- package/pan-wizard-core/bin/lib/utils.cjs +22 -0
- package/pan-wizard-core/bin/lib/verify.cjs +24 -10
- package/pan-wizard-core/bin/pan-tools.cjs +8 -1
- package/pan-wizard-core/workflows/health.md +1 -0
- package/scripts/coverage-gate.cjs +257 -0
- package/scripts/install-git-hooks.js +5 -0
- package/scripts/mutation-probe.cjs +272 -0
- package/scripts/release-check.js +33 -12
- package/scripts/test-quality-lint.cjs +240 -0
- package/scripts/test-surface.cjs +335 -0
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
* campaigns run: runtime installs fall behind the latest version, legacy
|
|
6
6
|
* uppercase planning filenames linger from pre-v2.2 layouts, atomic-write
|
|
7
7
|
* .tmp orphans survive crashes, per-agent memory logs grow past the cap,
|
|
8
|
-
* cost ledgers
|
|
8
|
+
* cost ledgers carry oversum rows (a session's cumulative usage booked to one
|
|
9
|
+
* subagent — pre-v3.12.4 hooks wrote nothing else, and the parent-transcript
|
|
10
|
+
* slice path kept producing them up to v3.28), telemetry
|
|
9
11
|
* trace sessions pile up unboundedly, and stray fragment `.planning/`
|
|
10
12
|
* directories appear where a mapping step once ran.
|
|
11
13
|
*
|
|
@@ -39,7 +41,7 @@ const {
|
|
|
39
41
|
CHARS_PER_TOKEN,
|
|
40
42
|
STATE_FILE,
|
|
41
43
|
} = require('./constants.cjs');
|
|
42
|
-
const { planningPath, planningRel } = require('./utils.cjs');
|
|
44
|
+
const { planningPath, planningRel, detectPlanningModel } = require('./utils.cjs');
|
|
43
45
|
const { detectForeignPlanningTree } = require('./foreign-planning.cjs');
|
|
44
46
|
const { listMemoryAgents, readMemory, compactMemory } = require('./memory.cjs');
|
|
45
47
|
const { readRecords, isSuspectRecord, METRICS_DIR, TOKENS_FILE } = require('./cost.cjs');
|
|
@@ -253,7 +255,8 @@ function recordMass(r) {
|
|
|
253
255
|
}
|
|
254
256
|
|
|
255
257
|
/**
|
|
256
|
-
* H-5: cost ledger dominated by physically implausible
|
|
258
|
+
* H-5: cost ledger dominated by physically implausible records — the oversum
|
|
259
|
+
* signature, a session's cumulative usage booked to one subagent row.
|
|
257
260
|
*
|
|
258
261
|
* Gated on token MASS as well as record count. A count-only gate passes a ledger
|
|
259
262
|
* whose few bad rows carry most of the tokens — field case: 24% of rows suspect
|
|
@@ -286,7 +289,7 @@ function checkCostLedger(cwd) {
|
|
|
286
289
|
: 'token mass';
|
|
287
290
|
findings.push(mkFinding('poisoned-ledger', 'critical',
|
|
288
291
|
planningRel(METRICS_DIR, TOKENS_FILE),
|
|
289
|
-
`${suspect}/${records.length} records suspect (${Math.round(ratio * 100)}% of rows, ${Math.round(massRatio * 100)}% of token mass) —
|
|
292
|
+
`${suspect}/${records.length} records suspect (${Math.round(ratio * 100)}% of rows, ${Math.round(massRatio * 100)}% of token mass) — oversum signature (a session's cumulative usage booked to one subagent row), tripped on ${basis}; aggregates quarantine them but the file is dead weight. Run \`cost rebuild\` first while the session transcripts still exist — quarantining moves the whole ledger aside, and a rebuild afterwards has no rows left to keep`,
|
|
290
293
|
{ action: 'quarantine-ledger' }));
|
|
291
294
|
return { findings };
|
|
292
295
|
}
|
|
@@ -466,7 +469,7 @@ function checkCachedContext(cwd) {
|
|
|
466
469
|
try {
|
|
467
470
|
const ttl = assessCacheTtl(readRecords(cwd).filter(r => !isSuspectRecord(r)));
|
|
468
471
|
if (ttl.recommend) {
|
|
469
|
-
findings.push(mkFinding('cache-context',
|
|
472
|
+
findings.push(mkFinding('cache-context', ttl.severity, planningRel(path.join(METRICS_DIR, TOKENS_FILE)), ttl.advice, null));
|
|
470
473
|
}
|
|
471
474
|
} catch { /* no ledger, or unreadable — nothing to say */ }
|
|
472
475
|
return { findings };
|
|
@@ -494,15 +497,11 @@ function checkPlanningFragment(cwd) {
|
|
|
494
497
|
const dir = planningPath(cwd);
|
|
495
498
|
let entries = [];
|
|
496
499
|
try { entries = fs.readdirSync(dir); } catch { return { findings, planning_exists: false }; }
|
|
497
|
-
|
|
498
|
-
//
|
|
499
|
-
//
|
|
500
|
-
//
|
|
501
|
-
|
|
502
|
-
const SPINE = ['project.md', 'state.md', 'phases', 'roadmap.md', 'requirements.md',
|
|
503
|
-
'milestones', 'focus', 'quick', 'orchestration'];
|
|
504
|
-
const hasSpine = SPINE.some(s => lower.includes(s));
|
|
505
|
-
if (!hasSpine && entries.length > 0) {
|
|
500
|
+
// A spine is whatever marks a deliberate PAN workflow — the phase model, the focus
|
|
501
|
+
// model, or an orchestration campaign (PLANNING_MODEL_MARKERS, shared with
|
|
502
|
+
// `validate health` so the two verbs cannot disagree about a tree). A dir holding
|
|
503
|
+
// only generated artifacts (codebase maps, metrics, traces) is a stray fragment.
|
|
504
|
+
if (detectPlanningModel(dir).model === 'fragment') {
|
|
506
505
|
findings.push(mkFinding('planning-fragment', 'info', planningRootRel(),
|
|
507
506
|
`.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`,
|
|
508
507
|
null));
|
|
@@ -661,16 +660,14 @@ function applyFix(cwd, finding) {
|
|
|
661
660
|
const dest = `${abs}.quarantined-${stamp}`;
|
|
662
661
|
fs.renameSync(abs, dest);
|
|
663
662
|
|
|
664
|
-
// The cursor is a per-
|
|
665
|
-
//
|
|
666
|
-
//
|
|
667
|
-
//
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
cursorNote = ', cursor reset';
|
|
673
|
-
} catch { /* no cursor to reset */ }
|
|
663
|
+
// The cost cursor (COST_CURSOR_FILE) STAYS. It is a per-TRANSCRIPT
|
|
664
|
+
// high-water mark — how many records of each session transcript the
|
|
665
|
+
// hooks have already attributed — not a position in the ledger, so it
|
|
666
|
+
// has nothing to do with the file being moved aside. This step used to
|
|
667
|
+
// delete it as "a fresh ledger must not inherit the old read position",
|
|
668
|
+
// and the next SubagentStop then re-summed every session transcript from
|
|
669
|
+
// line 0: a fresh oversum row the day after each quarantine (field,
|
|
670
|
+
// 2026-08-25 → 08-26). Quarantine and poison had become a loop.
|
|
674
671
|
|
|
675
672
|
// Quarantine leaves a dated copy behind, and nothing else ever removes
|
|
676
673
|
// one. Run hygiene a few times over a year and the metrics dir fills
|
|
@@ -680,7 +677,7 @@ function applyFix(cwd, finding) {
|
|
|
680
677
|
|
|
681
678
|
return {
|
|
682
679
|
applied: true,
|
|
683
|
-
detail: `renamed to ${path.basename(dest)}${
|
|
680
|
+
detail: `renamed to ${path.basename(dest)}${prunedNote} — fresh ledger starts clean; transcript cursor kept`,
|
|
684
681
|
};
|
|
685
682
|
}
|
|
686
683
|
default:
|
|
@@ -301,11 +301,17 @@ function selectMemory(cwd, agent, opts = {}) {
|
|
|
301
301
|
/**
|
|
302
302
|
* Memory-load telemetry gate (ADR-0036 acceptance signal). Estimates the tokens
|
|
303
303
|
* of memory that would be injected whole (every agent log) and compares to the
|
|
304
|
-
* median per-agent
|
|
304
|
+
* median per-agent PROMPT from the trustworthy cost ledger (suspect records
|
|
305
305
|
* quarantined). Read-only, non-blocking; degrades to an absolute-token check
|
|
306
306
|
* when the ledger is thin.
|
|
307
307
|
*
|
|
308
|
-
*
|
|
308
|
+
* The prompt is `input + cache_read + cache_write`, not `input` alone: under prompt
|
|
309
|
+
* caching the uncached remainder is tens of tokens, so dividing by it reported 1.8k
|
|
310
|
+
* of memory as 8,940% of a "median agent input" and called it critical (field sweep
|
|
311
|
+
* 2026-09-17). Memory is injected into the whole prompt, so the whole prompt is what
|
|
312
|
+
* it must be measured against.
|
|
313
|
+
*
|
|
314
|
+
* @returns {{memory_tokens, agents, median_prompt_tokens, fraction, status, advisory}}
|
|
309
315
|
*/
|
|
310
316
|
function memoryLoadBudget(cwd, opts = {}) {
|
|
311
317
|
const { agents } = listMemoryAgents(cwd);
|
|
@@ -317,12 +323,12 @@ function memoryLoadBudget(cwd, opts = {}) {
|
|
|
317
323
|
let median = null;
|
|
318
324
|
try {
|
|
319
325
|
const cost = require('./cost.cjs');
|
|
320
|
-
const
|
|
321
|
-
.filter(r => !cost.isSuspectRecord(r))
|
|
322
|
-
.map(r => Number(r.input_tokens) || 0)
|
|
326
|
+
const prompts = (cost.readRecords(cwd) || [])
|
|
327
|
+
.filter(r => !cost.isSuspectRecord(r) && !cost.isEmptyRecord(r))
|
|
328
|
+
.map(r => (Number(r.input_tokens) || 0) + (Number(r.cache_read_tokens) || 0) + (Number(r.cache_write_tokens) || 0))
|
|
323
329
|
.filter(n => n > 0)
|
|
324
330
|
.sort((a, b) => a - b);
|
|
325
|
-
if (
|
|
331
|
+
if (prompts.length) median = prompts[Math.floor(prompts.length / 2)];
|
|
326
332
|
} catch { /* thin/absent ledger — absolute-token check only */ }
|
|
327
333
|
|
|
328
334
|
const fraction = median ? memoryTokens / median : null;
|
|
@@ -335,9 +341,9 @@ function memoryLoadBudget(cwd, opts = {}) {
|
|
|
335
341
|
const advisory = status === 'ok'
|
|
336
342
|
? 'Memory-load within budget.'
|
|
337
343
|
: `Memory injection is ~${memoryTokens} tokens across ${agents.length} agent log(s)` +
|
|
338
|
-
(fraction != null ? ` (~${Math.round(fraction * 100)}% of median agent
|
|
344
|
+
(fraction != null ? ` (~${Math.round(fraction * 100)}% of a median agent prompt)` : '') +
|
|
339
345
|
`. Bound it with cue-scoped 'memory select' or trim with 'memory compact <agent>'.`;
|
|
340
|
-
return { memory_tokens: memoryTokens, agents: agents.length,
|
|
346
|
+
return { memory_tokens: memoryTokens, agents: agents.length, median_prompt_tokens: median, fraction, status, advisory };
|
|
341
347
|
}
|
|
342
348
|
|
|
343
349
|
// ─── CLI command wrappers ────────────────────────────────────────────────────
|
|
@@ -134,9 +134,82 @@ function getCurrentSessionId(cwd) {
|
|
|
134
134
|
}
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Day-scoped auto-session id, the same shape the trace hook mints
|
|
139
|
+
* (`sess_auto_YYYYMMDD`) so a day's hook-written and agent-reported events share one
|
|
140
|
+
* session instead of splitting into two.
|
|
141
|
+
*/
|
|
142
|
+
function autoSessionId(now = new Date()) {
|
|
143
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
144
|
+
return `sess_auto_${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* How long a `current-session` pointer is evidence of a live session. An explicit
|
|
149
|
+
* (non-auto) session used to stay "current" indefinitely — a July session was still
|
|
150
|
+
* current in September in a field project, so `readActiveSessionMeta` in the cost hook
|
|
151
|
+
* backfilled two-month-old command/phase onto today's ledger rows, and agent-reported
|
|
152
|
+
* events would have landed in a long-dead session's directory (field sweep 2026-09-17).
|
|
153
|
+
*/
|
|
154
|
+
const SESSION_STALE_MS = 24 * 60 * 60 * 1000;
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Last time anything was written to a session: its event log if it has one, else the
|
|
158
|
+
* moment it started. Null when the session cannot be read at all.
|
|
159
|
+
*/
|
|
160
|
+
function sessionLastActivityMs(cwd, sid) {
|
|
161
|
+
const dir = path.join(getTracesDir(cwd), sid);
|
|
162
|
+
try {
|
|
163
|
+
return fs.statSync(path.join(dir, TRACE_EVENT_FILE)).mtimeMs;
|
|
164
|
+
} catch { /* no events yet */ }
|
|
165
|
+
try {
|
|
166
|
+
const meta = JSON.parse(fs.readFileSync(path.join(dir, OPT_SESSION_FILE), 'utf-8'));
|
|
167
|
+
const t = new Date(meta.started_at).getTime();
|
|
168
|
+
return Number.isFinite(t) ? t : null;
|
|
169
|
+
} catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Is this session finished, or too old to still be the one running? Read-only —
|
|
176
|
+
* finalizing a stale session is the writing path's job (the trace hook's rollover).
|
|
177
|
+
*/
|
|
178
|
+
function isSessionStale(cwd, sid, now = Date.now()) {
|
|
179
|
+
const dir = path.join(getTracesDir(cwd), sid);
|
|
180
|
+
try {
|
|
181
|
+
const meta = JSON.parse(fs.readFileSync(path.join(dir, OPT_SESSION_FILE), 'utf-8'));
|
|
182
|
+
if (meta && meta.ended_at) return true;
|
|
183
|
+
} catch { /* unreadable meta — fall through to the age test */ }
|
|
184
|
+
const last = sessionLastActivityMs(cwd, sid);
|
|
185
|
+
if (last === null) return true;
|
|
186
|
+
return now - last > SESSION_STALE_MS;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Append one trace event.
|
|
191
|
+
*
|
|
192
|
+
* Creates the day's auto-session when none is active. It used to return false instead,
|
|
193
|
+
* and the 16 `optimize trace log` call sites in the workflows are fire-and-forget
|
|
194
|
+
* (`2>/dev/null || true`), so on the phase pipeline — which never starts a trace
|
|
195
|
+
* session — every agent-reported event was silently discarded. Across fourteen field
|
|
196
|
+
* projects the instrument held 3,737 events, 99.7% of them the completion rows the hook
|
|
197
|
+
* writes, and not one error, gap or correction in its whole history (sweep 2026-09-17).
|
|
198
|
+
* An explicit `--session` is still honoured verbatim.
|
|
199
|
+
*/
|
|
137
200
|
function logTraceEvent(cwd, event, sessionId) {
|
|
138
|
-
|
|
139
|
-
|
|
201
|
+
// An explicit id is honoured verbatim. A POINTER, by contrast, is only evidence while
|
|
202
|
+
// the session it names is alive; a dead one is no session at all.
|
|
203
|
+
let sid = sessionId || null;
|
|
204
|
+
if (!sid) {
|
|
205
|
+
const current = getCurrentSessionId(cwd);
|
|
206
|
+
if (current && !isSessionStale(cwd, current)) sid = current;
|
|
207
|
+
}
|
|
208
|
+
if (!sid) {
|
|
209
|
+
const created = initTraceSession(cwd, { sessionId: autoSessionId(), description: 'auto-session (day-scoped)' });
|
|
210
|
+
if (!created || created.error) return false;
|
|
211
|
+
sid = created.session_id;
|
|
212
|
+
}
|
|
140
213
|
|
|
141
214
|
try {
|
|
142
215
|
const sessionDir = path.join(getTracesDir(cwd), sid);
|
|
@@ -1290,6 +1363,9 @@ module.exports = {
|
|
|
1290
1363
|
TRACE_EVENT_FILE,
|
|
1291
1364
|
OPT_SESSION_FILE,
|
|
1292
1365
|
CURRENT_SESSION_FILE,
|
|
1366
|
+
autoSessionId,
|
|
1367
|
+
isSessionStale,
|
|
1368
|
+
SESSION_STALE_MS,
|
|
1293
1369
|
EVENT_TYPES,
|
|
1294
1370
|
IMPACT_LEVELS,
|
|
1295
1371
|
VALID_SCOPES,
|
|
@@ -182,10 +182,32 @@ function hasBraveSearchKey() {
|
|
|
182
182
|
return fileAccessible(path.join(os.homedir(), '.pan-wizard', 'brave_api_key'));
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
+
/**
|
|
186
|
+
* Which workflow model a planning tree runs (PLANNING_MODEL_MARKERS). The phase model
|
|
187
|
+
* wins when its markers are present, since a phase project may also hold focus
|
|
188
|
+
* artifacts; `fragment` means entries exist but none of them mark a deliberate
|
|
189
|
+
* workflow, and `absent` that the directory could not be read.
|
|
190
|
+
*
|
|
191
|
+
* @param {string} planningDir - absolute path to the tree (e.g. planningPath(cwd))
|
|
192
|
+
* @returns {{model: 'phase'|'focus'|'campaign'|'fragment'|'empty'|'absent', evidence: string[], entries: number}}
|
|
193
|
+
*/
|
|
194
|
+
function detectPlanningModel(planningDir) {
|
|
195
|
+
const { PLANNING_MODEL_MARKERS } = require('./constants.cjs');
|
|
196
|
+
let entries;
|
|
197
|
+
try { entries = fs.readdirSync(planningDir); } catch { return { model: 'absent', evidence: [], entries: 0 }; }
|
|
198
|
+
const lower = new Set(entries.map(e => String(e).toLowerCase()));
|
|
199
|
+
for (const model of ['phase', 'focus', 'campaign']) {
|
|
200
|
+
const evidence = PLANNING_MODEL_MARKERS[model].filter(m => lower.has(m));
|
|
201
|
+
if (evidence.length) return { model, evidence, entries: entries.length };
|
|
202
|
+
}
|
|
203
|
+
return { model: entries.length ? 'fragment' : 'empty', evidence: [], entries: entries.length };
|
|
204
|
+
}
|
|
205
|
+
|
|
185
206
|
module.exports = {
|
|
186
207
|
readJsonFile,
|
|
187
208
|
removeQuotes,
|
|
188
209
|
planningPath,
|
|
210
|
+
detectPlanningModel,
|
|
189
211
|
planningRel,
|
|
190
212
|
phasesPath,
|
|
191
213
|
milestonesPath,
|
|
@@ -14,7 +14,7 @@ const {
|
|
|
14
14
|
PLAN_SUFFIX, SUMMARY_SUFFIX, STANDARDS_FILE, STANDARDS_CATALOG, HEALTH_STATUS,
|
|
15
15
|
BUILTIN_DRIFT_RULES, DRIFT_VERDICTS, BINARY_EXTENSIONS, DRIFT_MAX_FILES, DRIFT_MAX_FILE_SIZE, DRIFT_SEVERITY_WEIGHTS,
|
|
16
16
|
} = require('./constants.cjs');
|
|
17
|
-
const { planningPath, phasesPath, filterPlanFiles, filterSummaryFiles, fileAccessible } = require('./utils.cjs');
|
|
17
|
+
const { planningPath, phasesPath, filterPlanFiles, filterSummaryFiles, fileAccessible, detectPlanningModel } = require('./utils.cjs');
|
|
18
18
|
const { detectForeignPlanningTree } = require('./foreign-planning.cjs');
|
|
19
19
|
// Drift detection lives in verify-drift.cjs; re-exported below so consumers of
|
|
20
20
|
// verify.cjs are unaffected by the decomposition.
|
|
@@ -1320,19 +1320,33 @@ function cmdValidateHealth(cwd, options, raw) {
|
|
|
1320
1320
|
return;
|
|
1321
1321
|
}
|
|
1322
1322
|
|
|
1323
|
+
// Check 1c: which workflow model is this tree running? Checks 2-8b below are the
|
|
1324
|
+
// PHASE model's — a focus-model project (`/pan:focus`, no project/roadmap/state by
|
|
1325
|
+
// design) and an orchestration campaign would each fail all of them and be called
|
|
1326
|
+
// broken, which is what eight of fourteen field projects hit (sweep 2026-09-17).
|
|
1327
|
+
// config.json is the one check every model shares.
|
|
1328
|
+
const shape = detectPlanningModel(planningPath(cwd));
|
|
1329
|
+
const phaseModel = shape.model === 'phase' || shape.model === 'fragment' || shape.model === 'empty';
|
|
1330
|
+
|
|
1323
1331
|
// Checks 2-8: individual structure and consistency checks
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1332
|
+
if (phaseModel) {
|
|
1333
|
+
checkProjectFile(cwd, addIssue);
|
|
1334
|
+
checkRoadmapFile(cwd, addIssue);
|
|
1335
|
+
checkStateFile(cwd, addIssue, repairs);
|
|
1336
|
+
} else {
|
|
1337
|
+
addIssue('info', 'I003', `${shape.model}-model project (${shape.evidence.join(', ')}) — the phase-model checks (project.md, roadmap.md, state.md, phases/) do not apply`, null);
|
|
1338
|
+
}
|
|
1327
1339
|
checkConfigFile(cwd, addIssue, repairs);
|
|
1328
|
-
|
|
1329
|
-
|
|
1340
|
+
if (phaseModel) {
|
|
1341
|
+
checkPhaseDirectories(cwd, addIssue);
|
|
1342
|
+
checkPhaseContents(cwd, addIssue);
|
|
1330
1343
|
|
|
1331
|
-
|
|
1332
|
-
|
|
1344
|
+
// Check 8b: cross-document state consistency
|
|
1345
|
+
checkStateConsistency(cwd, addIssue, repairs);
|
|
1333
1346
|
|
|
1334
|
-
|
|
1335
|
-
|
|
1347
|
+
// Check 8c: verification gate (phases with verifier enabled need verification.md)
|
|
1348
|
+
checkVerificationGate(cwd, addIssue);
|
|
1349
|
+
}
|
|
1336
1350
|
|
|
1337
1351
|
// Check 9 (optional): standards compliance
|
|
1338
1352
|
if (options.standards) {
|
|
@@ -203,6 +203,7 @@ const codebase = require('./lib/codebase.cjs');
|
|
|
203
203
|
const memory = require('./lib/memory.cjs');
|
|
204
204
|
const bus = require('./lib/bus.cjs');
|
|
205
205
|
const cost = require('./lib/cost.cjs');
|
|
206
|
+
const costRebuild = require('./lib/cost-rebuild.cjs');
|
|
206
207
|
const preview = require('./lib/preview.cjs');
|
|
207
208
|
const reviewDeep = require('./lib/review-deep.cjs');
|
|
208
209
|
const knowledge = require('./lib/knowledge.cjs');
|
|
@@ -1207,8 +1208,14 @@ async function main() {
|
|
|
1207
1208
|
cost.cmdCostAppend(cwd, rec, raw);
|
|
1208
1209
|
} else if (subcommand === 'clear') {
|
|
1209
1210
|
cost.cmdCostClear(cwd, raw);
|
|
1211
|
+
} else if (subcommand === 'rebuild') {
|
|
1212
|
+
costRebuild.cmdCostRebuild(cwd, {
|
|
1213
|
+
apply: args.includes('--apply'),
|
|
1214
|
+
mainThread: !args.includes('--no-main-thread'),
|
|
1215
|
+
claudeDir: getArgValue(args, '--claude-dir'),
|
|
1216
|
+
}, raw);
|
|
1210
1217
|
} else {
|
|
1211
|
-
error('Unknown cost subcommand. Available: report, append, clear');
|
|
1218
|
+
error('Unknown cost subcommand. Available: report, append, clear, rebuild');
|
|
1212
1219
|
}
|
|
1213
1220
|
break;
|
|
1214
1221
|
}
|
|
@@ -162,6 +162,7 @@ Report final status.
|
|
|
162
162
|
| W007 | warning | Phase on disk but not in ROADMAP | No |
|
|
163
163
|
| I001 | info | Plan without SUMMARY (may be in progress) | No |
|
|
164
164
|
| I002 | info | Phase in ROADMAP ahead of current phase, not planned yet | No |
|
|
165
|
+
| I003 | info | Focus-model or campaign tree — the phase-model checks do not apply | No |
|
|
165
166
|
| STATE_REQ_DRIFT | warning | state.md complete but REQUIREMENTS.md has unchecked boxes | Yes |
|
|
166
167
|
| STATE_ROADMAP_DRIFT | warning | state.md complete but roadmap.md has unchecked plan boxes | Yes |
|
|
167
168
|
| VERIFICATION_GATE_MISSING | warning | Phase has completed plans but no verification record | No |
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
/**
|
|
4
|
+
* coverage-gate.cjs — did the shipped code actually run under the tests?
|
|
5
|
+
*
|
|
6
|
+
* Runs the whole suite under Node's own coverage instrumentation (no dependency:
|
|
7
|
+
* `node --test --experimental-test-coverage`, lcov reporter; the child processes
|
|
8
|
+
* the tests spawn — pan-tools, the installer, the hooks — are captured through the
|
|
9
|
+
* inherited NODE_V8_COVERAGE), then enforces:
|
|
10
|
+
* - line and function floors overall and per module group (tests/fixtures/
|
|
11
|
+
* coverage-policy.json — a floor sits a point below the measured baseline, so
|
|
12
|
+
* a real regression fails and normal churn does not);
|
|
13
|
+
* - every dispatcher `case` arm executed at least once — the binary rule that
|
|
14
|
+
* catches "a verb no test dispatches", which a percentage hides. An arm may be
|
|
15
|
+
* allowlisted in the policy with a reason (interactive, network, a P2 item).
|
|
16
|
+
* The never-called functions are printed, ranked, so a gap has a name.
|
|
17
|
+
*
|
|
18
|
+
* node scripts/coverage-gate.cjs run the suite, evaluate, exit 1 on a violation
|
|
19
|
+
* node scripts/coverage-gate.cjs --lcov f evaluate an existing lcov file (no run)
|
|
20
|
+
* node scripts/coverage-gate.cjs --json machine-readable result
|
|
21
|
+
*
|
|
22
|
+
* Node < 22 lacks the coverage include/exclude flags: the gate reports "skipped"
|
|
23
|
+
* and exits 0 there, so the 18/20 CI jobs stay green and the 22 job carries it.
|
|
24
|
+
* Wired as release-check Gate 9 and as an advisory CI step on the Node 22 job.
|
|
25
|
+
*/
|
|
26
|
+
const fs = require('fs');
|
|
27
|
+
const os = require('os');
|
|
28
|
+
const path = require('path');
|
|
29
|
+
const { spawnSync } = require('child_process');
|
|
30
|
+
const { parseCaseArms } = require('./test-surface.cjs');
|
|
31
|
+
|
|
32
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
33
|
+
const POLICY_REL = path.join('tests', 'fixtures', 'coverage-policy.json');
|
|
34
|
+
const DISPATCHER_REL = 'pan-wizard-core/bin/pan-tools.cjs';
|
|
35
|
+
const TEST_DIRS = ['tests', 'tests/scenarios'];
|
|
36
|
+
const INCLUDE = ['pan-wizard-core/**/*.cjs', 'pan-wizard-core/**/*.js', 'bin/**', 'hooks/*.js', 'scripts/**'];
|
|
37
|
+
const EXCLUDE = ['tests/**', '**/node_modules/**'];
|
|
38
|
+
const MIN_NODE_MAJOR = 22;
|
|
39
|
+
|
|
40
|
+
const DEFAULT_POLICY = Object.freeze({
|
|
41
|
+
floors: { overall_lines: 92, overall_functions: 93, groups: { lib: 92, installer: 90, hooks: 90, mcp: 95 } },
|
|
42
|
+
arms_allow: [],
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// ─── lcov ───────────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
/** Parse lcov text into [{ path, lines: Map(line→hits), fn: Map(name→line), fnda: Map(name→hits), lf, lh, brf, brh }]. */
|
|
48
|
+
function parseLcov(text) {
|
|
49
|
+
const files = [];
|
|
50
|
+
let cur = null;
|
|
51
|
+
for (const raw of String(text || '').split(/\r?\n/)) {
|
|
52
|
+
const ci = raw.indexOf(':');
|
|
53
|
+
const k = ci >= 0 ? raw.slice(0, ci) : raw;
|
|
54
|
+
const v = ci >= 0 ? raw.slice(ci + 1) : '';
|
|
55
|
+
if (k === 'SF') { cur = { path: v.split('\\').join('/'), lines: new Map(), fn: new Map(), fnda: new Map(), lf: 0, lh: 0, brf: 0, brh: 0 }; continue; }
|
|
56
|
+
if (!cur) continue;
|
|
57
|
+
if (k === 'DA') { const [ln, c] = v.split(',').map(Number); cur.lines.set(ln, c); }
|
|
58
|
+
else if (k === 'FN') { const i = v.indexOf(','); cur.fn.set(v.slice(i + 1), Number(v.slice(0, i))); }
|
|
59
|
+
else if (k === 'FNDA') { const i = v.indexOf(','); cur.fnda.set(v.slice(i + 1), Number(v.slice(0, i))); }
|
|
60
|
+
else if (k === 'LF') cur.lf = Number(v); else if (k === 'LH') cur.lh = Number(v);
|
|
61
|
+
else if (k === 'BRF') cur.brf = Number(v); else if (k === 'BRH') cur.brh = Number(v);
|
|
62
|
+
else if (raw === 'end_of_record') { files.push(cur); cur = null; }
|
|
63
|
+
}
|
|
64
|
+
return files;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function relPath(p, root = ROOT) {
|
|
68
|
+
const r = root.split('\\').join('/');
|
|
69
|
+
const i = p.indexOf(r);
|
|
70
|
+
return i >= 0 ? p.slice(i + r.length).replace(/^\//, '') : p;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function groupOf(rel) {
|
|
74
|
+
if (/^pan-wizard-core\/bin\/lib\//.test(rel)) return 'lib';
|
|
75
|
+
if (/^pan-wizard-core\/mcp\//.test(rel)) return 'mcp';
|
|
76
|
+
if (/^pan-wizard-core\/bin\//.test(rel)) return 'cli';
|
|
77
|
+
if (/^pan-wizard-core\/workflows\//.test(rel)) return 'native-workflows';
|
|
78
|
+
if (/^bin\//.test(rel)) return 'installer';
|
|
79
|
+
if (/^hooks\//.test(rel)) return 'hooks';
|
|
80
|
+
if (/^scripts\//.test(rel)) return 'scripts';
|
|
81
|
+
return 'other';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Which case arms executed. An arm is executed when the first instrumented line
|
|
86
|
+
* after its label (before the next arm at the same or a shallower indent) ran; a
|
|
87
|
+
* label immediately followed by another label shares that arm's body (fallthrough).
|
|
88
|
+
*/
|
|
89
|
+
function armCoverage(dispatcherSrc, dispatcherFile) {
|
|
90
|
+
const arms = parseCaseArms(dispatcherSrc);
|
|
91
|
+
const byLine = new Map(arms.map((a) => [a.line, a]));
|
|
92
|
+
const lines = dispatcherSrc.split(/\r?\n/);
|
|
93
|
+
const da = dispatcherFile ? dispatcherFile.lines : new Map();
|
|
94
|
+
const result = [];
|
|
95
|
+
for (const a of arms) {
|
|
96
|
+
let verdict = null;
|
|
97
|
+
for (let ln = a.line + 1; ln <= lines.length; ln++) {
|
|
98
|
+
const nxt = byLine.get(ln);
|
|
99
|
+
if (nxt && nxt.indent <= a.indent) {
|
|
100
|
+
if (nxt.indent === a.indent && /^\s*case\s+'/.test(lines[ln - 1])) verdict = 'fallthrough';
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
if (da.has(ln)) { verdict = da.get(ln) > 0; break; }
|
|
104
|
+
}
|
|
105
|
+
result.push({ id: a.parent ? `${a.parent} > ${a.label}` : a.label, line: a.line, verdict });
|
|
106
|
+
}
|
|
107
|
+
// A fallthrough label takes the verdict of the arm it shares.
|
|
108
|
+
for (let i = 0; i < result.length; i++) {
|
|
109
|
+
if (result[i].verdict === 'fallthrough') {
|
|
110
|
+
let j = i + 1;
|
|
111
|
+
while (j < result.length && result[j].verdict === 'fallthrough') j++;
|
|
112
|
+
result[i].verdict = j < result.length ? result[j].verdict : null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return result;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function validatePolicy(policy) {
|
|
119
|
+
const errors = [];
|
|
120
|
+
for (const a of policy.arms_allow || []) {
|
|
121
|
+
if (!a || typeof a.arm !== 'string') errors.push(`arms_allow entry without an arm: ${JSON.stringify(a)}`);
|
|
122
|
+
else if (!a.reason || !String(a.reason).trim()) errors.push(`arms_allow entry "${a.arm}" has no reason`);
|
|
123
|
+
}
|
|
124
|
+
return errors;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Evaluate parsed lcov against the policy and the dispatcher source.
|
|
129
|
+
* Pure. Returns { ok, violations[], groups, overall, arms, never_called, policy_errors }.
|
|
130
|
+
*/
|
|
131
|
+
function evaluateCoverage(lcovFiles, { dispatcherSrc, policy = DEFAULT_POLICY, root = ROOT } = {}) {
|
|
132
|
+
const violations = [];
|
|
133
|
+
const policyErrors = validatePolicy(policy);
|
|
134
|
+
violations.push(...policyErrors.map((e) => `policy: ${e}`));
|
|
135
|
+
if (!lcovFiles.length) violations.push('lcov parsed no files — malformed or empty coverage output (failing closed)');
|
|
136
|
+
|
|
137
|
+
const groups = {};
|
|
138
|
+
const overall = { lf: 0, lh: 0, ff: 0, fh: 0 };
|
|
139
|
+
const neverCalled = [];
|
|
140
|
+
let dispatcherFile = null;
|
|
141
|
+
for (const f of lcovFiles) {
|
|
142
|
+
const rel = relPath(f.path, root);
|
|
143
|
+
if (rel.endsWith(DISPATCHER_REL) || rel === DISPATCHER_REL) dispatcherFile = f;
|
|
144
|
+
const g = groupOf(rel);
|
|
145
|
+
const fns = [...f.fn.keys()];
|
|
146
|
+
const fh = fns.filter((n) => (f.fnda.get(n) || 0) > 0).length;
|
|
147
|
+
const acc = groups[g] = groups[g] || { files: 0, lf: 0, lh: 0, ff: 0, fh: 0 };
|
|
148
|
+
acc.files++; acc.lf += f.lf; acc.lh += f.lh; acc.ff += fns.length; acc.fh += fh;
|
|
149
|
+
overall.lf += f.lf; overall.lh += f.lh; overall.ff += fns.length; overall.fh += fh;
|
|
150
|
+
for (const n of fns) if (!((f.fnda.get(n) || 0) > 0)) neverCalled.push(`${rel}:${f.fn.get(n)} ${n || '(anonymous)'}`);
|
|
151
|
+
}
|
|
152
|
+
const pct = (h, t) => (t ? Math.round((h / t) * 1000) / 10 : 100);
|
|
153
|
+
const overallPct = { lines: pct(overall.lh, overall.lf), functions: pct(overall.fh, overall.ff) };
|
|
154
|
+
const floors = policy.floors || DEFAULT_POLICY.floors;
|
|
155
|
+
if (lcovFiles.length) {
|
|
156
|
+
if (overallPct.lines < floors.overall_lines) violations.push(`overall line coverage ${overallPct.lines}% is below the floor ${floors.overall_lines}%`);
|
|
157
|
+
if (overallPct.functions < floors.overall_functions) violations.push(`overall function coverage ${overallPct.functions}% is below the floor ${floors.overall_functions}%`);
|
|
158
|
+
for (const [g, floor] of Object.entries(floors.groups || {})) {
|
|
159
|
+
const acc = groups[g];
|
|
160
|
+
if (!acc) { violations.push(`group "${g}" has no files under coverage — include globs or layout changed`); continue; }
|
|
161
|
+
const p = pct(acc.lh, acc.lf);
|
|
162
|
+
if (p < floor) violations.push(`${g} line coverage ${p}% is below the floor ${floor}%`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let arms = [];
|
|
167
|
+
if (dispatcherSrc) {
|
|
168
|
+
arms = armCoverage(dispatcherSrc, dispatcherFile);
|
|
169
|
+
const allow = new Map((policy.arms_allow || []).map((a) => [a.arm, a.reason]));
|
|
170
|
+
if (!dispatcherFile && lcovFiles.length) violations.push('the dispatcher was not in the coverage output — no test ran pan-tools.cjs?');
|
|
171
|
+
for (const a of arms) {
|
|
172
|
+
if (a.verdict === true) continue;
|
|
173
|
+
if (allow.has(a.id)) { a.allowlisted = allow.get(a.id); continue; }
|
|
174
|
+
violations.push(`dispatcher arm never executed: ${a.id} (line ${a.line}) — add a test that dispatches it, or allowlist it in ${POLICY_REL} with a reason`);
|
|
175
|
+
}
|
|
176
|
+
for (const [arm] of allow) if (!arms.some((a) => a.id === arm)) violations.push(`policy: arms_allow names an arm that no longer exists: ${arm}`);
|
|
177
|
+
for (const [arm, reason] of allow) { const a = arms.find((x) => x.id === arm); if (a && a.verdict === true) violations.push(`policy: arm "${arm}" is executed now — remove its allowlist entry (${reason})`); }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const groupTable = Object.fromEntries(Object.entries(groups).map(([g, a]) => [g, { files: a.files, lines: pct(a.lh, a.lf), functions: pct(a.fh, a.ff) }]));
|
|
181
|
+
return { ok: violations.length === 0, violations, overall: overallPct, groups: groupTable, arms, never_called: neverCalled.sort(), policy_errors: policyErrors };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ─── Running the suite ──────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
function expandTestFiles(root = ROOT, dirs = TEST_DIRS) {
|
|
187
|
+
const files = [];
|
|
188
|
+
for (const dir of dirs) {
|
|
189
|
+
const abs = path.join(root, dir);
|
|
190
|
+
let entries = [];
|
|
191
|
+
try { entries = fs.readdirSync(abs); } catch { continue; }
|
|
192
|
+
for (const f of entries) if (f.endsWith('.test.cjs')) files.push(path.join(abs, f));
|
|
193
|
+
}
|
|
194
|
+
return files.sort();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function runSuiteWithCoverage(root = ROOT, lcovPath) {
|
|
198
|
+
const specLog = lcovPath + '.spec.log';
|
|
199
|
+
const args = ['--test', '--experimental-test-coverage'];
|
|
200
|
+
for (const g of INCLUDE) args.push(`--test-coverage-include=${g}`);
|
|
201
|
+
for (const g of EXCLUDE) args.push(`--test-coverage-exclude=${g}`);
|
|
202
|
+
args.push('--test-reporter=lcov', `--test-reporter-destination=${lcovPath}`, '--test-reporter=spec', `--test-reporter-destination=${specLog}`);
|
|
203
|
+
args.push(...expandTestFiles(root));
|
|
204
|
+
const r = spawnSync(process.execPath, args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 64 * 1024 * 1024 });
|
|
205
|
+
return { status: r.status, specLog, stderr: r.stderr || '' };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function loadPolicy(root = ROOT) {
|
|
209
|
+
try { return JSON.parse(fs.readFileSync(path.join(root, POLICY_REL), 'utf8')); } catch { return DEFAULT_POLICY; }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function render(result) {
|
|
213
|
+
const lines = [];
|
|
214
|
+
lines.push(`coverage gate — ${result.ok ? 'OK' : 'FAIL'}`);
|
|
215
|
+
lines.push(` overall: lines ${result.overall.lines}% · functions ${result.overall.functions}%`);
|
|
216
|
+
for (const [g, a] of Object.entries(result.groups).sort()) lines.push(` ${g.padEnd(18)} files ${String(a.files).padStart(3)} lines ${String(a.lines).padStart(5)}% functions ${String(a.functions).padStart(5)}%`);
|
|
217
|
+
const executed = result.arms.filter((a) => a.verdict === true).length;
|
|
218
|
+
const allowed = result.arms.filter((a) => a.allowlisted).length;
|
|
219
|
+
lines.push(` dispatcher arms: ${executed}/${result.arms.length} executed${allowed ? `, ${allowed} allowlisted` : ''}`);
|
|
220
|
+
if (result.never_called.length) {
|
|
221
|
+
lines.push(` never-called functions: ${result.never_called.length} (first 12)`);
|
|
222
|
+
for (const n of result.never_called.slice(0, 12)) lines.push(` ${n}`);
|
|
223
|
+
}
|
|
224
|
+
for (const v of result.violations) lines.push(` ✖ ${v}`);
|
|
225
|
+
return lines.join('\n');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function main(argv) {
|
|
229
|
+
const major = Number(process.versions.node.split('.')[0]);
|
|
230
|
+
const lcovArg = argv.includes('--lcov') ? argv[argv.indexOf('--lcov') + 1] : null;
|
|
231
|
+
if (!lcovArg && major < MIN_NODE_MAJOR) {
|
|
232
|
+
console.log(`coverage gate — skipped on Node ${process.versions.node} (needs ${MIN_NODE_MAJOR}+ for coverage include/exclude flags)`);
|
|
233
|
+
return 0;
|
|
234
|
+
}
|
|
235
|
+
let lcovPath = lcovArg;
|
|
236
|
+
let tmp = null;
|
|
237
|
+
if (!lcovPath) {
|
|
238
|
+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pan-coverage-'));
|
|
239
|
+
lcovPath = path.join(tmp, 'coverage.lcov');
|
|
240
|
+
const r = runSuiteWithCoverage(ROOT, lcovPath);
|
|
241
|
+
if (r.status !== 0) {
|
|
242
|
+
console.error(`coverage gate — the suite itself failed (exit ${r.status}); see ${r.specLog}`);
|
|
243
|
+
return 1;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
let text = '';
|
|
247
|
+
try { text = fs.readFileSync(lcovPath, 'utf8'); } catch (e) { console.error(`coverage gate — cannot read ${lcovPath}: ${e.message}`); return 1; }
|
|
248
|
+
const result = evaluateCoverage(parseLcov(text), { dispatcherSrc: fs.readFileSync(path.join(ROOT, DISPATCHER_REL), 'utf8'), policy: loadPolicy(ROOT), root: ROOT });
|
|
249
|
+
if (argv.includes('--json')) console.log(JSON.stringify(result, null, 2));
|
|
250
|
+
else console.log(render(result));
|
|
251
|
+
if (tmp && !argv.includes('--keep')) { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* best-effort */ } }
|
|
252
|
+
return result.ok ? 0 : 1;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (require.main === module) process.exit(main(process.argv.slice(2)));
|
|
256
|
+
|
|
257
|
+
module.exports = { parseLcov, groupOf, armCoverage, evaluateCoverage, validatePolicy, expandTestFiles, render, DEFAULT_POLICY, POLICY_REL, INCLUDE, EXCLUDE, MIN_NODE_MAJOR };
|
|
@@ -52,6 +52,11 @@ try {
|
|
|
52
52
|
|
|
53
53
|
// 3. Confirm the hook file is executable on Unix. On Windows the bit doesn't
|
|
54
54
|
// matter — Git Bash treats `.sh` and shebanged scripts as executable.
|
|
55
|
+
// The file is TRACKED as 100755, so this is a safety net rather than the source
|
|
56
|
+
// of truth: it used to be tracked 100644, and since npm ci runs this script
|
|
57
|
+
// through `prepare`, every Linux and macOS checkout was left with a one-bit dirty
|
|
58
|
+
// tree that nothing looked at until CI began asserting the tree is unchanged
|
|
59
|
+
// (2026-09-17).
|
|
55
60
|
const hookFile = path.join(REPO_ROOT, HOOKS_DIR, 'pre-commit');
|
|
56
61
|
if (process.platform !== 'win32') {
|
|
57
62
|
try {
|