pan-wizard 3.21.1 → 3.24.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.
Files changed (136) hide show
  1. package/README.md +38 -24
  2. package/agents/pan-conductor.md +3 -3
  3. package/agents/pan-design-checker.md +83 -0
  4. package/agents/pan-designer.md +67 -0
  5. package/agents/pan-document_code.md +3 -2
  6. package/agents/pan-executor.md +2 -2
  7. package/agents/pan-plan-checker.md +19 -6
  8. package/agents/pan-planner.md +4 -1
  9. package/agents/pan-previewer.md +1 -1
  10. package/agents/pan-roadmapper.md +3 -1
  11. package/agents/pan-verifier.md +10 -10
  12. package/bin/install-lib.cjs +128 -17
  13. package/bin/install.js +480 -74
  14. package/commands/pan/army.md +20 -14
  15. package/commands/pan/audit-deployment.md +17 -14
  16. package/commands/pan/cost.md +2 -2
  17. package/commands/pan/debug.md +1 -1
  18. package/commands/pan/design-phase.md +77 -0
  19. package/commands/pan/exec-phase.md +2 -2
  20. package/commands/pan/experiment.md +18 -14
  21. package/commands/pan/focus-auto.md +5 -5
  22. package/commands/pan/focus-design.md +13 -1
  23. package/commands/pan/focus-exec.md +1 -1
  24. package/commands/pan/learn.md +1 -1
  25. package/commands/pan/links.md +3 -1
  26. package/commands/pan/map-codebase.md +4 -4
  27. package/commands/pan/mcp-bridge.md +1 -1
  28. package/commands/pan/milestone-new.md +3 -1
  29. package/commands/pan/optimize.md +2 -2
  30. package/commands/pan/patches.md +1 -1
  31. package/commands/pan/plan-phase.md +3 -1
  32. package/commands/pan/preview.md +3 -3
  33. package/commands/pan/profile.md +3 -3
  34. package/commands/pan/research-phase.md +1 -1
  35. package/commands/pan/retro.md +4 -1
  36. package/commands/pan/review-deep.md +1 -1
  37. package/commands/pan/settings.md +2 -2
  38. package/commands/pan/what-if.md +1 -1
  39. package/hooks/dist/pan-check-update.js +149 -38
  40. package/hooks/dist/pan-context-monitor.js +155 -81
  41. package/hooks/dist/pan-cost-logger.js +288 -13
  42. package/hooks/dist/pan-statusline.js +24 -8
  43. package/hooks/dist/pan-stop-guard.js +160 -0
  44. package/hooks/dist/pan-trace-logger.js +261 -19
  45. package/package.json +2 -2
  46. package/pan-wizard-core/bin/lib/bridge.cjs +1 -1
  47. package/pan-wizard-core/bin/lib/bus.cjs +19 -4
  48. package/pan-wizard-core/bin/lib/campaign.cjs +8 -3
  49. package/pan-wizard-core/bin/lib/codebase.cjs +15 -5
  50. package/pan-wizard-core/bin/lib/commands.cjs +88 -6
  51. package/pan-wizard-core/bin/lib/config.cjs +27 -2
  52. package/pan-wizard-core/bin/lib/constants.cjs +1 -0
  53. package/pan-wizard-core/bin/lib/core.cjs +113 -8
  54. package/pan-wizard-core/bin/lib/cost.cjs +61 -24
  55. package/pan-wizard-core/bin/lib/distill.cjs +22 -4
  56. package/pan-wizard-core/bin/lib/doc-lint.cjs +25 -7
  57. package/pan-wizard-core/bin/lib/experiment.cjs +25 -1
  58. package/pan-wizard-core/bin/lib/focus.cjs +42 -7
  59. package/pan-wizard-core/bin/lib/frontmatter.cjs +62 -31
  60. package/pan-wizard-core/bin/lib/git.cjs +51 -16
  61. package/pan-wizard-core/bin/lib/hud.cjs +17 -2
  62. package/pan-wizard-core/bin/lib/init.cjs +2 -5
  63. package/pan-wizard-core/bin/lib/knowledge.cjs +20 -1
  64. package/pan-wizard-core/bin/lib/learn-lint.cjs +50 -0
  65. package/pan-wizard-core/bin/lib/links.cjs +9 -6
  66. package/pan-wizard-core/bin/lib/lock.cjs +23 -4
  67. package/pan-wizard-core/bin/lib/memory-optimize.cjs +101 -17
  68. package/pan-wizard-core/bin/lib/memory-rebuild.cjs +26 -2
  69. package/pan-wizard-core/bin/lib/milestone.cjs +11 -5
  70. package/pan-wizard-core/bin/lib/optimize.cjs +18 -7
  71. package/pan-wizard-core/bin/lib/phase-remove.cjs +41 -2
  72. package/pan-wizard-core/bin/lib/phase.cjs +82 -11
  73. package/pan-wizard-core/bin/lib/preview.cjs +23 -4
  74. package/pan-wizard-core/bin/lib/review-deep.cjs +14 -6
  75. package/pan-wizard-core/bin/lib/roadmap.cjs +6 -2
  76. package/pan-wizard-core/bin/lib/runner.cjs +17 -7
  77. package/pan-wizard-core/bin/lib/squads.cjs +43 -11
  78. package/pan-wizard-core/bin/lib/state.cjs +32 -11
  79. package/pan-wizard-core/bin/lib/template.cjs +8 -3
  80. package/pan-wizard-core/bin/lib/verify-deploy.cjs +14 -2
  81. package/pan-wizard-core/bin/lib/verify-drift.cjs +15 -1
  82. package/pan-wizard-core/bin/lib/verify-preflight.cjs +9 -3
  83. package/pan-wizard-core/bin/lib/verify.cjs +84 -39
  84. package/pan-wizard-core/bin/lib/whatif.cjs +8 -1
  85. package/pan-wizard-core/bin/pan-tools.cjs +42 -19
  86. package/pan-wizard-core/learnings/index.json +7 -7
  87. package/pan-wizard-core/learnings/universal/autonomous-loop.md +5 -3
  88. package/pan-wizard-core/learnings/universal/concurrency.md +1 -1
  89. package/pan-wizard-core/references/design-methodology.md +94 -0
  90. package/pan-wizard-core/references/git-integration.md +9 -9
  91. package/pan-wizard-core/references/guardrails.md +4 -1
  92. package/pan-wizard-core/references/model-profile-resolution.md +1 -1
  93. package/pan-wizard-core/references/model-profiles.md +44 -34
  94. package/pan-wizard-core/references/planning-config.md +5 -5
  95. package/pan-wizard-core/references/verification-patterns.md +67 -0
  96. package/pan-wizard-core/templates/codebase/architecture.md +1 -1
  97. package/pan-wizard-core/templates/codebase/concerns.md +1 -1
  98. package/pan-wizard-core/templates/codebase/conventions.md +1 -1
  99. package/pan-wizard-core/templates/codebase/integrations.md +1 -1
  100. package/pan-wizard-core/templates/codebase/stack.md +1 -1
  101. package/pan-wizard-core/templates/codebase/structure.md +1 -1
  102. package/pan-wizard-core/templates/codebase/testing.md +1 -1
  103. package/pan-wizard-core/templates/design.md +146 -0
  104. package/pan-wizard-core/templates/discovery.md +3 -3
  105. package/pan-wizard-core/templates/milestone-archive.md +2 -2
  106. package/pan-wizard-core/templates/playbook.md +1 -1
  107. package/pan-wizard-core/templates/uat.md +1 -1
  108. package/pan-wizard-core/workflows/diagnose-issues.md +9 -7
  109. package/pan-wizard-core/workflows/exec-phase.md +28 -8
  110. package/pan-wizard-core/workflows/execute-plan.md +2 -3
  111. package/pan-wizard-core/workflows/health.md +24 -1
  112. package/pan-wizard-core/workflows/help.md +5 -4
  113. package/pan-wizard-core/workflows/learn.md +5 -5
  114. package/pan-wizard-core/workflows/map-codebase.md +1 -1
  115. package/pan-wizard-core/workflows/milestone-audit.md +1 -1
  116. package/pan-wizard-core/workflows/milestone-new.md +4 -4
  117. package/pan-wizard-core/workflows/new-project.md +47 -32
  118. package/pan-wizard-core/workflows/optimize.md +8 -8
  119. package/pan-wizard-core/workflows/pause.md +1 -1
  120. package/pan-wizard-core/workflows/phase-tests.md +2 -2
  121. package/pan-wizard-core/workflows/plan-phase.md +4 -0
  122. package/pan-wizard-core/workflows/profile.md +13 -10
  123. package/pan-wizard-core/workflows/research-phase.md +1 -1
  124. package/pan-wizard-core/workflows/resume-project.md +1 -1
  125. package/pan-wizard-core/workflows/settings.md +9 -9
  126. package/pan-wizard-core/workflows/transition.md +24 -8
  127. package/pan-wizard-core/workflows/update.md +23 -20
  128. package/pan-wizard-core/workflows/verify-phase.md +7 -2
  129. package/pan-zcode/KNOWN-BETA-RISKS.md +8 -0
  130. package/pan-zcode/README.md +14 -2
  131. package/pan-zcode/bin/install-zcode.js +1 -1
  132. package/pan-zcode/mcp/native-tools.cjs +1 -1
  133. package/pan-zcode/mcp/server.cjs +76 -14
  134. package/scripts/build-hooks.js +2 -1
  135. package/scripts/generate-skills-docs.py +0 -4
  136. package/scripts/run-tests.cjs +11 -4
