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.
@@ -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 written by pre-v3.12.4 hooks are 100% poisoned, telemetry
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 (pre-v3.12.4) records.
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) — pre-v3.12.4 oversum signature, tripped on ${basis}; aggregates quarantine them but the file is dead weight`,
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', 'info', planningRel(path.join(METRICS_DIR, TOKENS_FILE)), ttl.advice, null));
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
- const lower = entries.map(e => e.toLowerCase());
498
- // Spine = anything that marks a deliberate PAN workflow: the phase model
499
- // (project/state/phases/roadmap/requirements/milestones) OR the focus model
500
- // (focus/quick) OR an orchestration campaign. A dir holding only generated
501
- // artifacts (codebase maps, metrics, traces) is a stray fragment.
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-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 */ }
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)}${cursorNote}${prunedNote} — fresh ledger starts clean`,
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 input from the trustworthy cost ledger (suspect records
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
- * @returns {{memory_tokens, agents, median_input_tokens, fraction, status, advisory}}
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 inputs = (cost.readRecords(cwd) || [])
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 (inputs.length) median = inputs[Math.floor(inputs.length / 2)];
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 input)` : '') +
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, median_input_tokens: median, fraction, status, advisory };
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
- const sid = sessionId || getCurrentSessionId(cwd);
139
- if (!sid) return false;
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
- checkProjectFile(cwd, addIssue);
1325
- checkRoadmapFile(cwd, addIssue);
1326
- checkStateFile(cwd, addIssue, repairs);
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
- checkPhaseDirectories(cwd, addIssue);
1329
- checkPhaseContents(cwd, addIssue);
1340
+ if (phaseModel) {
1341
+ checkPhaseDirectories(cwd, addIssue);
1342
+ checkPhaseContents(cwd, addIssue);
1330
1343
 
1331
- // Check 8b: cross-document state consistency
1332
- checkStateConsistency(cwd, addIssue, repairs);
1344
+ // Check 8b: cross-document state consistency
1345
+ checkStateConsistency(cwd, addIssue, repairs);
1333
1346
 
1334
- // Check 8c: verification gate (phases with verifier enabled need verification.md)
1335
- checkVerificationGate(cwd, addIssue);
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 {