pan-wizard 3.13.1 → 3.14.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 CHANGED
@@ -576,7 +576,7 @@ PAN is not a replacement for your IDE or AI agent — it's the orchestration lay
576
576
 
577
577
  | Command | What it does |
578
578
  |---------|--------------|
579
- | `/pan:progress` | Where am I? What's next? (supports `health` subformat) |
579
+ | `/pan:progress` | Where am I? What's next? |
580
580
  | `/pan:hud` (alias `/pan:dashboard`) | Render a self-contained HTML dashboard of project + bot-army state to `.planning/hud.html` (`--open`, `--out`, `--stdout`) |
581
581
  | `/pan:help` | Show all commands and usage guide |
582
582
  | `/pan:update` | Update PAN with changelog preview |
@@ -621,7 +621,7 @@ PAN is not a replacement for your IDE or AI agent — it's the orchestration lay
621
621
  | `/pan:todo-check` | List pending todos |
622
622
  | `/pan:debug [desc]` | Systematic debugging with persistent state |
623
623
  | `/pan:quick [--full]` | Execute ad-hoc task with PAN guarantees (`--full` adds plan-checking and verification) |
624
- | `/pan:health [--repair] [--standards] [--full] [--drift] [--links]` | Validate `.planning/` directory integrity. `--repair` auto-fixes; `--standards` checks compliance; `--full` runs tests + build; `--drift` runs convention drift; `--links` attaches doc-code link-graph summary |
624
+ | `/pan:health [--repair]` | Validate `.planning/` directory integrity; `--repair` auto-fixes detected issues |
625
625
  | `/pan:hygiene [--apply] [--trace-age-days N]` | Scan for PAN version drift and stale project artifacts (legacy filenames, .tmp orphans, memory bloat, poisoned cost ledgers, trace debris, fragment planning dirs); `--apply` executes the safe fixes — ledgers are quarantined by rename, never deleted |
626
626
  | `/pan:links [--strict]` | Validate the doc-code link graph: inline `[[<id>]]` refs, `// @pan:` source anchors, `require-code-mention` contracts (ADR-0027, v3.8.0+) |
627
627
  | `/pan:phase-tests [N]` | Generate tests for a completed phase based on UAT criteria |
@@ -719,8 +719,7 @@ These spawn additional agents during planning/execution. They improve quality bu
719
719
  | `workflow.plan_check` | `true` | Verifies plans achieve phase goals before execution |
720
720
  | `workflow.verifier` | `true` | Confirms must-haves were delivered after execution |
721
721
  | `workflow.auto_advance` | `false` | Auto-chain discuss → plan → execute without stopping |
722
- | `workflow.nyquist_validation` | `true` | Map test coverage during planning (Nyquist layer) |
723
- | `workflow.standards_health` | `true` | Include standards compliance in health reports |
722
+ | `workflow.nyquist_validation` | `false` | Map test coverage during planning (Nyquist layer) |
724
723
 
725
724
  Use `/pan:settings` to toggle these, or override per-invocation:
726
725
  - `/pan:plan-phase --skip-research`
@@ -21,6 +21,17 @@ const fs = require('fs');
21
21
  const os = require('os');
22
22
  const path = require('path');
23
23
 
24
+ // Per-user bridge directory inside tmpdir, created 0700 so another user on a
25
+ // shared host can't pre-plant a symlink at a predictable session path or read
26
+ // the bridge files. Both hooks derive the same dir from the same uid, so the
27
+ // statusline→context-monitor IPC channel is preserved.
28
+ function bridgeDir() {
29
+ const uid = (typeof process.getuid === 'function' ? process.getuid() : process.env.USERNAME || 'win');
30
+ const dir = path.join(os.tmpdir(), `pan-hooks-${uid}`);
31
+ try { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); } catch { /* best-effort */ }
32
+ return dir;
33
+ }
34
+
24
35
  const WARNING_THRESHOLD = 35; // remaining_percentage <= 35%
25
36
  const CRITICAL_THRESHOLD = 25; // remaining_percentage <= 25%
26
37
  const STALE_SECONDS = 60; // ignore metrics older than 60s