@@ -195,11 +195,16 @@ function cmdStateUpdate(cwd, field, value) {
195
195
  writeStateMd(statePath, content, cwd);
196
196
  output({ updated: true });
197
197
  } else {
198
- output({ updated: false, reason: `Field "${field}" not found in state.md` });
198
+ // error key ⇒ exit 1: the caller asked for a field update that did not
199
+ // happen, so state.md does not say what the caller now believes it says.
200
+ output({ updated: false, reason: `Field "${field}" not found in state.md`, error: 'field_not_found' });
199
201
  }
200
202
  } catch {
201
203
  // state.md does not exist or is unreadable -- report gracefully
202
- output({ updated: false, reason: 'state.md not found' });
204
+ // Same condition `state json` reports as {error:'state.md not found'} at exit 1.
205
+ // Reporting it as a bare `updated: false` was the identical defect wearing a
206
+ // different key — one file, one missing file, two exit codes.
207
+ output({ updated: false, reason: 'state.md not found', error: 'state_not_found' });
203
208
  }
204
209
  }
205
210
 
@@ -256,6 +261,9 @@ function cmdStateAdvancePlan(cwd, raw) {
256
261
  content = stateReplaceField(content, 'Status', 'Phase complete — ready for verification') || content;
257
262
  content = stateReplaceField(content, 'Last Activity', today) || content;
258
263
  writeStateMd(statePath, content, cwd);
264
+ // No error key, exit 0: `last_plan` is the NORMAL end-of-phase signal — the
265
+ // phase advanced to "ready for verification" and state.md was written. Gating on
266
+ // it would make every completed phase look like a failure.
259
267
  output({ advanced: false, reason: 'last_plan', current_plan: currentPlan, total_plans: totalPlans, status: 'ready_for_verification' }, raw, 'false');
260
268
  } else {
261
269
  const newPlan = currentPlan + 1;
@@ -303,7 +311,7 @@ function cmdStateRecordMetric(cwd, options, raw) {
303
311
  writeStateMd(statePath, content, cwd);
304
312
  output({ recorded: true, phase, plan, duration }, raw, 'true');
305
313
  } else {
306
- output({ recorded: false, reason: 'Performance Metrics section not found in state.md' }, raw, 'false');
314
+ output({ recorded: false, reason: 'Performance Metrics section not found in state.md', error: 'metrics_section_missing' }, raw, 'false');
307
315
  }
308
316
  }