@@ -38,15 +49,18 @@ process.stdin.on('end', () => {
38
49
  process.exit(0);
39
50
  }
40
51
 
41
- const tmpDir = os.tmpdir();
52
+ const tmpDir = bridgeDir();
42
53
  const metricsPath = path.join(tmpDir, `claude-ctx-${sessionId}.json`);
43
54
 
44
- // If no metrics file, this is a subagent or fresh session -- exit silently
45
- if (!fs.existsSync(metricsPath)) {
55
+ // Read metrics directly; absence (subagent/fresh session) or a corrupt
56
+ // file just means "nothing to warn about" — exit silently. No
57
+ // existsSync-then-read gap.
58
+ let metrics;
59
+ try {
60
+ metrics = JSON.parse(fs.readFileSync(metricsPath, 'utf8'));
61
+ } catch {
46
62
  process.exit(0);
47
63
  }
48
-
49
- const metrics = JSON.parse(fs.readFileSync(metricsPath, 'utf8'));
50
64
  const now = Math.floor(Date.now() / 1000);
51
65
 
52
66
  // Ignore stale metrics
@@ -67,13 +81,11 @@ process.stdin.on('end', () => {
67
81
  let warnData = { callsSinceWarn: 0, lastLevel: null };
68
82
  let firstWarn = true;
69
83
 
70
- if (fs.existsSync(warnPath)) {
71
- try {
72
- warnData = JSON.parse(fs.readFileSync(warnPath, 'utf8'));
73
- firstWarn = false;
74
- } catch (e) {
75
- // Corrupted file, reset
76
- }
84
+ try {
85
+ warnData = JSON.parse(fs.readFileSync(warnPath, 'utf8'));
86
+ firstWarn = false;
87
+ } catch {
88
+ // No prior warning file (or corrupted) — treat as first warning.
77
89
  }
78
90
 
79
91
  warnData.callsSinceWarn = (warnData.callsSinceWarn || 0) + 1;
@@ -39,7 +39,13 @@ function buildStatuslineOutput(data, deps) {
39
39
 
40
40
  if (session && d.skipBridge !== true) {
41
41
  try {
42
- const bridgePath = pathMod.join(tmpDir, `claude-ctx-${session}.json`);
42
+ // Write the bridge file into a per-user 0700 subdir so another user on
43
+ // a shared host can't symlink-attack the predictable session path.
44
+ // Mirrors bridgeDir() in pan-context-monitor.js (the reader).
45
+ const uid = (typeof process.getuid === 'function' ? process.getuid() : process.env.USERNAME || 'win');
46
+ const bridgeSubdir = pathMod.join(tmpDir, `pan-hooks-${uid}`);
47
+ try { fsMod.mkdirSync(bridgeSubdir, { recursive: true, mode: 0o700 }); } catch { /* best-effort */ }
48
+ const bridgePath = pathMod.join(bridgeSubdir, `claude-ctx-${session}.json`);
43
49
  fsMod.writeFileSync(bridgePath, JSON.stringify({
44
50
  session_id: session,
45
51
  remaining_percentage: remaining,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pan-wizard",
3
- "version": "3.13.1",
3
+ "version": "3.14.0",
4
4
  "description": "Command a bot army for your codebase: an Opus Mission Control delegates whole-project goals to specialist squads and ships behind a human merge gate. Five AI CLIs, zero context rot.",
5
5
  "bin": {
6
6
  "pan-wizard": "bin/install.js"
@@ -62,10 +62,10 @@
62
62
  "prepare": "node scripts/install-git-hooks.js",
63
63
  "release:check": "node scripts/release-check.js",
64
64
  "prepublishOnly": "node scripts/release-check.js",
65
- "test": "node --test tests/*.test.cjs",
66
- "test:scenarios": "node --test tests/scenarios/*.test.cjs",
67
- "test:all": "node --test tests/*.test.cjs tests/scenarios/*.test.cjs",
68
- "test:e2e": "node --test tests/scenarios/*.test.cjs",
65
+ "test": "node scripts/run-tests.cjs tests",
66
+ "test:scenarios": "node scripts/run-tests.cjs tests/scenarios",
67
+ "test:all": "node scripts/run-tests.cjs tests tests/scenarios",
68
+ "test:e2e": "node scripts/run-tests.cjs tests/scenarios",
69
69
  "test:vscode": "npx playwright test --config tests/e2e/playwright.config.mjs",
70
70
  "test:watch": "node --test --watch tests/*.test.cjs",
71
71
  "build:plugin": "node scripts/build-plugin.js"
@@ -154,10 +154,23 @@ function cmdConfigSet(cwd, keyPath, value, raw) {
154
154
  // After the loop, `current` points to the parent object and the
155
155
  // final segment is used as the property key for assignment.
156
156
  const keys = keyPath.split('.');
157
+
158
+ // Reject prototype-polluting segments up front so a key path like
159
+ // "__proto__.x" or "constructor.prototype.y" can never walk into or mutate
160
+ // Object.prototype. Inline literal guard (no helper) so the check is
161
+ // unambiguous — every remaining assignment is on a vetted key.
162
+ for (const key of keys) {
163
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
164
+ error(`Invalid config key "${keyPath}": __proto__/constructor/prototype are not allowed`);
165
+ return;
166
+ }
167
+ }
168
+
169
+ // Every segment is now vetted; walk the path building intermediate objects.
157
170
  let current = config;
158
171
  for (let i = 0; i < keys.length - 1; i++) {
159
172
  const key = keys[i];
160
- if (current[key] === undefined || typeof current[key] !== 'object') {
173
+ if (!Object.prototype.hasOwnProperty.call(current, key) || typeof current[key] !== 'object' || current[key] === null) {
161
174
  current[key] = {};
162
175
  }
163
176
  current = current[key];
@@ -159,9 +159,13 @@ function output(result, raw, rawValue) {
159
159
  // Large payloads exceed Claude Code's Bash tool buffer (~50KB).
160
160
  // Write to tmpfile and output the path prefixed with @file: so callers can detect it.
161
161
  if (json.length > MAX_JSON_SIZE) {
162
- const tmpPath = path.join(os.tmpdir(), `pan-${Date.now()}.json`);
162
+ // Create a fresh private directory (mkdtemp → unique, unguessable, owned
163
+ // by us) and write inside it, so a pre-planted file or symlink on a
164
+ // shared tmpdir can't be followed or overwritten.
163
165
  try {
164
- fs.writeFileSync(tmpPath, json, 'utf-8');
166
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pan-'));
167
+ const tmpPath = path.join(tmpDir, 'out.json');
168
+ fs.writeFileSync(tmpPath, json, { encoding: 'utf-8', flag: 'wx' });
165
169
  process.stdout.write('@file:' + tmpPath);
166
170
  } catch {
167
171
  // Tmpfile write failed (disk full, permissions) — truncate and write to stdout
@@ -147,7 +147,13 @@ function cmdDocLintSchemaCheck(cwd, schemaPath, opts = {}) {
147
147
  const COUNT_PATTERNS = [
148
148
  // "52 commands", "21 agents", "30 modules", "2667 tests", etc.
149
149
  // Word boundaries + allowed plurals; case-insensitive matching.
150
- { re: /(?<!\.)\b(\d+)\s+(commands?|agents?|modules?|workflows?|templates?|references?|specs?|adrs?|test\s+files?|test\s+suites?)\b/gi,
150
+ // Bare "N tests" / "N hooks" added (previously only "(N tests)" and
151
+ // "test files/suites" matched, so "3115 tests" slipped). Adjective-separated
152
+ // ("slash commands") and hyphen-compound ("sub-agents") variants are left
153
+ // uncaught on purpose — broadening to them also matched years ("2026
154
+ // multi-agent") and narrative, breaking the docs-clean invariant; the
155
+ // CLAUDE.md-table self-audit test is the stronger backstop for the counts.
156
+ { re: /(?<!\.)\b(\d+)\s+(commands?|agents?|modules?|workflows?|templates?|references?|specs?|adrs?|hooks?|test\s+files?|test\s+suites?|tests?)\b/gi,
151
157
  label: 'noun-phrase count' },
152
158
  // "27th module", "21st agent", "52nd command" — drift-prone ordinals
153
159
  { re: /(?<!\.)\b(\d+)(th|st|nd|rd)\s+(module|reference|agent|command|template|hook|workflow|spec|adr)\b/gi,
@@ -277,10 +283,89 @@ function cmdDocLintCounts(cwd, dir, opts = {}) {
277
283
  process.exit(violations.length > 0 ? 1 : 0);
278
284
  }
279
285
 
286
+ // ─── Aspirational-flag checker (ADR-0036 review — closes the "documented CLI
287
+ // flag that doesn't exist in the parser" gap). Heuristic: a `--flag` that
288
+ // appears in a doc line referencing the PAN CLI but never appears as a literal
289
+ // anywhere in the source is very likely fake or stale. Scoped to PAN-CLI lines
290
+ // to avoid flagging unrelated tool flags (git/npm/node). ─────────────────────
291
+ function collectSourceFlags(cwd, roots) {
292
+ const flags = new Set();
293
+ const walk = (dir) => {
294
+ let entries;
295
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
296
+ for (const e of entries) {
297
+ const fp = path.join(dir, e.name);
298
+ if (e.isDirectory()) { if (e.name !== 'node_modules') walk(fp); }
299
+ else if (/\.(cjs|js|mjs)$/.test(e.name)) {
300
+ let c = ''; try { c = fs.readFileSync(fp, 'utf-8'); } catch { continue; }
301
+ for (const m of c.matchAll(/--[a-z][a-z0-9-]+/g)) flags.add(m[0]);
302
+ }
303
+ }
304
+ };
305
+ for (const r of roots) walk(path.join(cwd, r));
306
+ return flags;
307
+ }
308
+
309
+ function scanDocFlags(cwd, opts = {}) {
310
+ const sourceFlags = collectSourceFlags(cwd, opts.sourceRoots || ['pan-wizard-core/bin', 'bin']);
311
+ const docDirs = opts.docDirs || ['docs'];
312
+ const collected = [];
313
+ for (const d of docDirs) {
314
+ const abs = path.join(cwd, d);
315
+ if (!fs.existsSync(abs)) continue;
316
+ for (const f of walkMarkdownFiles(abs)) {
317
+ if (f.readError) continue;
318
+ collected.push({ rel: path.join(d, f.relativePath).replace(/\\/g, '/'), content: f.content });
319
+ }
320
+ }
321
+ for (const rel of (opts.files || [])) {
322
+ try { collected.push({ rel: rel.replace(/\\/g, '/'), content: fs.readFileSync(path.join(cwd, rel), 'utf-8') }); } catch { /* skip */ }
323
+ }
324
+ // Scope to `pan-tools <cmd>` lines ONLY — that is the surface whose flags are
325
+ // parsed in bin/ source. Slash-command flags (`/pan:exec-phase --gaps-only`)
326
+ // are a different surface: they are parsed by the command/workflow markdown
327
+ // prompts, so they legitimately never appear in bin/ source and must not be
328
+ // flagged here.
329
+ const CLI_CTX = /\bpan-tools\b/;
330
+ const violations = [];
331
+ for (const file of collected) {
332
+ // Skip frozen/aspirational docs (feature specs, ADRs, experiments, archive)
333
+ // — by design they describe proposed/future flags, same allowlist as counts.
334
+ if (isCountAllowed(file.rel)) continue;
335
+ const lines = file.content.split(/\r?\n/);
336
+ for (let i = 0; i < lines.length; i++) {
337
+ if (!CLI_CTX.test(lines[i])) continue;
338
+ // Lookbehind excludes mid-token double-dashes — notably markdown anchors
339
+ // like `#army--project-dashboard` — so only real ` --flag` tokens match.
340
+ for (const m of lines[i].matchAll(/(?<![\w#-])(--[a-z][a-z0-9-]+)/g)) {
341
+ if (!sourceFlags.has(m[1])) violations.push({ file: file.rel, line: i + 1, flag: m[1] });
342
+ }
343
+ }
344
+ }
345
+ return { source_flags: sourceFlags.size, doc_files: collected.length, violation_count: violations.length, violations };
346
+ }
347
+
348
+ function cmdDocLintFlags(cwd, opts = {}, raw) {
349
+ const r = scanDocFlags(cwd, opts);
350
+ if (raw) {
351
+ if (r.violation_count === 0) {
352
+ process.stdout.write(`OK — ${r.doc_files} docs scanned against ${r.source_flags} source flags, no aspirational CLI flags\n`);
353
+ } else {
354
+ for (const v of r.violations) process.stdout.write(`${v.file}:${v.line} — ${v.flag} (documented for the PAN CLI but not found in source)\n`);
355
+ process.stdout.write(`\n${r.violation_count} aspirational flag(s)\n`);
356
+ }
357
+ } else {
358
+ output(r, false);
359
+ }
360
+ process.exit(r.violation_count > 0 ? 1 : 0);
361
+ }
362
+
280
363
  module.exports = {
281
364
  cmdDocLint,
282
365
  cmdDocLintSchemaCheck,
283
366
  cmdDocLintCounts,
367
+ scanDocFlags,
368
+ cmdDocLintFlags,
284
369
  isCountAllowed,
285
370
  COUNT_PATTERNS,
286
371
  DEFAULT_SCHEMA_PATH,
@@ -8,7 +8,7 @@
8
8
 
9
9
  const fs = require('fs');
10
10
  const path = require('path');
11
- const { output, error, safeReadFile, loadConfig, scanPendingTodos, scanSourceTodos, toPosix, isGitRepo, execGit } = require('./core.cjs');
11
+ const { output, error, safeReadFile, loadConfig, scanPendingTodos, scanSourceTodos, toPosix, isGitRepo, execGit, escapeRegex } = require('./core.cjs');
12
12
  const {
13
13
  PLANNING_DIR, PHASES_DIR, ROADMAP_FILE, PATTERNS_FILE, EFFORT_POINTS, PRIORITY_LEVELS, EFFORT_SIZES,
14
14
  FOCUS_MODES, FOCUS_TIERS, FOCUS_DIR,
@@ -457,7 +457,7 @@ function checkDocStaleness(cwd, opts) {
457
457
  function checkOldCommandNames(content, file, stale) {
458
458
  for (const [oldName, newName] of Object.entries(COMMAND_RENAME_MAP)) {
459
459
  // Match /pan:old-name or pan:old-name (command references)
460
- const pattern = new RegExp(`pan:${oldName.replace(/-/g, '\\-')}\\b`);
460
+ const pattern = new RegExp(`pan:${escapeRegex(oldName)}\\b`);
461
461
  if (pattern.test(content)) {
462
462
  stale.push({ file, entity: 'renamed_command', old: oldName, new: newName });
463
463
  }
@@ -717,6 +717,45 @@ function focusAutoStop(cwd, raw) {
717
717
  }, raw);
718
718
  }
719
719
 
720
+ // Anti-fake (ADR-0036 review): the regression breaker compares tests_after vs
721
+ // tests_before, both historically agent-supplied CLI args that focus-auto never
722
+ // re-ran — so a fabricated "tests green" passed unchecked. When verification is
723
+ // enabled (config.focus.verify_tests), re-run the node:test suite and use the
724
+ // REAL pass count; otherwise keep the supplied value but mark the cycle
725
+ // tests_verified:false so the loop's trust is visible to the HUD/audit/human.
726
+ //
727
+ // Invocation is a fixed execFile('node', ['--test']) — NO shell and NO
728
+ // configurable command string, so there is no command-injection surface (same
729
+ // safe pattern verify.cjs uses). If no node:test suite is present, fall back to
730
+ // the supplied value rather than mis-firing the regression breaker.
731
+ function verifyTestCount(cwd, config, fallback) {
732
+ const focusCfg = (config && config.focus) || {};
733
+ if (!focusCfg.verify_tests) return { tests_after: fallback, verified: false };
734
+ const { execFileSync } = require('child_process');
735
+ const num = (out, re) => { const m = String(out || '').match(re); return m ? Number(m[1]) : null; };
736
+ const interpret = (out, exitCode) => {
737
+ // node --test summary lines use either the TAP marker ("# tests N") or the
738
+ // spec reporter's info marker ("ℹ tests N"), depending on Node version /
739
+ // reporter — match both so the count is captured regardless.
740
+ const total = num(out, /[#ℹ]\s*tests\s+(\d+)/);
741
+ if (!total) return { tests_after: fallback, verified: false }; // no node:test suite here
742
+ const passed = num(out, /[#ℹ]\s*pass\s+(\d+)/);
743
+ return { tests_after: passed !== null ? passed : (exitCode === 0 ? fallback : 0), verified: true, exit_code: exitCode };
744
+ };
745
+ // Strip any NODE_TEST_* context so the child runs as a standalone runner with
746
+ // predictable spec/TAP output — otherwise, when focus-auto is itself invoked
747
+ // from within a `node --test` process, the child inherits the nested-test
748
+ // context and emits a serialized format we can't parse.
749
+ const env = { ...process.env };
750
+ for (const k of Object.keys(env)) { if (k.startsWith('NODE_TEST')) delete env[k]; }
751
+ try {
752
+ const out = execFileSync('node', ['--test'], { cwd, timeout: 120000, stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8', env });
753
+ return interpret(out, 0);
754
+ } catch (err) {
755
+ return interpret(err && err.stdout, (err && err.status) || 1);
756
+ }
757
+ }
758
+
720
759
  function focusAutoUpdate(cwd, raw, getVal) {
721
760
  const run = readAutoRun(cwd);
722
761
  if (!run) return error('No auto-run in progress. Cannot update.');
@@ -735,6 +774,12 @@ function focusAutoUpdate(cwd, raw, getVal) {
735
774
  timestamp: new Date().toISOString(),
736
775
  };
737
776
 
777
+ // Anti-fake: re-run the suite when verification is enabled; else record that
778
+ // this count was self-reported (tests_verified:false) so the trust is visible.
779
+ const tv = verifyTestCount(cwd, loadConfig(cwd), cycle.tests_after);
780
+ cycle.tests_after = tv.tests_after;
781
+ cycle.tests_verified = tv.verified;
782
+
738
783
  if (!run.cycles) run.cycles = [];
739
784
  run.cycles.push(cycle);
740
785
 
@@ -1016,6 +1061,7 @@ module.exports = {
1016
1061
  writeAutoRun,
1017
1062
  cmdFocusAuto,
1018
1063
  determineStopReason,
1064
+ verifyTestCount,
1019
1065
  // Opus 4.7
1020
1066
  determineContinuation,
1021
1067
  classifyStageDependencies,
@@ -822,13 +822,29 @@ ${body}
822
822
 
823
823
  function openInBrowser(filePath) {
824
824
  const { execFileSync } = require('child_process');
825
+ // Only open a path we can resolve to an existing regular file, and refuse
826
+ // anything carrying shell/cmd metacharacters — on Windows `start` is a cmd
827
+ // builtin that re-parses its command line, so a crafted --out value must not
828
+ // be able to reach it. The allowlist check is the taint barrier; `resolved`
829
+ // is what actually gets opened.
830
+ let resolved;
831
+ try {
832
+ resolved = path.resolve(filePath);
833
+ if (!fs.statSync(resolved).isFile()) return false;
834
+ } catch {
835
+ return false;
836
+ }
837
+ // Allowlist barrier: only ordinary path characters may reach the opener.
838
+ // Anything outside this set (shell/cmd metacharacters, quotes, newlines) is
839
+ // rejected outright, so a crafted --out value cannot reach Windows `start`.
840
+ if (!/^[A-Za-z0-9 _.:\\/()-]+$/.test(resolved)) return false;
825
841
  try {
826
842
  if (process.platform === 'win32') {
827
- execFileSync('cmd', ['/c', 'start', '', filePath], { stdio: 'ignore' });
843
+ execFileSync('cmd', ['/c', 'start', '', resolved], { stdio: 'ignore' });
828
844
  } else if (process.platform === 'darwin') {
829
- execFileSync('open', [filePath], { stdio: 'ignore' });
845
+ execFileSync('open', [resolved], { stdio: 'ignore' });
830
846
  } else {
831
- execFileSync('xdg-open', [filePath], { stdio: 'ignore' });
847
+ execFileSync('xdg-open', [resolved], { stdio: 'ignore' });
832
848
  }
833
849
  return true;
834
850
  } catch {
@@ -14,7 +14,7 @@
14
14
 
15
15
  const fs = require('fs');
16
16
  const path = require('path');
17
- const { output, error, safeReadFile, toPosix } = require('./core.cjs');
17
+ const { output, error, safeReadFile, toPosix, escapeRegex } = require('./core.cjs');
18
18
  const { PLANNING_DIR } = require('./constants.cjs');
19
19
  const { planningPath } = require('./utils.cjs');
20
20
  const { listMemoryAgents, readMemory } = require('./memory.cjs');
@@ -56,7 +56,7 @@ function scoreRelevance(question, content) {
56
56
  const body = content.toLowerCase();
57
57
  let score = 0;
58
58
  for (const w of words) {
59
- const count = (body.match(new RegExp(`\\b${w.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}\\b`, 'g')) || []).length;
59
+ const count = (body.match(new RegExp(`\\b${escapeRegex(w)}\\b`, 'g')) || []).length;
60
60
  score += count;
61
61
  }
62
62
  return score;
@@ -8,7 +8,7 @@
8
8
 
9
9
  const fs = require('fs');
10
10
  const path = require('path');
11
- const { output } = require('./core.cjs');
11
+ const { output, escapeRegex } = require('./core.cjs');
12
12
  const { PLANNING_DIR } = require('./constants.cjs');
13
13
 
14
14
  // ─── Storage layout ──────────────────────────────────────────────────────────
@@ -1052,7 +1052,7 @@ function unpromotePattern(patternId, opts) {
1052
1052
  // Strip the pattern's body section. Pattern body is a `## P-<id> — ...` heading
1053
1053
  // followed by content until the next `## ` or end-of-file.
1054
1054
  const headingRe = new RegExp(
1055
- `\\n## ${patternId.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}\\b[^\\n]*[\\s\\S]*?(?=\\n## |$)`,
1055
+ `\\n## ${escapeRegex(patternId)}\\b[^\\n]*[\\s\\S]*?(?=\\n## |$)`,
1056
1056
  ''
1057
1057
  );
1058
1058
  const newBody = parsed.body.replace(headingRe, '');
@@ -47,7 +47,7 @@ function renumberDecimalPhases(phasesDir, baseInt, removedDecimal) {
47
47
  const dirs = entries.filter(entry => entry.isDirectory()).map(entry => entry.name).sort((left, right) => comparePhaseNum(left, right));
48
48
 
49
49
  // Find sibling decimals with higher numbers than the removed one
50
- const decPattern = new RegExp(`^${baseInt}\\.(\\d+)-(.+)$`);
50
+ const decPattern = new RegExp(`^${escapeRegex(String(baseInt))}\\.(\\d+)-(.+)$`);
51
51
  const toRename = [];
52
52
  for (const dir of dirs) {
53
53
  const decMatch = dir.match(decPattern);
@@ -133,7 +133,7 @@ function cmdPhaseNextDecimal(cwd, basePhase, raw) {
133
133
  const baseExists = dirs.some(dir => dir.startsWith(normalized + '-') || dir === normalized);
134
134
 
135
135
  // Find existing decimal phases for this base
136
- const decimalPattern = new RegExp(`^${normalized}\\.(\\d+)`);
136
+ const decimalPattern = new RegExp(`^${escapeRegex(normalized)}\\.(\\d+)`);
137
137
  const existingDecimals = [];
138
138
 
139
139
  for (const dir of dirs) {
@@ -444,7 +444,7 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
444
444
  // Normalize input then strip leading zeros for flexible matching
445
445
  const normalizedAfter = normalizePhaseName(afterPhase);
446
446
  const unpadded = normalizedAfter.replace(/^0+/, '');
447
- const afterPhaseEscaped = unpadded.replace(/\./g, '\\.');
447
+ const afterPhaseEscaped = escapeRegex(unpadded);
448
448
  const targetPattern = new RegExp(`#{2,4}\\s*Phase\\s+0*${afterPhaseEscaped}:`, 'i');
449
449
  if (!targetPattern.test(content)) {
450
450
  error(`Phase ${afterPhase} not found in roadmap.md`);
@@ -458,7 +458,7 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
458
458
  try {
459
459
  const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
460
460
  const dirs = entries.filter(entry => entry.isDirectory()).map(entry => entry.name);
461
- const decimalPattern = new RegExp(`^${normalizedBase}\\.(\\d+)`);
461
+ const decimalPattern = new RegExp(`^${escapeRegex(normalizedBase)}\\.(\\d+)`);
462
462
  for (const dir of dirs) {
463
463
  const decMatch = dir.match(decimalPattern);
464
464
  if (decMatch) existingDecimals.push(parseInt(decMatch[1], 10));
@@ -484,7 +484,7 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
484
484
  const phaseEntry = `\n### Phase ${decimalPhase}: ${description} (INSERTED)\n\n**Goal:** [Urgent work - to be planned]\n**Requirements**: TBD\n**Depends on:** Phase ${afterPhase}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /pan:plan-phase ${decimalPhase} to break down)\n`;
485
485
 
486
486
  // Insert after the target phase section
487
- const headerPattern = new RegExp(`(#{2,4}\\s*Phase\\s+0*${afterPhaseEscaped}:[^\\n]*\\n)`, 'i');
487
+ const headerPattern = new RegExp(`(#{2,4}\\s*Phase\\s+0*${afterPhaseEscaped}:[^\\n]*\\n)`, 'i'); // afterPhaseEscaped already run through escapeRegex above
488
488
  const headerMatch = content.match(headerPattern);
489
489
  if (!headerMatch) {
490
490
  error(`Could not find Phase ${afterPhase} header`);
@@ -198,7 +198,9 @@ function writeDeepReview(cwd, phaseNum, payload, opts) {
198
198
  lines.push('|----------|--------|----------|-------------|------|');
199
199
  for (const f of payload.findings) {
200
200
  const loc = f.file ? `\`${f.file}${f.line ? `:${f.line}` : ''}\`` : '—';
201
- const desc = f.description.replace(/\|/g, '\\|');
201
+ // Neutralize markdown-table-breaking chars: escape pipes and flatten any
202
+ // newlines so a finding description can't corrupt the table structure.
203
+ const desc = String(f.description).replace(/\\/g, '\\\\').replace(/\|/g, '\\|').replace(/[\r\n]+/g, ' ');
202
204
  lines.push(`| ${f.severity} | ${f.source} | ${f.category} | ${desc} | ${loc} |`);
203
205
  }
204
206
  lines.push('');
@@ -5,7 +5,7 @@
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
7
  const { execFileSync } = require('child_process');
8
- const { safeReadFile, normalizePhaseName, execGit, findPhaseInternal, getMilestoneInfo, toPosix, output, error } = require('./core.cjs');
8
+ const { safeReadFile, normalizePhaseName, execGit, findPhaseInternal, getMilestoneInfo, toPosix, output, error, escapeRegex } = require('./core.cjs');
9
9
  const { extractFrontmatter, parseMustHavesBlock } = require('./frontmatter.cjs');
10
10
  const { writeStateMd, readStateSafe } = require('./state.cjs');
11
11
  const {
@@ -317,32 +317,22 @@ function cmdVerifyCommits(cwd, hashes, raw) {
317
317
  * @param {boolean} raw - If true, output raw value instead of JSON
318
318
  * @returns {void}
319
319
  */
320
- function cmdVerifyArtifacts(cwd, planFilePath, raw) {
321
- if (!planFilePath) { error('plan file path required'); }
322
- const fullPath = path.isAbsolute(planFilePath) ? planFilePath : path.join(cwd, planFilePath);
323
- const content = safeReadFile(fullPath);
324
- if (!content) { output({ error: 'File not found', path: planFilePath }, raw); return; }
325
-
326
- const artifacts = parseMustHavesBlock(content, 'artifacts');
327
- if (artifacts.length === 0) {
328
- output({ error: 'No must_haves.artifacts found in frontmatter', path: planFilePath }, raw);
329
- return;
330
- }
331
-
320
+ /**
321
+ * Pure substance check of must_haves.artifacts against disk — no output/exit,
322
+ * so `verify reconcile` can compose it. Returns {all_passed, passed, total, artifacts}.
323
+ */
324
+ function checkArtifacts(cwd, planContent) {
325
+ const artifacts = parseMustHavesBlock(planContent, 'artifacts');
332
326
  const results = [];
333
327
  for (const artifact of artifacts) {
334
328
  if (typeof artifact === 'string') continue; // skip simple string items
335
329
  const artPath = artifact.path;
336
330
  if (!artPath) continue;
337
-
338
- const artFullPath = path.join(cwd, artPath);
339
- const fileContent = safeReadFile(artFullPath);
331
+ const fileContent = safeReadFile(path.join(cwd, artPath));
340
332
  const exists = fileContent !== null;
341
333
  const check = { path: artPath, exists, issues: [], passed: false };
342
-
343
334
  if (exists) {
344
335
  const lineCount = fileContent.split('\n').length;
345
-
346
336
  if (artifact.min_lines && lineCount < artifact.min_lines) {
347
337
  check.issues.push(`Only ${lineCount} lines, need ${artifact.min_lines}`);
348
338
  }
@@ -350,8 +340,8 @@ function cmdVerifyArtifacts(cwd, planFilePath, raw) {
350
340
  check.issues.push(`Missing pattern: ${artifact.contains}`);
351
341
  }
352
342
  if (artifact.exports) {
353
- const exports = Array.isArray(artifact.exports) ? artifact.exports : [artifact.exports];
354
- for (const exp of exports) {
343
+ const exps = Array.isArray(artifact.exports) ? artifact.exports : [artifact.exports];
344
+ for (const exp of exps) {
355
345
  if (!fileContent.includes(exp)) check.issues.push(`Missing export: ${exp}`);
356
346
  }
357
347
  }
@@ -359,43 +349,42 @@ function cmdVerifyArtifacts(cwd, planFilePath, raw) {
359
349
  } else {
360
350
  check.issues.push('File not found');
361
351
  }
362
-
363
352
  results.push(check);
364
353
  }
365
-
366
354
  const passed = results.filter(r => r.passed).length;
367
- output({
368
- all_passed: passed === results.length,
369
- passed,
370
- total: results.length,
371
- artifacts: results,
372
- }, raw, passed === results.length ? 'valid' : 'invalid');
355
+ return { all_passed: passed === results.length, passed, total: results.length, artifacts: results };
373
356
  }
374
357
 
375
- /**
376
- * Verify must_haves.key_links from a plan.md: source-to-target references and patterns.
377
- * @param {string} cwd - Working directory path
378
- * @param {string} planFilePath - Path to the plan.md file containing key link specs
379
- * @param {boolean} raw - If true, output raw value instead of JSON
380
- * @returns {void}
381
- */
382
- function cmdVerifyKeyLinks(cwd, planFilePath, raw) {
358
+ function cmdVerifyArtifacts(cwd, planFilePath, raw) {
383
359
  if (!planFilePath) { error('plan file path required'); }
384
360
  const fullPath = path.isAbsolute(planFilePath) ? planFilePath : path.join(cwd, planFilePath);
385
361
  const content = safeReadFile(fullPath);
386
362
  if (!content) { output({ error: 'File not found', path: planFilePath }, raw); return; }
387
-
388
- const keyLinks = parseMustHavesBlock(content, 'key_links');
389
- if (keyLinks.length === 0) {
390
- output({ error: 'No must_haves.key_links found in frontmatter', path: planFilePath }, raw);
363
+ const r = checkArtifacts(cwd, content);
364
+ if (r.total === 0) {
365
+ output({ error: 'No must_haves.artifacts found in frontmatter', path: planFilePath }, raw);
391
366
  return;
392
367
  }
368
+ output(r, raw, r.all_passed ? 'valid' : 'invalid');
369
+ }
393
370
 
371
+ /**
372
+ * Verify must_haves.key_links from a plan.md: source-to-target references and patterns.
373
+ * @param {string} cwd - Working directory path
374
+ * @param {string} planFilePath - Path to the plan.md file containing key link specs
375
+ * @param {boolean} raw - If true, output raw value instead of JSON
376
+ * @returns {void}
377
+ */
378
+ /**
379
+ * Pure wiring check of must_haves.key_links against disk — no output/exit.
380
+ * Returns {all_verified, verified, total, links}.
381
+ */
382
+ function checkKeyLinks(cwd, planContent) {
383
+ const keyLinks = parseMustHavesBlock(planContent, 'key_links');
394
384
  const results = [];
395
385
  for (const link of keyLinks) {
396
386
  if (typeof link === 'string') continue;
397
387
  const check = { from: link.from, to: link.to, via: link.via || '', verified: false, detail: '' };
398
-
399
388
  const sourceContent = safeReadFile(path.join(cwd, link.from || ''));
400
389
  if (!sourceContent) {
401
390
  check.detail = 'Source file not found';
@@ -415,29 +404,142 @@ function cmdVerifyKeyLinks(cwd, planFilePath, raw) {
415
404
  }
416
405
  }
417
406
  } catch {
418
- // Regex compilation failed -- report the invalid pattern to the caller
419
407
  check.detail = `Invalid regex pattern: ${link.pattern}`;
420
408
  }
409
+ } else if (sourceContent.includes(link.to || '')) {
410
+ check.verified = true;
411
+ check.detail = 'Target referenced in source';
421
412
  } else {
422
- // No pattern: just check source references target
423
- if (sourceContent.includes(link.to || '')) {
424
- check.verified = true;
425
- check.detail = 'Target referenced in source';
426
- } else {
427
- check.detail = 'Target not referenced in source';
428
- }
413
+ check.detail = 'Target not referenced in source';
429
414
  }
430
-
431
415
  results.push(check);
432
416
  }
433
-
434
417
  const verified = results.filter(r => r.verified).length;
435
- output({
436
- all_verified: verified === results.length,
437
- verified,
438
- total: results.length,
439
- links: results,
440
- }, raw, verified === results.length ? 'valid' : 'invalid');
418
+ return { all_verified: verified === results.length, verified, total: results.length, links: results };
419
+ }
420
+
421
+ function cmdVerifyKeyLinks(cwd, planFilePath, raw) {
422
+ if (!planFilePath) { error('plan file path required'); }
423
+ const fullPath = path.isAbsolute(planFilePath) ? planFilePath : path.join(cwd, planFilePath);
424
+ const content = safeReadFile(fullPath);
425
+ if (!content) { output({ error: 'File not found', path: planFilePath }, raw); return; }
426
+ const r = checkKeyLinks(cwd, content);
427
+ if (r.total === 0) {
428
+ output({ error: 'No must_haves.key_links found in frontmatter', path: planFilePath }, raw);
429
+ return;
430
+ }
431
+ output(r, raw, r.all_verified ? 'valid' : 'invalid');
432
+ }
433
+
434
+ // ─── Reconcile: cross-check a written verification verdict against the
435
+ // mechanical signals (ADR-0036 review — closes the rubber-stamp gap). ─────────
436
+
437
+ function findPhaseDir(cwd, phaseNum) {
438
+ const base = phasesPath(cwd);
439
+ let entries;
440
+ try { entries = fs.readdirSync(base, { withFileTypes: true }); } catch { return null; }
441
+ const re = new RegExp('^0*' + String(phaseNum).replace(/[^0-9A-Za-z.]/g, '') + '-');
442
+ for (const e of entries) {
443
+ if (e.isDirectory() && re.test(e.name)) return path.join(base, e.name);
444
+ }
445
+ return null;
446
+ }
447
+
448
+ /**
449
+ * Re-derive the mechanical signals for a phase and check them against the
450
+ * verdict written in its -verification.md. A verification that CLAIMS a pass
451
+ * while artifacts fail substance checks or key-links are unwired is a
452
+ * contradiction (a rubber stamp) — reported deterministically, never trusted.
453
+ * When no must_haves are declared there are no mechanical signals to reconcile,
454
+ * so the verdict is passed through (reconciled: true, with a note).
455
+ */
456
+ function reconcilePhase(cwd, phaseNum) {
457
+ const base = { phase: String(phaseNum), found: false, reconciled: true, contradictions: [] };
458
+ const dir = findPhaseDir(cwd, phaseNum);
459
+ if (!dir) return { ...base, note: 'phase directory not found' };
460
+ let files;
461
+ try { files = fs.readdirSync(dir); } catch { return { ...base, note: 'phase directory unreadable' }; }
462
+ const verFile = files.find(f => isVerificationFile(f));
463
+ if (!verFile) return { ...base, note: 'no verification.md — absence is covered by the verification gate, not reconcile' };
464
+ const verRaw = safeReadFile(path.join(dir, verFile)) || '';
465
+ const sm = verRaw.match(/^status:\s*([A-Za-z_-]+)/m);
466
+ const status = sm ? sm[1].toLowerCase() : 'unknown';
467
+ const claimsPass = /^(pass|passed|verified|complete|verified_pass)$/.test(status);
468
+ const planFile = files.find(f => isPlanFile(f));
469
+ const planContent = planFile ? (safeReadFile(path.join(dir, planFile)) || '') : '';
470
+ const artifacts = checkArtifacts(cwd, planContent);
471
+ const keyLinks = checkKeyLinks(cwd, planContent);
472
+ const signals = artifacts.total + keyLinks.total;
473
+ const contradictions = [];
474
+ if (claimsPass) {
475
+ if (artifacts.total > 0 && !artifacts.all_passed) {
476
+ contradictions.push(`verification status "${status}" but ${artifacts.total - artifacts.passed}/${artifacts.total} artifact substance check(s) FAIL`);
477
+ }
478
+ if (keyLinks.total > 0 && !keyLinks.all_verified) {
479
+ contradictions.push(`verification status "${status}" but ${keyLinks.total - keyLinks.verified}/${keyLinks.total} key-link(s) UNWIRED`);
480
+ }
481
+ }
482
+ return {
483
+ phase: String(phaseNum), found: true, verification_status: status, claims_pass: claimsPass,
484
+ mechanical_signals: signals, artifacts, key_links: keyLinks, contradictions,
485
+ reconciled: contradictions.length === 0,
486
+ note: signals === 0 ? 'no must_haves declared — mechanical reconciliation unavailable; verdict trusted' : undefined,
487
+ };
488
+ }
489
+
490
+ function cmdVerifyReconcile(cwd, phaseNum, raw) {
491
+ if (!phaseNum) { error('Usage: verify reconcile <phase>'); }
492
+ const r = reconcilePhase(cwd, phaseNum);
493
+ output(r, raw, r.reconciled ? 'valid' : 'invalid');
494
+ process.exit(r.reconciled ? 0 : 1);
495
+ }
496
+
497
+ // ─── Stub / fake-return scanner (ADR-0036 review — closes the hardcoded
498
+ // "return {ok:true}" / "not implemented" gap the old anti-pattern grep missed,
499
+ // which only blocked the literal `return {}` and `placeholder`/`coming soon`). ─
500
+ const STUB_PATTERNS = [
501
+ { re: /\bnot[\s_-]?implemented\b/i, marker: 'not-implemented', severity: 'high' },
502
+ { re: /\bNotImplemented(Error)?\b/, marker: 'NotImplemented', severity: 'high' },
503
+ { re: /throw\s+new\s+\w*Error\s*\(\s*['"`][^'"`]*\b(unimplemented|not\s+implemented|stub|todo)\b/i, marker: 'throw-stub', severity: 'high' },
504
+ { re: /\bres(ponse)?\.status\(\s*501\s*\)/, marker: 'http-501', severity: 'high' },
505
+ { re: /\b(coming\s+soon|placeholder)\b/i, marker: 'placeholder', severity: 'medium' },
506
+ { re: /return\s*(\{\s*\}|\[\s*\])\s*;?\s*(\/\/.*)?$/, marker: 'empty-return', severity: 'medium' },
507
+ { re: /return\s*\{\s*ok\s*:\s*true\s*\}\s*;?\s*(\/\/.*)?$/, marker: 'fake-ok-return', severity: 'medium' },
508
+ { re: /\b(TODO|FIXME|XXX|HACK)\b/, marker: 'todo-marker', severity: 'low' },
509
+ ];
510
+ const STUB_CODE_EXT = /\.(js|cjs|mjs|jsx|ts|tsx|py|go|rb|java|php|rs|c|cc|cpp|h|hpp|cs|kt|swift|scala)$/i;
511
+
512
+ /**
513
+ * Scan source files for stub / fake-implementation markers. Defaults to the
514
+ * git-changed files (so it gates a handoff), or a caller-supplied file list.
515
+ * `high`-severity markers are the blocking set; TODO markers are informational.
516
+ * @returns {{scanned, findings: Array, blocking: number, total: number}}
517
+ */
518
+ function scanStubs(cwd, opts = {}) {
519
+ let files = Array.isArray(opts.files) ? opts.files : getChangedFiles(cwd);
520
+ files = (files || []).filter(f => STUB_CODE_EXT.test(f));
521
+ const findings = [];
522
+ for (const rel of files) {
523
+ const content = safeReadFile(path.join(cwd, rel));
524
+ if (content === null) continue;
525
+ const lines = content.split(/\r?\n/);
526
+ for (let i = 0; i < lines.length; i++) {
527
+ for (const { re, marker, severity } of STUB_PATTERNS) {
528
+ if (re.test(lines[i])) {
529
+ findings.push({ file: toPosix(rel), line: i + 1, marker, severity, text: lines[i].trim().slice(0, 160) });
530
+ break; // one finding per line
531
+ }
532
+ }
533
+ }
534
+ }
535
+ const blocking = findings.filter(f => f.severity === 'high').length;
536
+ return { scanned: files.length, findings, blocking, total: findings.length };
537
+ }
538
+
539
+ function cmdVerifyStubs(cwd, opts = {}, raw) {
540
+ const r = scanStubs(cwd, opts);
541
+ output(r, raw, r.blocking === 0 ? 'valid' : 'invalid');
542
+ if (opts.gate) process.exit(r.blocking > 0 ? 1 : 0);
441
543
  }
442
544
 
443
545
  /**
@@ -995,12 +1097,12 @@ function syncRequirementCheckboxes(cwd) {
995
1097
  let fixed = 0;
996
1098
  for (const phaseNum of completedPhases) {
997
1099
  const reqMatch = roadmapContent.match(
998
- new RegExp(`Phase\\s+${phaseNum.replace(/\./g, '\\.')}[\\s\\S]*?\\*\\*Requirements:\\*\\*\\s*([^\\n]+)`, 'i')
1100
+ new RegExp(`Phase\\s+${escapeRegex(phaseNum)}[\\s\\S]*?\\*\\*Requirements:\\*\\*\\s*([^\\n]+)`, 'i')
999
1101
  );
1000
1102
  if (!reqMatch) continue;
1001
1103
  const reqIds = reqMatch[1].replace(/[\[\]]/g, '').split(/[,\s]+/).map(id => id.trim()).filter(Boolean);
1002
1104
  for (const reqId of reqIds) {
1003
- const escaped = reqId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1105
+ const escaped = escapeRegex(reqId);
1004
1106
  const re = new RegExp(`(- \\[) (\\]\\s*\\*\\*${escaped}\\*\\*)`, 'gi');
1005
1107
  const before = reqContent;
1006
1108
  reqContent = reqContent.replace(re, '$1x$2');
@@ -1284,8 +1386,11 @@ function runFullTestCheck(cwd) {
1284
1386
  stdio: ['pipe', 'pipe', 'pipe'],
1285
1387
  encoding: 'utf-8',
1286
1388
  });
1287
- const testMatch = result.match(/# tests (\d+)/);
1288
- const passMatch = result.match(/# pass (\d+)/);
1389
+ // Match both TAP ("# tests N") and spec-reporter ("ℹ tests N") summaries —
1390
+ // modern node --test defaults to the spec reporter, which the old "# "-only
1391
+ // regex silently missed (returning tests: null).
1392
+ const testMatch = result.match(/[#ℹ]\s*tests\s+(\d+)/);
1393
+ const passMatch = result.match(/[#ℹ]\s*pass\s+(\d+)/);
1289
1394
  return {
1290
1395
  pass: true,
1291
1396
  exitCode: 0,
@@ -1344,6 +1449,12 @@ module.exports = {
1344
1449
  cmdVerifyCommits,
1345
1450
  cmdVerifyArtifacts,
1346
1451
  cmdVerifyKeyLinks,
1452
+ checkArtifacts,
1453
+ checkKeyLinks,
1454
+ reconcilePhase,
1455
+ cmdVerifyReconcile,
1456
+ scanStubs,
1457
+ cmdVerifyStubs,
1347
1458
  cmdValidateConsistency,
1348
1459
  cmdValidateHealth,
1349
1460
  cmdPreflight,
@@ -524,6 +524,10 @@ async function main() {
524
524
  verify.cmdVerifyArtifacts(cwd, args[2], raw);
525
525
  } else if (subcommand === 'key-links') {
526
526
  verify.cmdVerifyKeyLinks(cwd, args[2], raw);
527
+ } else if (subcommand === 'reconcile') {
528
+ verify.cmdVerifyReconcile(cwd, args[2], raw);
529
+ } else if (subcommand === 'stubs') {
530
+ verify.cmdVerifyStubs(cwd, { gate: args.includes('--gate') }, raw);
527
531
  } else {
528
532
  error('Unknown verify subcommand. Available: plan-structure, phase-completeness, references, commits, artifacts, key-links');
529
533
  }
@@ -1303,6 +1307,12 @@ async function main() {
1303
1307
  docLint.cmdDocLintCounts(cwd, dir, { raw, exclude });
1304
1308
  break;
1305
1309
  }
1310
+ if (subcommand === 'flags') {
1311
+ const docDirs = [];
1312
+ for (let k = 0; k < args.length; k++) if (args[k] === '--doc-dir') docDirs.push(args[k + 1]);
1313
+ docLint.cmdDocLintFlags(cwd, { docDirs: docDirs.length ? docDirs : undefined }, raw);
1314
+ break;
1315
+ }
1306
1316
  // Default: lint a directory
1307
1317
  const dir = args[1];
1308
1318
  if (!dir || dir.startsWith('--')) { error('doc-lint <dir> required (or doc-lint schema-check <path>, doc-lint counts <dir>)'); }
@@ -659,6 +659,20 @@ if [ -n "$VERIF" ]; then
659
659
  fi
660
660
  ```
661
661
 
662
+ **Cross-check the written verdict against the mechanical signals (anti-rubber-stamp, ADR-0036).** The `status:` string above is authored by the verifier agent; `reconcile` re-derives the artifact/key-link checks from disk and exits non-zero when a claimed pass contradicts them:
663
+ ```bash
664
+ node ~/.claude/pan-wizard-core/bin/pan-tools.cjs verify reconcile "${PHASE_NUMBER}" --raw
665
+ RECONCILE_EXIT=$?
666
+ ```
667
+ If `RECONCILE_EXIT` is non-zero:
668
+ ```
669
+ ⚠ Reconcile gate: Phase ${PHASE_NUMBER} verification says "passed" but the mechanical
670
+ checks disagree — artifacts fail substance checks or key-links are unwired.
671
+ This is a rubber-stamped verification. Do NOT auto-advance.
672
+ Re-run /pan:verify-phase and fix the failing artifacts/key-links.
673
+ ```
674
+ STOP — do not auto-advance. Return to user.
675
+
662
676
  If `VERIF_STATUS` is not `passed`:
663
677
  ```
664
678
  ⚠ Verification gate: Phase ${PHASE_NUMBER} verification status is "${VERIF_STATUS:-missing}"
@@ -3,15 +3,16 @@
3
3
  * release-check.js — Pre-publish validation gate.
4
4
  *
5
5
  * Wired into `prepublishOnly` so `npm publish` fails BEFORE upload if any
6
- * gate is red. Runs five checks in order; first failure aborts.
6
+ * gate is red. Runs seven checks in order; first failure aborts.
7
7
  *
8
8
  * 1. build:hooks — hook scripts copy/build cleanly
9
9
  * 2. test:all — full test suite (unit + scenario) passes
10
10
  * 3. npm audit — no known vulnerabilities in production deps
11
- * (we have zero runtime deps, but esbuild dev-dep is checked)
11
+ * (we have zero runtime deps, but the dev-deps are checked)
12
12
  * 4. doc-lint counts — no drift-prone count violations in user-facing docs
13
- * 5. npm pack dry-run — package builds; size is sane
14
- * 6. smoke install — npm pack + install into temp dir + run pan-tools list
13
+ * 5. links validate — doc↔code link graph resolves (no broken references)
14
+ * 6. npm pack dry-run — package builds; size is sane
15
+ * 7. smoke install — npm pack + install into temp dir + run pan-tools list
15
16
  * catches "ships but doesn't actually work" failures
16
17
  *
17
18
  * Usage:
@@ -56,7 +57,7 @@ function run(cmd, args, opts = {}) {
56
57
  }
57
58
 
58
59
  // Gate 1: build:hooks
59
- process.stderr.write('\n[release-check] Gate 1/6: build:hooks\n');
60
+ process.stderr.write('\n[release-check] Gate 1/7: build:hooks\n');
60
61
  {
61
62
  const r = run('npm', ['run', 'build:hooks']);
62
63
  logGate('build:hooks', r.status === 0, r.status !== 0 ? `exit ${r.status}` : '');
@@ -64,7 +65,7 @@ process.stderr.write('\n[release-check] Gate 1/6: build:hooks\n');
64
65
  }
65
66
 
66
67
  // Gate 2: test:all
67
- process.stderr.write('\n[release-check] Gate 2/6: test:all\n');
68
+ process.stderr.write('\n[release-check] Gate 2/7: test:all\n');
68
69
  {
69
70
  const r = run('npm', ['run', 'test:all']);
70
71
  logGate('test:all', r.status === 0, r.status !== 0 ? `exit ${r.status}` : '');
@@ -73,9 +74,9 @@ process.stderr.write('\n[release-check] Gate 2/6: test:all\n');
73
74
 
74
75
  // Gate 3: npm audit (production deps only)
75
76
  if (SKIP_AUDIT) {
76
- process.stderr.write('\n[release-check] Gate 3/6: npm audit (SKIPPED)\n');
77
+ process.stderr.write('\n[release-check] Gate 3/7: npm audit (SKIPPED)\n');
77
78
  } else {
78
- process.stderr.write('\n[release-check] Gate 3/6: npm audit --omit=dev\n');
79
+ process.stderr.write('\n[release-check] Gate 3/7: npm audit --omit=dev\n');
79
80
  const r = run('npm', ['audit', '--omit=dev', '--audit-level=high'], { capture: true });
80
81
  // npm audit exits non-zero on findings. We tolerate moderate; fail on high+.
81
82
  const ok = r.status === 0;
@@ -87,7 +88,7 @@ if (SKIP_AUDIT) {
87
88
  }
88
89
 
89
90
  // Gate 4: doc-lint counts on user-facing docs (count-SSoT enforcement)
90
- process.stderr.write('\n[release-check] Gate 4/6: doc-lint counts docs/\n');
91
+ process.stderr.write('\n[release-check] Gate 4/7: doc-lint counts docs/\n');
91
92
  {
92
93
  const tools = path.join(REPO_ROOT, 'pan-wizard-core', 'bin', 'pan-tools.cjs');
93
94
  const docsDir = path.join(REPO_ROOT, 'docs');
@@ -100,6 +101,20 @@ process.stderr.write('\n[release-check] Gate 4/6: doc-lint counts docs/\n');
100
101
  }
101
102
  }
102
103
 
104
+ // Gate 5: doc↔code link graph resolves (anti-fake — a doc cannot reference a
105
+ // code anchor that doesn't exist; deterministic, self-enforcing exit 1).
106
+ process.stderr.write('\n[release-check] Gate 5/7: links validate\n');
107
+ {
108
+ const tools = path.join(REPO_ROOT, 'pan-wizard-core', 'bin', 'pan-tools.cjs');
109
+ const r = run('node', [tools, 'links', 'validate', '--raw'], { capture: true });
110
+ const ok = r.status === 0;
111
+ logGate('links validate', ok, ok ? 'doc↔code link graph resolves' : 'broken doc↔code references');
112
+ if (!ok) {
113
+ process.stderr.write((r.stdout || '') + '\n');
114
+ process.exit(1);
115
+ }
116
+ }
117
+
103
118
 
104
119
  // npm runs lifecycle scripts (prepare) before pack; any of their stdout noise
105
120
  // lands ahead of the --json payload. npm pretty-prints the JSON array starting
@@ -111,8 +126,8 @@ function parseNpmJson(stdout) {
111
126
  return JSON.parse(stdout.slice(m));
112
127
  }
113
128
 
114
- // Gate 5: npm pack dry-run
115
- process.stderr.write('\n[release-check] Gate 5/6: npm pack --dry-run\n');
129
+ // Gate 6: npm pack dry-run
130
+ process.stderr.write('\n[release-check] Gate 6/7: npm pack --dry-run\n');
116
131
  {
117
132
  const r = run('npm', ['pack', '--dry-run', '--json'], { capture: true });
118
133
  if (r.status !== 0) {
@@ -137,11 +152,11 @@ process.stderr.write('\n[release-check] Gate 5/6: npm pack --dry-run\n');
137
152
  }
138
153
  }
139
154
 
140
- // Gate 6: smoke install — pack and install into temp dir, run pan-tools
155
+ // Gate 7: smoke install — pack and install into temp dir, run pan-tools
141
156
  if (SKIP_SMOKE) {
142
- process.stderr.write('\n[release-check] Gate 6/6: smoke install (SKIPPED)\n');
157
+ process.stderr.write('\n[release-check] Gate 7/7: smoke install (SKIPPED)\n');
143
158
  } else {
144
- process.stderr.write('\n[release-check] Gate 6/6: smoke install (npm pack + install + sanity)\n');
159
+ process.stderr.write('\n[release-check] Gate 7/7: smoke install (npm pack + install + sanity)\n');
145
160
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pan-release-smoke-'));
146
161
  try {
147
162
  // Pack
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Glob-free test runner. `node --test tests/*.test.cjs` relies on shell glob
4
+ * expansion: bash provides it (linux/macOS) and Node >=22 expands test-path
5
+ * globs itself, but Windows PowerShell does neither on Node 18/20 — the
6
+ * literal pattern "tests/*.test.cjs" matches no file and the run exits 1.
7
+ * This script expands the pattern deterministically on every platform.
8
+ *
9
+ * Usage: node scripts/run-tests.cjs <dir> [<dir> ...]
10
+ * Runs every *.test.cjs DIRECTLY inside each listed directory (no recursion,
11
+ * so `tests` and `tests/scenarios` stay separately addressable).
12
+ */
13
+
14
+ const { spawnSync } = require('child_process');
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+
18
+ const dirs = process.argv.slice(2);
19
+ if (dirs.length === 0) {
20
+ console.error('Usage: node scripts/run-tests.cjs <dir> [<dir> ...]');
21
+ process.exit(1);
22
+ }
23
+
24
+ const files = [];
25
+ for (const dir of dirs) {
26
+ let entries;
27
+ try {
28
+ entries = fs.readdirSync(dir);
29
+ } catch (e) {
30
+ console.error(`run-tests: cannot read directory ${dir}: ${e.message}`);
31
+ process.exit(1);
32
+ }
33
+ for (const name of entries.sort()) {
34
+ if (name.endsWith('.test.cjs')) files.push(path.join(dir, name));
35
+ }
36
+ }
37
+
38
+ if (files.length === 0) {
39
+ console.error(`run-tests: no *.test.cjs files found in: ${dirs.join(', ')}`);
40
+ process.exit(1);
41
+ }
42
+
43
+ const result = spawnSync(process.execPath, ['--test', ...files], { stdio: 'inherit' });
44
+ process.exit(result.status ?? 1);