309
317
 
@@ -345,7 +353,7 @@ function cmdStateUpdateProgress(cwd, raw) {
345
353
  writeStateMd(statePath, content, cwd);
346
354
  output({ updated: true, percent, completed: totalSummaries, total: totalPlans, bar: progressStr }, raw, progressStr);
347
355
  } else {
348
- output({ updated: false, reason: 'Progress field not found in state.md' }, raw, 'false');
356
+ output({ updated: false, reason: 'Progress field not found in state.md', error: 'progress_field_missing' }, raw, 'false');
349
357
  }
350
358
  }
351
359
 
@@ -369,7 +377,7 @@ function cmdStateAddDecision(cwd, options, raw) {
369
377
  summaryText = readTextArgOrFile(cwd, summary, summary_file, 'summary');
370
378
  rationaleText = readTextArgOrFile(cwd, rationale || '', rationale_file, 'rationale');
371
379
  } catch (err) {
372
- output({ added: false, reason: err.message }, raw, 'false');
380
+ output({ added: false, reason: err.message, error: err.message || 'decision_read_failed' }, raw, 'false');
373
381
  return;
374
382
  }
375
383
 
@@ -389,7 +397,7 @@ function cmdStateAddDecision(cwd, options, raw) {
389
397
  writeStateMd(statePath, content, cwd);
390
398
  output({ added: true, decision: entry }, raw, 'true');
391
399
  } else {
392
- output({ added: false, reason: 'Decisions section not found in state.md' }, raw, 'false');
400
+ output({ added: false, reason: 'Decisions section not found in state.md', error: 'decisions_section_missing' }, raw, 'false');
393
401
  }
394
402
  }
395
403
 
@@ -410,7 +418,7 @@ function cmdStateAddBlocker(cwd, text, raw) {
410
418
  try {
411
419
  blockerText = readTextArgOrFile(cwd, blockerOptions.text, blockerOptions.text_file, 'blocker');
412
420
  } catch (err) {
413
- output({ added: false, reason: err.message }, raw, 'false');
421
+ output({ added: false, reason: err.message, error: err.message || 'blocker_read_failed' }, raw, 'false');
414
422
  return;
415
423
  }
416
424
 
@@ -428,7 +436,7 @@ function cmdStateAddBlocker(cwd, text, raw) {
428
436
  writeStateMd(statePath, content, cwd);
429
437
  output({ added: true, blocker: blockerText }, raw, 'true');
430
438
  } else {
431
- output({ added: false, reason: 'Blockers section not found in state.md' }, raw, 'false');
439
+ output({ added: false, reason: 'Blockers section not found in state.md', error: 'blockers_section_missing' }, raw, 'false');
432
440
  }
433
441
  }
434
442
 
@@ -456,6 +464,17 @@ function cmdStateResolveBlocker(cwd, text, raw) {
456
464
  return !line.toLowerCase().includes(text.toLowerCase());
457
465
  });
458
466
 
467
+ // M27: if nothing was removed, no blocker matched the given text. Report
468
+ // resolved:false rather than falsely claiming a resolution (and skip the
469
+ // write entirely — the file is unchanged).
470
+ if (filtered.length === lines.length) {
471
+ // error key ⇒ exit 1. M27 stopped this from falsely claiming a resolution;
472
+ // the exit code finishes the job. The blocker is STILL OPEN and the caller
473
+ // that asked to clear it would otherwise carry on believing it is gone.
474
+ output({ resolved: false, reason: 'no matching blocker', blocker: text, error: 'blocker_not_matched' }, raw, 'false');
475
+ return;
476
+ }
477
+
459
478
  let newBody = filtered.join('\n');
460
479
  // If section is now empty, add placeholder
461
480
  if (!newBody.trim() || !newBody.includes('- ')) {
@@ -466,7 +485,7 @@ function cmdStateResolveBlocker(cwd, text, raw) {
466
485
  writeStateMd(statePath, content, cwd);
467
486
  output({ resolved: true, blocker: text }, raw, 'true');
468
487
  } else {
469
- output({ resolved: false, reason: 'Blockers section not found in state.md' }, raw, 'false');
488
+ output({ resolved: false, reason: 'Blockers section not found in state.md', error: 'blockers_section_missing' }, raw, 'false');
470
489
  }
471
490
  }
472
491
 
@@ -510,7 +529,7 @@ function cmdStateRecordSession(cwd, options, raw) {
510
529
  try { require('./memory-optimize.cjs').maybeAutoOptimizeMemory(cwd); } catch { /* best-effort */ }
511
530
  output({ recorded: true, updated }, raw, 'true');
512
531
  } else {
513
- output({ recorded: false, reason: 'No session fields found in state.md' }, raw, 'false');
532
+ output({ recorded: false, reason: 'No session fields found in state.md', error: 'session_fields_missing' }, raw, 'false');
514
533
  }
515
534
  }
516
535
 
@@ -1012,7 +1031,9 @@ function cmdDashboard(cwd, raw) {
1012
1031
  lines.push(`Blockers: ${blockerCount}`);
1013
1032
  if (lastActivity) lines.push(`Last Activity: ${lastActivity}${lastActivityDesc ? ' — ' + lastActivityDesc : ''}`);
1014
1033
  if (nextPhase) lines.push(`Next Phase: ${nextPhase.number} — ${nextPhase.name}`);
1015
- output(result, false, lines.join('\n'));
1034
+ // raw=true so output() emits the human summary; passing false discarded it
1035
+ // and printed JSON, making the whole lines[] block dead (M28, ADR audit 2026-08).
1036
+ output(result, true, lines.join('\n'));
1016
1037
  return;
1017
1038
  }
1018
1039
 
@@ -9,7 +9,7 @@ const {
9
9
  SIMPLE_TASK_THRESHOLD, SIMPLE_FILE_THRESHOLD, COMPLEX_TASK_THRESHOLD, COMPLEX_FILE_THRESHOLD,
10
10
  } = require('./constants.cjs');
11
11
  const { planningPath, phasesPath } = require('./utils.cjs');
12
- const { normalizePhaseName, findPhaseInternal, generateSlugInternal, toPosix, output, error } = require('./core.cjs');
12
+ const { normalizePhaseName, findPhaseInternal, generateSlugInternal, toPosix, output, EXIT_OK, error } = require('./core.cjs');
13
13
  const { reconstructFrontmatter } = require('./frontmatter.cjs');
14
14
 
15
15
  /**
@@ -64,8 +64,13 @@ function cmdTemplateSelect(cwd, planPath, raw) {
64
64
  const result = { template, type, taskCount, fileCount, hasDecisions };
65
65
  output(result, raw, template);
66
66
  } catch (err) {
67
- // Fallback to standard template on any read/parse error
68
- output({ template: 'templates/summary-standard.md', type: 'standard', error: err.message }, raw, 'templates/summary-standard.md');
67
+ // Fallback to standard template on any read/parse error.
68
+ // EXIT_OK: this is a RESULT, not a failure — the command answered the question
69
+ // it was asked ("which summary template?") with the documented default, and
70
+ // `error` here is only the reason detection fell back. Callers use it as
71
+ // `TEMPLATE=$(pan-tools template select … --raw)`; exiting non-zero would
72
+ // report a failure for a command that produced a usable answer.
73
+ output({ template: 'templates/summary-standard.md', type: 'standard', error: err.message }, raw, 'templates/summary-standard.md', EXIT_OK);
69
74
  }
70
75
  }
71
76
 
@@ -90,8 +90,20 @@ function validateRuntimeInstall(cwd, configDir, runtime) {
90
90
  if (hooks && typeof hooks === 'object') {
91
91
  for (const hookArr of Object.values(hooks)) {
92
92
  if (!Array.isArray(hookArr)) continue;
93
- for (const hook of hookArr) {
94
- if (hook.command) hookCommands.push(hook.command);
93
+ // M29: PAN installs hooks in the nested Claude shape — each event maps to
94
+ // an array of GROUPS, and each group holds the real commands under
95
+ // group.hooks[]: { matcher, hooks: [{ type, command }] }. The old code
96
+ // only read group.command (undefined in that shape), so it validated
97
+ // nothing for PAN's own hooks. Collect both the flat group.command (other
98
+ // runtimes) AND every command nested in group.hooks[].
99
+ for (const group of hookArr) {
100
+ if (!group || typeof group !== 'object') continue;
101
+ if (group.command) hookCommands.push(group.command);
102
+ if (Array.isArray(group.hooks)) {
103
+ for (const h of group.hooks) {
104
+ if (h && h.command) hookCommands.push(h.command);
105
+ }
106
+ }
95
107
  }
96
108
  }
97
109
  }
@@ -135,7 +135,7 @@ function calculateDriftScore(violations, filesChecked, rulesCount) {
135
135
  * @param {string} [sinceRef] - Git ref to diff against (default: HEAD)
136
136
  * @returns {string[]} Array of relative file paths
137
137
  */
138
- function getChangedFiles(cwd, sinceRef) {
138
+ function getChangedFiles(cwd, sinceRef, opts = {}) {
139
139
  const ref = sinceRef || 'HEAD';
140
140
  // Try staged + unstaged first, then diff against ref
141
141
  const result = execGit(cwd, ['diff', '--name-only', ref]);
@@ -150,6 +150,20 @@ function getChangedFiles(cwd, sinceRef) {
150
150
  if (line.trim()) allFiles.add(line.trim());
151
151
  }
152
152
  }
153
+ // Untracked files are OPT-IN because the two consumers want different things.
154
+ // Drift detection asks "what changed against the baseline", where an untracked
155
+ // scratch file is noise. The stub gate asks "is any code in this handoff a stub",
156
+ // and a brand-new file is precisely the normal shape of PAN execution — a plan
157
+ // creates files, a later step commits them — so during that window `git diff` lists
158
+ // nothing and a fully stubbed new module sailed through the gate.
159
+ if (opts.includeUntracked) {
160
+ const untracked = execGit(cwd, ['ls-files', '--others', '--exclude-standard']);
161
+ if (untracked.exitCode === 0) {
162
+ for (const line of untracked.stdout.split(/\r?\n/)) {
163
+ if (line.trim()) allFiles.add(line.trim());
164
+ }
165
+ }
166
+ }
153
167
  // Filter out binary extensions and limit
154
168
  const filtered = [];
155
169
  for (const f of allFiles) {
@@ -6,7 +6,7 @@
6
6
 
7
7
  const fs = require('fs');
8
8
  const path = require('path');
9
- const { safeReadFile, execGit, findPhaseInternal, output } = require('./core.cjs');
9
+ const { safeReadFile, execGit, findPhaseInternal, output, normalizePhaseName } = require('./core.cjs');
10
10
  const { readStateSafe } = require('./state.cjs');
11
11
  const {
12
12
  STATE_FILE, ROADMAP_FILE, CONFIG_FILE, PATTERNS_FILE, PHASE_DIR_RE,
@@ -162,7 +162,13 @@ function cmdDepsValidate(cwd, raw) {
162
162
  const headerRe = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
163
163
  let match;
164
164
  while ((match = headerRe.exec(roadmapContent)) !== null) {
165
- roadmapPhases.set(match[1], match[2].trim());
165
+ // Key both maps by the NORMALIZED number. Roadmap headings are unpadded
166
+ // ("Phase 1") and directory names are zero-padded ("01-foundation"), so
167
+ // keying on the raw values made every phase mismatch in BOTH directions:
168
+ // `deps validate` reported "in roadmap but no directory on disk" for phases
169
+ // whose directory was right there, plus an "orphaned directory" warning for
170
+ // the same phase — inventing errors on a roadmap in PAN's own template shape.
171
+ roadmapPhases.set(normalizePhaseName(match[1]), match[2].trim());
166
172
  }
167
173
  } else {
168
174
  issues.push({ type: 'warning', message: 'roadmap.md not found' });
@@ -176,7 +182,7 @@ function cmdDepsValidate(cwd, raw) {
176
182
  if (entry.isDirectory()) {
177
183
  const dirMatch = entry.name.match(PHASE_DIR_RE);
178
184
  if (dirMatch) {
179
- diskPhases.set(dirMatch[1], entry.name);
185
+ diskPhases.set(normalizePhaseName(dirMatch[1]), entry.name);
180
186
  }
181
187
  }
182
188
  }
@@ -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, escapeRegex } = require('./core.cjs');
8
+ const { safeReadFile, normalizePhaseName, comparePhaseNum, execGit, findPhaseInternal, getMilestoneInfo, toPosix, output, EXIT_OK, error, escapeRegex } = require('./core.cjs');
9
9
  const { extractFrontmatter, parseMustHavesBlock } = require('./frontmatter.cjs');
10
10
  const { writeStateMd, readStateSafe } = require('./state.cjs');
11
11
  const {
@@ -362,7 +362,17 @@ function cmdVerifyArtifacts(cwd, planFilePath, raw) {
362
362
  if (!content) { output({ error: 'File not found', path: planFilePath }, raw); return; }
363
363
  const r = checkArtifacts(cwd, content);
364
364
  if (r.total === 0) {
365
- output({ error: 'No must_haves.artifacts found in frontmatter', path: planFilePath }, raw);
365
+ // EXIT_OK: "nothing declared to check" is a RESULT, not a failure. Two pieces of
366
+ // shipped evidence: (1) the plan template ships `must_haves: { truths: [],
367
+ // artifacts: [], key_links: [] }` (pan-wizard-core/templates/phase-prompt.md,
368
+ // template.cjs generatePlanTemplate), so an empty block is the documented default
369
+ // state of a scaffolded plan; presence of the block is enforced by
370
+ // `verify plan-structure`, not here. (2) parseMustHavesBlock currently matches the
371
+ // block header at 4-space indent while every shipped template and agent emits it at
372
+ // 2 — so this branch is reached for *every* plan authored in PAN's own format, and
373
+ // exiting non-zero would fail every real `verify artifacts` call. Reclassifying
374
+ // this as a failure requires fixing that indentation mismatch first.
375
+ output({ error: 'No must_haves.artifacts found in frontmatter', path: planFilePath }, raw, undefined, EXIT_OK);
366
376
  return;
367
377
  }
368
378
  output(r, raw, r.all_passed ? 'valid' : 'invalid');
@@ -425,7 +435,10 @@ function cmdVerifyKeyLinks(cwd, planFilePath, raw) {
425
435
  if (!content) { output({ error: 'File not found', path: planFilePath }, raw); return; }
426
436
  const r = checkKeyLinks(cwd, content);
427
437
  if (r.total === 0) {
428
- output({ error: 'No must_haves.key_links found in frontmatter', path: planFilePath }, raw);
438
+ // EXIT_OK: same reasoning as cmdVerifyArtifacts above — an empty key_links block is
439
+ // the shipped template default, and the parseMustHavesBlock indent mismatch means
440
+ // this branch fires for every plan written in PAN's own format.
441
+ output({ error: 'No must_haves.key_links found in frontmatter', path: planFilePath }, raw, undefined, EXIT_OK);
429
442
  return;
430
443
  }
431
444
  output(r, raw, r.all_verified ? 'valid' : 'invalid');
@@ -490,8 +503,11 @@ function reconcilePhase(cwd, phaseNum) {
490
503
  function cmdVerifyReconcile(cwd, phaseNum, raw) {
491
504
  if (!phaseNum) { error('Usage: verify reconcile <phase>'); }
492
505
  const r = reconcilePhase(cwd, phaseNum);
493
- output(r, raw, r.reconciled ? 'valid' : 'invalid');
494
- process.exit(r.reconciled ? 0 : 1);
506
+ // Exit non-zero on contradiction so exec-phase's auto-advance gate actually
507
+ // stops on a rubber-stamped verification. output() previously hard-coded
508
+ // exit 0, making the old trailing process.exit dead code (H3, ADR audit
509
+ // 2026-08); the exitCode arg restores the gate.
510
+ output(r, raw, r.reconciled ? 'valid' : 'invalid', r.reconciled ? 0 : 1);
495
511
  }
496
512
 
497
513
  // ─── Stub / fake-return scanner (ADR-0036 review — closes the hardcoded
@@ -516,7 +532,9 @@ const STUB_CODE_EXT = /\.(js|cjs|mjs|jsx|ts|tsx|py|go|rb|java|php|rs|c|cc|cpp|h|
516
532
  * @returns {{scanned, findings: Array, blocking: number, total: number}}
517
533
  */
518
534
  function scanStubs(cwd, opts = {}) {
519
- let files = Array.isArray(opts.files) ? opts.files : getChangedFiles(cwd);
535
+ // includeUntracked: a stub gate that cannot see new files is not a gate. See the
536
+ // note in verify-drift.cjs getChangedFiles for why drift does not want the same.
537
+ let files = Array.isArray(opts.files) ? opts.files : getChangedFiles(cwd, null, { includeUntracked: true });
520
538
  files = (files || []).filter(f => STUB_CODE_EXT.test(f));
521
539
  const findings = [];
522
540
  for (const rel of files) {
@@ -538,8 +556,9 @@ function scanStubs(cwd, opts = {}) {
538
556
 
539
557
  function cmdVerifyStubs(cwd, opts = {}, raw) {
540
558
  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);
559
+ // --gate must exit non-zero on blocking findings; output() used to hard-exit 0
560
+ // before the gate check, so the gate was dead (M30, ADR audit 2026-08).
561
+ output(r, raw, r.blocking === 0 ? 'valid' : 'invalid', opts.gate ? (r.blocking > 0 ? 1 : 0) : 0);
543
562
  }
544
563
 
545
564
  /**
@@ -757,8 +776,17 @@ function checkStateFile(cwd, addIssue, repairs) {
757
776
  if (stateContent === null) {
758
777
  // skip further state checks
759
778
  } else {
760
- // Extract phase references (e.g. "Phase 3" or "phase 01") from state.md
761
- const phaseRefs = [...stateContent.matchAll(/[Pp]hase\s+(\d+(?:\.\d+)*)/g)].map(match => match[1]);
779
+ // Extract phase references (e.g. "Phase 3" or "phase 01") from state.md,
780
+ // deduped by normalized phase number. A real state.md mentions the same phase
781
+ // several times -- a total, a progress line, a decisions note -- and one missing
782
+ // directory must produce one warning, not one per mention. Keying on the
783
+ // normalized form collapses "Phase 3" and "Phase 03"; the first spelling seen
784
+ // is kept so the message quotes what the file actually says.
785
+ const phaseRefs = new Map();
786
+ for (const match of stateContent.matchAll(/[Pp]hase\s+(\d+(?:\.\d+)*)/g)) {
787
+ const key = normalizePhaseName(match[1]);
788
+ if (!phaseRefs.has(key)) phaseRefs.set(key, match[1]);
789
+ }
762
790
  // Get disk phases for cross-reference
763
791
  const diskPhases = new Set();
764
792
  try {
@@ -773,7 +801,7 @@ function checkStateFile(cwd, addIssue, repairs) {
773
801
  // phases/ directory may not exist yet
774
802
  }
775
803
  // Check for invalid references -- only warn if there are phases on disk
776
- for (const ref of phaseRefs) {
804
+ for (const ref of phaseRefs.values()) {
777
805
  const normalizedRef = String(parseInt(ref, 10)).padStart(2, '0');
778
806
  if (!diskPhases.has(ref) && !diskPhases.has(normalizedRef) && !diskPhases.has(String(parseInt(ref, 10)))) {
779
807
  if (diskPhases.size > 0) {
@@ -866,10 +894,28 @@ function crossCheckRoadmapDisk(cwd, phasesDirPath, addIssue) {
866
894
  }
867
895
  } catch { /* phases/ may not exist yet */ }
868
896
 
897
+ // A roadmap phase ahead of the current position has simply not been planned yet.
898
+ // PAN's own workflow creates the condition: /pan:new-project writes a roadmap
899
+ // declaring every phase up front, and /pan:plan-phase creates directories one at a
900
+ // time -- so warning on those would make `degraded` the permanent state of every
901
+ // multi-phase project and drain the verdict of signal. Only a phase at or behind
902
+ // the current position is genuinely suspect. With no readable current phase we
903
+ // cannot tell ahead from behind, so fall back to warning on everything.
904
+ let currentPhase = null;
905
+ try {
906
+ const stateContent = fs.readFileSync(path.join(planningPath(cwd), STATE_FILE), 'utf-8');
907
+ const m = stateContent.match(/\*\*Current Phase:\*\*\s*(\d+[A-Z]?(?:\.\d+)*)/i);
908
+ if (m) currentPhase = m[1];
909
+ } catch { /* no state.md -- checkStateFile reports that; stay conservative here */ }
910
+
869
911
  for (const p of roadmapPhases) {
870
912
  const padded = String(parseInt(p, 10)).padStart(2, '0');
871
- if (!diskPhases.has(p) && !diskPhases.has(padded))
913
+ if (diskPhases.has(p) || diskPhases.has(padded)) continue;
914
+ if (currentPhase !== null && comparePhaseNum(p, currentPhase) > 0) {
915
+ addIssue('info', 'I002', `Phase ${p} in ${ROADMAP_FILE} is not planned yet (current phase: ${currentPhase})`, `Run /pan:plan-phase ${p} when you reach it`);
916
+ } else {
872
917
  addIssue('warning', 'W006', `Phase ${p} in ${ROADMAP_FILE} but no directory on disk`, 'Create phase directory or remove from roadmap');
918
+ }
873
919
  }
874
920
  for (const p of diskPhases) {
875
921
  const unpadded = String(parseInt(p, 10));
@@ -919,17 +965,13 @@ function repairIssues(cwd, repairs) {
919
965
  switch (repair) {
920
966
  case 'createConfig':
921
967
  case 'resetConfig': {
922
- // Write a fresh config.json with sensible defaults
923
- const defaults = {
924
- model_profile: 'balanced',
925
- commit_docs: true,
926
- search_gitignored: false,
927
- branching_strategy: 'none',
928
- research: true,
929
- plan_checker: true,
930
- verifier: true,
931
- parallelization: true,
932
- };
968
+ // Write the canonical NESTED config via buildConfigDefaults. The old
969
+ // flat literal (research/plan_checker/verifier at top level) did NOT
970
+ // match the schema the gate reads (config.workflow.verifier), so
971
+ // --repair silently disabled the verification gate (M31, ADR audit
972
+ // 2026-08; note the flat form also drifted plan_checker vs plan_check).
973
+ const { buildConfigDefaults } = require('./config.cjs');
974
+ const defaults = buildConfigDefaults(false, {});
933
975
  fs.writeFileSync(configFullPath, JSON.stringify(defaults, null, 2), 'utf-8');
934
976
  repairActions.push({ action: repair, success: true, path: CONFIG_FILE });
935
977
  break;
@@ -970,7 +1012,7 @@ function repairIssues(cwd, repairs) {
970
1012
  }
971
1013
  } catch (err) {
972
1014
  // Repair action failed -- record the error so callers can report it
973
- repairActions.push({ action: repair, success: false, error: err.message });
1015
+ repairActions.push({ action: repair, success: false, error: err.message || 'repair_failed' });
974
1016
  }
975
1017
  }
976
1018
 
@@ -1111,7 +1153,7 @@ function syncRequirementCheckboxes(cwd) {
1111
1153
  }
1112
1154
 
1113
1155
  if (fixed > 0) {
1114
- try { fs.writeFileSync(reqPath, reqContent, 'utf-8'); } catch (e) { return { fixed: 0, error: e.message }; }
1156
+ try { fs.writeFileSync(reqPath, reqContent, 'utf-8'); } catch (e) { return { fixed: 0, error: e.message || 'requirements_write_failed' }; }
1115
1157
  }
1116
1158
  return { fixed };
1117
1159
  }
@@ -1161,7 +1203,7 @@ function syncRoadmapPlanCheckboxes(cwd) {
1161
1203
  }
1162
1204
 
1163
1205
  if (fixed > 0) {
1164
- try { fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8'); } catch (e) { return { fixed: 0, error: e.message }; }
1206
+ try { fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8'); } catch (e) { return { fixed: 0, error: e.message || 'roadmap_write_failed' }; }
1165
1207
  }
1166
1208
  return { fixed };
1167
1209
  }
@@ -1308,19 +1350,6 @@ function cmdValidateHealth(cwd, options, raw) {
1308
1350
  }
1309
1351
  }
1310
1352
 
1311
- // Determine overall status from error/warning counts
1312
- let status;
1313
- if (errors.length > 0) {
1314
- status = HEALTH_STATUS.BROKEN;
1315
- } else if (warnings.length > 0) {
1316
- status = HEALTH_STATUS.DEGRADED;
1317
- } else {
1318
- status = HEALTH_STATUS.HEALTHY;
1319
- }
1320
-
1321
- const repairableCount = errors.filter(e => e.repairable).length +
1322
- warnings.filter(w => w.repairable).length;
1323
-
1324
1353
  // Check 11 (optional): drift analysis
1325
1354
  let driftResult;
1326
1355
  if (options.drift) {
@@ -1352,6 +1381,22 @@ function cmdValidateHealth(cwd, options, raw) {
1352
1381
  }
1353
1382
  }
1354
1383
 
1384
+ // Determine overall status from error/warning counts. This must run after every
1385
+ // check that can call addIssue -- computing it earlier left DRIFT_HIGH and
1386
+ // LINKS_ERR sitting in `warnings` while the verdict still read `healthy`, which
1387
+ // contradicts the documented behaviour of --links ("errors degrade health").
1388
+ let status;
1389
+ if (errors.length > 0) {
1390
+ status = HEALTH_STATUS.BROKEN;
1391
+ } else if (warnings.length > 0) {
1392
+ status = HEALTH_STATUS.DEGRADED;
1393
+ } else {
1394
+ status = HEALTH_STATUS.HEALTHY;
1395
+ }
1396
+
1397
+ const repairableCount = errors.filter(e => e.repairable).length +
1398
+ warnings.filter(w => w.repairable).length;
1399
+
1355
1400
  const result = {
1356
1401
  status,
1357
1402
  errors,
@@ -79,7 +79,7 @@ function buildCounterfactualContext(cwd, phaseNum, scenario) {
79
79
 
80
80
  return {
81
81
  phase: String(phaseNum),
82
- phase_name: phaseInfo.name || null,
82
+ phase_name: phaseInfo.phase_name || null,
83
83
  directory: toPosix(phaseInfo.directory),
84
84
  scenario,
85
85
  slug: scenarioSlug(scenario),
@@ -256,6 +256,13 @@ function cmdWhatifPrepare(cwd, phaseNum, scenario, raw) {
256
256
  const ctx = buildCounterfactualContext(cwd, phaseNum, scenario);
257
257
  if (ctx.error) { output(ctx, raw); return; }
258
258
  const wt = createWorktree(cwd, phaseNum, scenario);
259
+ // `worktree_error` (not `error`) because the payload also carries the context that
260
+ // WAS resolved, and `error` would read as "the context failed". It is still an
261
+ // error-family key, so output() derives exit 1 from it exactly as it does for the
262
+ // `ctx.error` guard one line above — which is the point: the two adjacent failures
263
+ // of the same command must not report different exit codes just because one of
264
+ // them renamed the key. Callers cannot proceed either way: what-if stage 2 spawns
265
+ // an agent INTO worktree_path, and there is no worktree.
259
266
  if (wt.error) { output({ ...ctx, worktree_error: wt.error }, raw); return; }
260
267
  output({ ...ctx, worktree: wt }, raw);
261
268
  }