pan-wizard 2.8.1

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 (164) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +772 -0
  3. package/agents/pan-debugger.md +1246 -0
  4. package/agents/pan-document_code.md +965 -0
  5. package/agents/pan-executor.md +469 -0
  6. package/agents/pan-integration-checker.md +443 -0
  7. package/agents/pan-phase-researcher.md +572 -0
  8. package/agents/pan-plan-checker.md +763 -0
  9. package/agents/pan-planner.md +1297 -0
  10. package/agents/pan-project-researcher.md +647 -0
  11. package/agents/pan-research-synthesizer.md +239 -0
  12. package/agents/pan-reviewer.md +112 -0
  13. package/agents/pan-roadmapper.md +642 -0
  14. package/agents/pan-verifier.md +672 -0
  15. package/assets/pan-logo-2000-transparent.svg +30 -0
  16. package/assets/pan-logo-2000.svg +43 -0
  17. package/assets/terminal.svg +119 -0
  18. package/bin/install-lib.cjs +616 -0
  19. package/bin/install.js +1936 -0
  20. package/commands/pan/add-phase.md +44 -0
  21. package/commands/pan/assumptions.md +47 -0
  22. package/commands/pan/audit-deployment.md +378 -0
  23. package/commands/pan/debug.md +168 -0
  24. package/commands/pan/discord.md +19 -0
  25. package/commands/pan/discuss-phase.md +84 -0
  26. package/commands/pan/exec-phase.md +45 -0
  27. package/commands/pan/focus-auto.md +323 -0
  28. package/commands/pan/focus-design.md +816 -0
  29. package/commands/pan/focus-exec.md +316 -0
  30. package/commands/pan/focus-plan.md +101 -0
  31. package/commands/pan/focus-scan.md +272 -0
  32. package/commands/pan/focus-sync.md +104 -0
  33. package/commands/pan/health.md +23 -0
  34. package/commands/pan/help.md +23 -0
  35. package/commands/pan/insert-phase.md +33 -0
  36. package/commands/pan/map-codebase.md +72 -0
  37. package/commands/pan/milestone-audit.md +37 -0
  38. package/commands/pan/milestone-cleanup.md +19 -0
  39. package/commands/pan/milestone-done.md +137 -0
  40. package/commands/pan/milestone-gaps.md +35 -0
  41. package/commands/pan/milestone-new.md +45 -0
  42. package/commands/pan/new-project.md +43 -0
  43. package/commands/pan/patches.md +110 -0
  44. package/commands/pan/pause.md +39 -0
  45. package/commands/pan/phase-budget.md +23 -0
  46. package/commands/pan/phase-tests.md +42 -0
  47. package/commands/pan/plan-phase.md +46 -0
  48. package/commands/pan/profile.md +36 -0
  49. package/commands/pan/progress.md +25 -0
  50. package/commands/pan/quick.md +42 -0
  51. package/commands/pan/remove-phase.md +32 -0
  52. package/commands/pan/research-phase.md +190 -0
  53. package/commands/pan/resume.md +41 -0
  54. package/commands/pan/retro.md +33 -0
  55. package/commands/pan/settings.md +37 -0
  56. package/commands/pan/todo-add.md +48 -0
  57. package/commands/pan/todo-check.md +46 -0
  58. package/commands/pan/update.md +38 -0
  59. package/commands/pan/verify-phase.md +39 -0
  60. package/hooks/dist/pan-check-update.js +62 -0
  61. package/hooks/dist/pan-context-monitor.js +122 -0
  62. package/hooks/dist/pan-statusline.js +108 -0
  63. package/package.json +66 -0
  64. package/pan-wizard-core/bin/lib/codebase.cjs +746 -0
  65. package/pan-wizard-core/bin/lib/commands.cjs +1435 -0
  66. package/pan-wizard-core/bin/lib/config.cjs +611 -0
  67. package/pan-wizard-core/bin/lib/constants.cjs +696 -0
  68. package/pan-wizard-core/bin/lib/context-budget.cjs +150 -0
  69. package/pan-wizard-core/bin/lib/core.cjs +650 -0
  70. package/pan-wizard-core/bin/lib/focus.cjs +900 -0
  71. package/pan-wizard-core/bin/lib/frontmatter.cjs +442 -0
  72. package/pan-wizard-core/bin/lib/init.cjs +881 -0
  73. package/pan-wizard-core/bin/lib/milestone.cjs +276 -0
  74. package/pan-wizard-core/bin/lib/phase.cjs +1212 -0
  75. package/pan-wizard-core/bin/lib/roadmap.cjs +470 -0
  76. package/pan-wizard-core/bin/lib/state.cjs +1029 -0
  77. package/pan-wizard-core/bin/lib/template.cjs +314 -0
  78. package/pan-wizard-core/bin/lib/utils.cjs +171 -0
  79. package/pan-wizard-core/bin/lib/verify.cjs +1808 -0
  80. package/pan-wizard-core/bin/pan-tools.cjs +773 -0
  81. package/pan-wizard-core/references/checkpoints.md +776 -0
  82. package/pan-wizard-core/references/continuation-format.md +249 -0
  83. package/pan-wizard-core/references/decimal-phase-calculation.md +65 -0
  84. package/pan-wizard-core/references/git-integration.md +248 -0
  85. package/pan-wizard-core/references/git-planning-commit.md +38 -0
  86. package/pan-wizard-core/references/model-profile-resolution.md +34 -0
  87. package/pan-wizard-core/references/model-profiles.md +111 -0
  88. package/pan-wizard-core/references/phase-argument-parsing.md +61 -0
  89. package/pan-wizard-core/references/planning-config.md +196 -0
  90. package/pan-wizard-core/references/questioning.md +145 -0
  91. package/pan-wizard-core/references/tdd.md +263 -0
  92. package/pan-wizard-core/references/ui-brand.md +160 -0
  93. package/pan-wizard-core/references/verification-patterns.md +612 -0
  94. package/pan-wizard-core/templates/codebase/architecture.md +283 -0
  95. package/pan-wizard-core/templates/codebase/best-practices.md +133 -0
  96. package/pan-wizard-core/templates/codebase/concerns.md +325 -0
  97. package/pan-wizard-core/templates/codebase/conventions.md +307 -0
  98. package/pan-wizard-core/templates/codebase/integrations.md +305 -0
  99. package/pan-wizard-core/templates/codebase/relationships.md +124 -0
  100. package/pan-wizard-core/templates/codebase/stack.md +199 -0
  101. package/pan-wizard-core/templates/codebase/structure.md +298 -0
  102. package/pan-wizard-core/templates/codebase/testing.md +480 -0
  103. package/pan-wizard-core/templates/config.json +37 -0
  104. package/pan-wizard-core/templates/context.md +283 -0
  105. package/pan-wizard-core/templates/continue-here.md +78 -0
  106. package/pan-wizard-core/templates/debug-subagent-prompt.md +91 -0
  107. package/pan-wizard-core/templates/debug.md +164 -0
  108. package/pan-wizard-core/templates/discovery.md +146 -0
  109. package/pan-wizard-core/templates/milestone-archive.md +123 -0
  110. package/pan-wizard-core/templates/milestone.md +115 -0
  111. package/pan-wizard-core/templates/phase-prompt.md +593 -0
  112. package/pan-wizard-core/templates/planner-subagent-prompt.md +117 -0
  113. package/pan-wizard-core/templates/project.md +184 -0
  114. package/pan-wizard-core/templates/requirements.md +231 -0
  115. package/pan-wizard-core/templates/research-project/architecture.md +204 -0
  116. package/pan-wizard-core/templates/research-project/features.md +147 -0
  117. package/pan-wizard-core/templates/research-project/pitfalls.md +200 -0
  118. package/pan-wizard-core/templates/research-project/stack.md +120 -0
  119. package/pan-wizard-core/templates/research-project/summary.md +170 -0
  120. package/pan-wizard-core/templates/research.md +552 -0
  121. package/pan-wizard-core/templates/retrospective.md +54 -0
  122. package/pan-wizard-core/templates/roadmap.md +202 -0
  123. package/pan-wizard-core/templates/standards.md +24 -0
  124. package/pan-wizard-core/templates/state.md +176 -0
  125. package/pan-wizard-core/templates/summary-complex.md +59 -0
  126. package/pan-wizard-core/templates/summary-minimal.md +41 -0
  127. package/pan-wizard-core/templates/summary-standard.md +49 -0
  128. package/pan-wizard-core/templates/summary.md +249 -0
  129. package/pan-wizard-core/templates/uat.md +247 -0
  130. package/pan-wizard-core/templates/user-setup.md +311 -0
  131. package/pan-wizard-core/templates/validation.md +76 -0
  132. package/pan-wizard-core/templates/verification-report.md +322 -0
  133. package/pan-wizard-core/workflows/add-phase.md +111 -0
  134. package/pan-wizard-core/workflows/assumptions.md +178 -0
  135. package/pan-wizard-core/workflows/diagnose-issues.md +219 -0
  136. package/pan-wizard-core/workflows/discuss-phase.md +542 -0
  137. package/pan-wizard-core/workflows/exec-phase.md +572 -0
  138. package/pan-wizard-core/workflows/execute-plan.md +448 -0
  139. package/pan-wizard-core/workflows/health.md +156 -0
  140. package/pan-wizard-core/workflows/help.md +431 -0
  141. package/pan-wizard-core/workflows/insert-phase.md +129 -0
  142. package/pan-wizard-core/workflows/map-codebase.md +401 -0
  143. package/pan-wizard-core/workflows/milestone-audit.md +297 -0
  144. package/pan-wizard-core/workflows/milestone-cleanup.md +152 -0
  145. package/pan-wizard-core/workflows/milestone-gaps.md +274 -0
  146. package/pan-wizard-core/workflows/milestone-new.md +382 -0
  147. package/pan-wizard-core/workflows/new-project.md +1178 -0
  148. package/pan-wizard-core/workflows/pause.md +122 -0
  149. package/pan-wizard-core/workflows/phase-tests.md +388 -0
  150. package/pan-wizard-core/workflows/plan-phase.md +569 -0
  151. package/pan-wizard-core/workflows/profile.md +115 -0
  152. package/pan-wizard-core/workflows/progress.md +381 -0
  153. package/pan-wizard-core/workflows/quick.md +453 -0
  154. package/pan-wizard-core/workflows/remove-phase.md +154 -0
  155. package/pan-wizard-core/workflows/research-phase.md +73 -0
  156. package/pan-wizard-core/workflows/resume-project.md +306 -0
  157. package/pan-wizard-core/workflows/retro.md +121 -0
  158. package/pan-wizard-core/workflows/settings.md +213 -0
  159. package/pan-wizard-core/workflows/todo-add.md +157 -0
  160. package/pan-wizard-core/workflows/todo-check.md +176 -0
  161. package/pan-wizard-core/workflows/transition.md +544 -0
  162. package/pan-wizard-core/workflows/update.md +219 -0
  163. package/pan-wizard-core/workflows/verify-phase.md +301 -0
  164. package/scripts/build-hooks.js +43 -0
@@ -0,0 +1,1808 @@
1
+ /**
2
+ * Verify -- Verification suite, consistency, and health validation
3
+ */
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const { execFileSync } = require('child_process');
8
+ const { safeReadFile, normalizePhaseName, execGit, findPhaseInternal, getMilestoneInfo, toPosix, output, error } = require('./core.cjs');
9
+ const { extractFrontmatter, parseMustHavesBlock } = require('./frontmatter.cjs');
10
+ const { writeStateMd, readStateSafe } = require('./state.cjs');
11
+ const {
12
+ PLANNING_DIR, PHASES_DIR, STATE_FILE, ROADMAP_FILE, REQUIREMENTS_FILE, CONFIG_FILE, PROJECT_FILE, PATTERNS_FILE,
13
+ isPlanFile, isSummaryFile, isVerificationFile, PHASE_HEADER_RE, PHASE_DIR_RE, ARCHIVE_DIR_RE, FIELD_VALUE_RE,
14
+ PLAN_SUFFIX, SUMMARY_SUFFIX, STANDARDS_FILE, STANDARDS_CATALOG, HEALTH_STATUS,
15
+ BUILTIN_DRIFT_RULES, DRIFT_VERDICTS, BINARY_EXTENSIONS, DRIFT_MAX_FILES, DRIFT_MAX_FILE_SIZE, DRIFT_SEVERITY_WEIGHTS,
16
+ } = require('./constants.cjs');
17
+ const { planningPath, phasesPath, filterPlanFiles, filterSummaryFiles, fileAccessible } = require('./utils.cjs');
18
+
19
+ /**
20
+ * Spot-check files mentioned in summary content.
21
+ * @param {string} cwd - Working directory
22
+ * @param {string} content - Summary content
23
+ * @param {number} checkCount - Max files to check
24
+ * @returns {{ filesToCheck: string[], missing: string[] }}
25
+ */
26
+ function verifyMentionedFiles(cwd, content, checkCount) {
27
+ const mentionedFiles = new Set();
28
+ const patterns = [
29
+ /`([^`]+\.[a-zA-Z]+)`/g,
30
+ /(?:Created|Modified|Added|Updated|Edited):\s*`?([^\s`]+\.[a-zA-Z]+)`?/gi,
31
+ ];
32
+ for (const pattern of patterns) {
33
+ let match;
34
+ while ((match = pattern.exec(content)) !== null) {
35
+ const fp = match[1];
36
+ if (fp && !fp.startsWith('http') && fp.includes('/')) mentionedFiles.add(fp);
37
+ }
38
+ }
39
+ const filesToCheck = Array.from(mentionedFiles).slice(0, checkCount);
40
+ const missing = [];
41
+ for (const file of filesToCheck) {
42
+ if (!fileAccessible(path.join(cwd, file))) missing.push(file);
43
+ }
44
+ return { filesToCheck, missing };
45
+ }
46
+
47
+ /**
48
+ * Check if any referenced commit hashes exist in git history.
49
+ * @param {string} cwd - Working directory
50
+ * @param {string} content - Summary content
51
+ * @returns {boolean}
52
+ */
53
+ function verifyCommitHashes(cwd, content) {
54
+ const hashes = content.match(/\b[0-9a-f]{7,40}\b/g) || [];
55
+ for (const hash of hashes.slice(0, 3)) {
56
+ const result = execGit(cwd, ['cat-file', '-t', hash]);
57
+ if (result.exitCode === 0 && result.stdout === 'commit') return true;
58
+ }
59
+ return false;
60
+ }
61
+
62
+ /**
63
+ * Detect self-check section outcome in summary content.
64
+ * @param {string} content - Summary content
65
+ * @returns {'passed'|'failed'|'not_found'}
66
+ */
67
+ function verifySelfCheck(content) {
68
+ const selfCheckPattern = /##\s*(?:Self[- ]?Check|Verification|Quality Check)/i;
69
+ if (!selfCheckPattern.test(content)) return 'not_found';
70
+ const checkSection = content.slice(content.search(selfCheckPattern));
71
+ if (/(?:fail|✗|❌|incomplete|blocked)/i.test(checkSection)) return 'failed';
72
+ if (/(?:all\s+)?(?:pass|✓|✅|complete|succeeded)/i.test(checkSection)) return 'passed';
73
+ return 'not_found';
74
+ }
75
+
76
+ function cmdVerifySummary(cwd, summaryPath, checkFileCount, raw) {
77
+ if (!summaryPath) error('summary-path required');
78
+
79
+ const fullPath = path.join(cwd, summaryPath);
80
+ const checkCount = checkFileCount || 2;
81
+
82
+ let content;
83
+ try { content = fs.readFileSync(fullPath, 'utf-8'); }
84
+ catch {
85
+ output({
86
+ passed: false,
87
+ checks: { summary_exists: false, files_created: { checked: 0, found: 0, missing: [] }, commits_exist: false, self_check: 'not_found' },
88
+ errors: ['summary.md not found'],
89
+ }, raw, 'failed');
90
+ return;
91
+ }
92
+
93
+ const errors = [];
94
+ const { filesToCheck, missing } = verifyMentionedFiles(cwd, content, checkCount);
95
+ const commitsExist = verifyCommitHashes(cwd, content);
96
+ const selfCheck = verifySelfCheck(content);
97
+
98
+ if (missing.length > 0) errors.push('Missing files: ' + missing.join(', '));
99
+ if (!commitsExist && (content.match(/\b[0-9a-f]{7,40}\b/g) || []).length > 0) errors.push('Referenced commit hashes not found in git history');
100
+ if (selfCheck === 'failed') errors.push('Self-check section indicates failure');
101
+
102
+ const passed = missing.length === 0 && selfCheck !== 'failed';
103
+ output({
104
+ passed,
105
+ checks: { summary_exists: true, files_created: { checked: filesToCheck.length, found: filesToCheck.length - missing.length, missing }, commits_exist: commitsExist, self_check: selfCheck },
106
+ errors,
107
+ }, raw, passed ? 'passed' : 'failed');
108
+ }
109
+
110
+ /**
111
+ * Validate a plan.md structure: required frontmatter, task elements, and consistency.
112
+ * @param {string} cwd - Working directory path
113
+ * @param {string} filePath - Path to the plan.md file
114
+ * @param {boolean} raw - If true, output raw value instead of JSON
115
+ * @returns {void}
116
+ */
117
+ function cmdVerifyPlanStructure(cwd, filePath, raw) {
118
+ if (!filePath) { error('file path required'); }
119
+ const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
120
+ const content = safeReadFile(fullPath);
121
+ if (!content) { output({ error: 'File not found', path: filePath }, raw); return; }
122
+
123
+ const fm = extractFrontmatter(content);
124
+ const errors = [];
125
+ const warnings = [];
126
+
127
+ // Check required frontmatter fields
128
+ const required = ['phase', 'plan', 'type', 'wave', 'depends_on', 'files_modified', 'autonomous', 'must_haves'];
129
+ for (const field of required) {
130
+ if (fm[field] === undefined) errors.push(`Missing required frontmatter field: ${field}`);
131
+ }
132
+
133
+ // Parse XML <task> elements and validate each has required sub-elements:
134
+ // <name> (required), <action> (required), <verify> (recommended),
135
+ // <done> (recommended), <files> (recommended)
136
+ const taskPattern = /<task[^>]*>([\s\S]*?)<\/task>/g;
137
+ const tasks = [];
138
+ let taskMatch;
139
+ while ((taskMatch = taskPattern.exec(content)) !== null) {
140
+ const taskContent = taskMatch[1];
141
+ const nameMatch = taskContent.match(/<name>([\s\S]*?)<\/name>/);
142
+ const taskName = nameMatch ? nameMatch[1].trim() : 'unnamed';
143
+ const hasFiles = /<files>/.test(taskContent);
144
+ const hasAction = /<action>/.test(taskContent);
145
+ const hasVerify = /<verify>/.test(taskContent);
146
+ const hasDone = /<done>/.test(taskContent);
147
+
148
+ if (!nameMatch) errors.push('Task missing <name> element');
149
+ if (!hasAction) errors.push(`Task '${taskName}' missing <action>`);
150
+ if (!hasVerify) warnings.push(`Task '${taskName}' missing <verify>`);
151
+ if (!hasDone) warnings.push(`Task '${taskName}' missing <done>`);
152
+ if (!hasFiles) warnings.push(`Task '${taskName}' missing <files>`);
153
+
154
+ tasks.push({ name: taskName, hasFiles, hasAction, hasVerify, hasDone });
155
+ }
156
+
157
+ if (tasks.length === 0) warnings.push('No <task> elements found');
158
+
159
+ // Wave/depends_on consistency
160
+ if (fm.wave && parseInt(fm.wave, 10) > 1 && (!fm.depends_on || (Array.isArray(fm.depends_on) && fm.depends_on.length === 0))) {
161
+ warnings.push('Wave > 1 but depends_on is empty');
162
+ }
163
+
164
+ // Autonomous/checkpoint consistency
165
+ const hasCheckpoints = /<task\s+type=["']?checkpoint/.test(content);
166
+ if (hasCheckpoints && fm.autonomous !== 'false' && fm.autonomous !== false) {
167
+ errors.push('Has checkpoint tasks but autonomous is not false');
168
+ }
169
+
170
+ output({
171
+ valid: errors.length === 0,
172
+ errors,
173
+ warnings,
174
+ task_count: tasks.length,
175
+ tasks,
176
+ frontmatter_fields: Object.keys(fm),
177
+ }, raw, errors.length === 0 ? 'valid' : 'invalid');
178
+ }
179
+
180
+ /**
181
+ * Check if all plans in a phase have corresponding summaries.
182
+ * @param {string} cwd - Working directory path
183
+ * @param {string} phase - Phase number to check completeness for
184
+ * @param {boolean} raw - If true, output raw value instead of JSON
185
+ * @returns {void}
186
+ */
187
+ function cmdVerifyPhaseCompleteness(cwd, phase, raw) {
188
+ if (!phase) { error('phase required'); }
189
+ const phaseInfo = findPhaseInternal(cwd, phase);
190
+ if (!phaseInfo || !phaseInfo.found) {
191
+ output({ error: 'Phase not found', phase }, raw);
192
+ return;
193
+ }
194
+
195
+ const errors = [];
196
+ const warnings = [];
197
+ const phaseDir = path.join(cwd, phaseInfo.directory);
198
+
199
+ // List plans and summaries
200
+ let files;
201
+ try { files = fs.readdirSync(phaseDir); } catch { output({ error: 'Cannot read phase directory' }, raw); return; }
202
+
203
+ const plans = files.filter(f => f.match(/-PLAN\.md$/i));
204
+ const summaries = files.filter(f => f.match(/-SUMMARY\.md$/i));
205
+
206
+ // Extract plan IDs (everything before -plan.md)
207
+ const planIds = new Set(plans.map(p => p.replace(/-PLAN\.md$/i, '')));
208
+ const summaryIds = new Set(summaries.map(s => s.replace(/-SUMMARY\.md$/i, '')));
209
+
210
+ // Plans without summaries
211
+ const incompletePlans = [...planIds].filter(id => !summaryIds.has(id));
212
+ if (incompletePlans.length > 0) {
213
+ errors.push(`Plans without summaries: ${incompletePlans.join(', ')}`);
214
+ }
215
+
216
+ // Summaries without plans (orphans)
217
+ const orphanSummaries = [...summaryIds].filter(id => !planIds.has(id));
218
+ if (orphanSummaries.length > 0) {
219
+ warnings.push(`Summaries without plans: ${orphanSummaries.join(', ')}`);
220
+ }
221
+
222
+ output({
223
+ complete: errors.length === 0,
224
+ phase: phaseInfo.phase_number,
225
+ plan_count: plans.length,
226
+ summary_count: summaries.length,
227
+ incomplete_plans: incompletePlans,
228
+ orphan_summaries: orphanSummaries,
229
+ errors,
230
+ warnings,
231
+ }, raw, errors.length === 0 ? 'complete' : 'incomplete');
232
+ }
233
+
234
+ /**
235
+ * Verify that @-references and backtick file paths in a document resolve to existing files.
236
+ * @param {string} cwd - Working directory path
237
+ * @param {string} filePath - Path to the file to check references in
238
+ * @param {boolean} raw - If true, output raw value instead of JSON
239
+ * @returns {void}
240
+ */
241
+ function cmdVerifyReferences(cwd, filePath, raw) {
242
+ if (!filePath) { error('file path required'); }
243
+ const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
244
+ const content = safeReadFile(fullPath);
245
+ if (!content) { output({ error: 'File not found', path: filePath }, raw); return; }
246
+
247
+ const found = [];
248
+ const missing = [];
249
+
250
+ // Find @-references: @path/to/file (must contain / to be a file path)
251
+ const atRefs = content.match(/@([^\s\n,)]+\/[^\s\n,)]+)/g) || [];
252
+ for (const ref of atRefs) {
253
+ const cleanRef = ref.slice(1); // remove @
254
+ const resolved = cleanRef.startsWith('~/')
255
+ ? path.join(process.env.HOME || '', cleanRef.slice(2))
256
+ : path.join(cwd, cleanRef);
257
+ if (fileAccessible(resolved)) found.push(cleanRef); else missing.push(cleanRef);
258
+ }
259
+
260
+ // Find backtick file paths that look like real paths (contain / and have extension)
261
+ const backtickRefs = content.match(/`([^`]+\/[^`]+\.[a-zA-Z]{1,10})`/g) || [];
262
+ for (const ref of backtickRefs) {
263
+ const cleanRef = ref.slice(1, -1); // remove backticks
264
+ if (cleanRef.startsWith('http') || cleanRef.includes('${') || cleanRef.includes('{{')) continue;
265
+ if (found.includes(cleanRef) || missing.includes(cleanRef)) continue; // dedup
266
+ const resolved = path.join(cwd, cleanRef);
267
+ if (fileAccessible(resolved)) found.push(cleanRef); else missing.push(cleanRef);
268
+ }
269
+
270
+ output({
271
+ valid: missing.length === 0,
272
+ found: found.length,
273
+ missing,
274
+ total: found.length + missing.length,
275
+ }, raw, missing.length === 0 ? 'valid' : 'invalid');
276
+ }
277
+
278
+ /**
279
+ * Verify that git commit hashes exist in the repository.
280
+ * @param {string} cwd - Working directory path
281
+ * @param {string[]} hashes - Array of commit hashes to verify
282
+ * @param {boolean} raw - If true, output raw value instead of JSON
283
+ * @returns {void}
284
+ */
285
+ function cmdVerifyCommits(cwd, hashes, raw) {
286
+ if (!hashes || hashes.length === 0) { error('At least one commit hash required'); }
287
+
288
+ const valid = [];
289
+ const invalid = [];
290
+ for (const hash of hashes) {
291
+ const result = execGit(cwd, ['cat-file', '-t', hash]);
292
+ if (result.exitCode === 0 && result.stdout.trim() === 'commit') {
293
+ valid.push(hash);
294
+ } else {
295
+ invalid.push(hash);
296
+ }
297
+ }
298
+
299
+ output({
300
+ all_valid: invalid.length === 0,
301
+ valid,
302
+ invalid,
303
+ total: hashes.length,
304
+ }, raw, invalid.length === 0 ? 'valid' : 'invalid');
305
+ }
306
+
307
+ /**
308
+ * Verify must_haves.artifacts from a plan.md: file existence, line counts, exports, patterns.
309
+ * @param {string} cwd - Working directory path
310
+ * @param {string} planFilePath - Path to the plan.md file containing artifact specs
311
+ * @param {boolean} raw - If true, output raw value instead of JSON
312
+ * @returns {void}
313
+ */
314
+ function cmdVerifyArtifacts(cwd, planFilePath, raw) {
315
+ if (!planFilePath) { error('plan file path required'); }
316
+ const fullPath = path.isAbsolute(planFilePath) ? planFilePath : path.join(cwd, planFilePath);
317
+ const content = safeReadFile(fullPath);
318
+ if (!content) { output({ error: 'File not found', path: planFilePath }, raw); return; }
319
+
320
+ const artifacts = parseMustHavesBlock(content, 'artifacts');
321
+ if (artifacts.length === 0) {
322
+ output({ error: 'No must_haves.artifacts found in frontmatter', path: planFilePath }, raw);
323
+ return;
324
+ }
325
+
326
+ const results = [];
327
+ for (const artifact of artifacts) {
328
+ if (typeof artifact === 'string') continue; // skip simple string items
329
+ const artPath = artifact.path;
330
+ if (!artPath) continue;
331
+
332
+ const artFullPath = path.join(cwd, artPath);
333
+ const fileContent = safeReadFile(artFullPath);
334
+ const exists = fileContent !== null;
335
+ const check = { path: artPath, exists, issues: [], passed: false };
336
+
337
+ if (exists) {
338
+ const lineCount = fileContent.split('\n').length;
339
+
340
+ if (artifact.min_lines && lineCount < artifact.min_lines) {
341
+ check.issues.push(`Only ${lineCount} lines, need ${artifact.min_lines}`);
342
+ }
343
+ if (artifact.contains && !fileContent.includes(artifact.contains)) {
344
+ check.issues.push(`Missing pattern: ${artifact.contains}`);
345
+ }
346
+ if (artifact.exports) {
347
+ const exports = Array.isArray(artifact.exports) ? artifact.exports : [artifact.exports];
348
+ for (const exp of exports) {
349
+ if (!fileContent.includes(exp)) check.issues.push(`Missing export: ${exp}`);
350
+ }
351
+ }
352
+ check.passed = check.issues.length === 0;
353
+ } else {
354
+ check.issues.push('File not found');
355
+ }
356
+
357
+ results.push(check);
358
+ }
359
+
360
+ const passed = results.filter(r => r.passed).length;
361
+ output({
362
+ all_passed: passed === results.length,
363
+ passed,
364
+ total: results.length,
365
+ artifacts: results,
366
+ }, raw, passed === results.length ? 'valid' : 'invalid');
367
+ }
368
+
369
+ /**
370
+ * Verify must_haves.key_links from a plan.md: source-to-target references and patterns.
371
+ * @param {string} cwd - Working directory path
372
+ * @param {string} planFilePath - Path to the plan.md file containing key link specs
373
+ * @param {boolean} raw - If true, output raw value instead of JSON
374
+ * @returns {void}
375
+ */
376
+ function cmdVerifyKeyLinks(cwd, planFilePath, raw) {
377
+ if (!planFilePath) { error('plan file path required'); }
378
+ const fullPath = path.isAbsolute(planFilePath) ? planFilePath : path.join(cwd, planFilePath);
379
+ const content = safeReadFile(fullPath);
380
+ if (!content) { output({ error: 'File not found', path: planFilePath }, raw); return; }
381
+
382
+ const keyLinks = parseMustHavesBlock(content, 'key_links');
383
+ if (keyLinks.length === 0) {
384
+ output({ error: 'No must_haves.key_links found in frontmatter', path: planFilePath }, raw);
385
+ return;
386
+ }
387
+
388
+ const results = [];
389
+ for (const link of keyLinks) {
390
+ if (typeof link === 'string') continue;
391
+ const check = { from: link.from, to: link.to, via: link.via || '', verified: false, detail: '' };
392
+
393
+ const sourceContent = safeReadFile(path.join(cwd, link.from || ''));
394
+ if (!sourceContent) {
395
+ check.detail = 'Source file not found';
396
+ } else if (link.pattern) {
397
+ try {
398
+ const regex = new RegExp(link.pattern);
399
+ if (regex.test(sourceContent)) {
400
+ check.verified = true;
401
+ check.detail = 'Pattern found in source';
402
+ } else {
403
+ const targetContent = safeReadFile(path.join(cwd, link.to || ''));
404
+ if (targetContent && regex.test(targetContent)) {
405
+ check.verified = true;
406
+ check.detail = 'Pattern found in target';
407
+ } else {
408
+ check.detail = `Pattern "${link.pattern}" not found in source or target`;
409
+ }
410
+ }
411
+ } catch {
412
+ // Regex compilation failed -- report the invalid pattern to the caller
413
+ check.detail = `Invalid regex pattern: ${link.pattern}`;
414
+ }
415
+ } else {
416
+ // No pattern: just check source references target
417
+ if (sourceContent.includes(link.to || '')) {
418
+ check.verified = true;
419
+ check.detail = 'Target referenced in source';
420
+ } else {
421
+ check.detail = 'Target not referenced in source';
422
+ }
423
+ }
424
+
425
+ results.push(check);
426
+ }
427
+
428
+ const verified = results.filter(r => r.verified).length;
429
+ output({
430
+ all_verified: verified === results.length,
431
+ verified,
432
+ total: results.length,
433
+ links: results,
434
+ }, raw, verified === results.length ? 'valid' : 'invalid');
435
+ }
436
+
437
+ /**
438
+ * Validate consistency between roadmap.md and disk: phase numbering, plan gaps, orphans.
439
+ * @param {string} cwd - Working directory path
440
+ * @param {boolean} raw - If true, output raw value instead of JSON
441
+ * @returns {void}
442
+ */
443
+
444
+ /**
445
+ * Check plan numbering, plan/summary pairing, and frontmatter within each phase directory.
446
+ * @param {string} phasesDirPath - Absolute path to .planning/phases/
447
+ * @param {string[]} warnings - Warnings array to append to
448
+ */
449
+ function checkPhaseInternalConsistency(phasesDirPath, warnings) {
450
+ try {
451
+ const entries = fs.readdirSync(phasesDirPath, { withFileTypes: true });
452
+ const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort();
453
+
454
+ for (const dir of dirs) {
455
+ let phaseFiles;
456
+ try {
457
+ phaseFiles = fs.readdirSync(path.join(phasesDirPath, dir));
458
+ } catch {
459
+ warnings.push(`Phase ${dir} directory unreadable`);
460
+ continue;
461
+ }
462
+ const plans = phaseFiles.filter(f => f.endsWith(PLAN_SUFFIX)).sort();
463
+
464
+ // Check plan number gaps
465
+ const planNums = plans.map(p => {
466
+ const planMatch = p.match(/-(\d{2})-plan\.md$/);
467
+ return planMatch ? parseInt(planMatch[1], 10) : null;
468
+ }).filter(n => n !== null);
469
+
470
+ for (let i = 1; i < planNums.length; i++) {
471
+ if (planNums[i] !== planNums[i - 1] + 1) {
472
+ warnings.push(`Gap in plan numbering in ${dir}: plan ${planNums[i - 1]} → ${planNums[i]}`);
473
+ }
474
+ }
475
+
476
+ // Check summaries without matching plans
477
+ const summaries = phaseFiles.filter(f => f.endsWith(SUMMARY_SUFFIX));
478
+ const planIds = new Set(plans.map(p => p.replace(PLAN_SUFFIX, '')));
479
+ const summaryIds = new Set(summaries.map(s => s.replace(SUMMARY_SUFFIX, '')));
480
+
481
+ for (const sid of summaryIds) {
482
+ if (!planIds.has(sid)) {
483
+ warnings.push(`Summary ${sid}${SUMMARY_SUFFIX} in ${dir} has no matching ${PLAN_SUFFIX.slice(1)}`);
484
+ }
485
+ }
486
+
487
+ // Check frontmatter in plans has required fields
488
+ for (const plan of plans) {
489
+ let content;
490
+ try {
491
+ content = fs.readFileSync(path.join(phasesDirPath, dir, plan), 'utf-8');
492
+ } catch {
493
+ warnings.push(`${dir}/${plan}: unreadable`);
494
+ continue;
495
+ }
496
+ const fm = extractFrontmatter(content);
497
+ if (!fm.wave) {
498
+ warnings.push(`${dir}/${plan}: missing 'wave' in frontmatter`);
499
+ }
500
+ }
501
+ }
502
+ } catch {
503
+ // phases/ directory may not exist or be unreadable
504
+ }
505
+ }
506
+
507
+ function cmdValidateConsistency(cwd, raw) {
508
+ const roadmapPath = path.join(planningPath(cwd), ROADMAP_FILE);
509
+ const phasesDirPath = phasesPath(cwd);
510
+ const errors = [];
511
+ const warnings = [];
512
+
513
+ // Check for ROADMAP
514
+ let roadmapContent;
515
+ try {
516
+ roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
517
+ } catch {
518
+ errors.push('roadmap.md not found');
519
+ output({ passed: false, errors, warnings }, raw, 'failed');
520
+ return;
521
+ }
522
+
523
+ // Extract phases from ROADMAP by matching "## Phase N:" header lines
524
+ const roadmapPhases = new Set();
525
+ PHASE_HEADER_RE.lastIndex = 0; // reset /g regex before each use
526
+ let match;
527
+ while ((match = PHASE_HEADER_RE.exec(roadmapContent)) !== null) {
528
+ roadmapPhases.add(match[1]);
529
+ }
530
+
531
+ // Get phases on disk by reading phase directory names
532
+ const diskPhases = new Set();
533
+ try {
534
+ const entries = fs.readdirSync(phasesDirPath, { withFileTypes: true });
535
+ const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
536
+ for (const dir of dirs) {
537
+ const dirMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
538
+ if (dirMatch) diskPhases.add(dirMatch[1]);
539
+ }
540
+ } catch {
541
+ // phases/ directory may not exist yet in a fresh project
542
+ }
543
+
544
+ // Check: phases in ROADMAP but not on disk
545
+ for (const p of roadmapPhases) {
546
+ if (!diskPhases.has(p) && !diskPhases.has(normalizePhaseName(p))) {
547
+ warnings.push(`Phase ${p} in roadmap.md but no directory on disk`);
548
+ }
549
+ }
550
+
551
+ // Check: phases on disk but not in ROADMAP
552
+ for (const p of diskPhases) {
553
+ const unpadded = String(parseInt(p, 10));
554
+ if (!roadmapPhases.has(p) && !roadmapPhases.has(unpadded)) {
555
+ warnings.push(`Phase ${p} exists on disk but not in roadmap.md`);
556
+ }
557
+ }
558
+
559
+ // Check: sequential phase numbers (integers only)
560
+ const integerPhases = [...diskPhases]
561
+ .filter(p => !p.includes('.'))
562
+ .map(p => parseInt(p, 10))
563
+ .sort((a, b) => a - b);
564
+
565
+ for (let i = 1; i < integerPhases.length; i++) {
566
+ if (integerPhases[i] !== integerPhases[i - 1] + 1) {
567
+ warnings.push(`Gap in phase numbering: ${integerPhases[i - 1]} → ${integerPhases[i]}`);
568
+ }
569
+ }
570
+
571
+ // Check: plan numbering, plan/summary pairing, and frontmatter within each phase directory
572
+ checkPhaseInternalConsistency(phasesDirPath, warnings);
573
+
574
+ const passed = errors.length === 0;
575
+ output({ passed, errors, warnings, warning_count: warnings.length }, raw, passed ? 'passed' : 'failed');
576
+ }
577
+
578
+ // ─── Health check helper functions ────────────────────────────────────────────
579
+ // Each checker receives cwd and a shared issues object { errors, warnings, info, repairs }
580
+ // and populates it with any problems found. This keeps cmdValidateHealth() as a
581
+ // thin orchestrator.
582
+
583
+ /**
584
+ * Check that the .planning/ directory exists.
585
+ * @param {string} cwd - Working directory
586
+ * @param {Function} addIssue - Issue recording callback
587
+ * @returns {boolean} false if .planning/ is missing (fatal), true otherwise
588
+ */
589
+ function checkPlanningDirExists(cwd, addIssue) {
590
+ if (!fileAccessible(planningPath(cwd))) {
591
+ addIssue('error', 'E001', PLANNING_DIR + '/ directory not found', 'Run /pan:new-project to initialize');
592
+ return false;
593
+ }
594
+ return true;
595
+ }
596
+
597
+ /**
598
+ * Check that project.md exists and contains expected sections.
599
+ * @param {string} cwd - Working directory
600
+ * @param {Function} addIssue - Issue recording callback
601
+ */
602
+ function checkProjectFile(cwd, addIssue) {
603
+ const projectPath = path.join(planningPath(cwd), PROJECT_FILE);
604
+ let content;
605
+ try {
606
+ content = fs.readFileSync(projectPath, 'utf-8');
607
+ } catch {
608
+ addIssue('error', 'E002', PROJECT_FILE + ' not found', 'Run /pan:new-project to create');
609
+ return;
610
+ }
611
+ const requiredSections = ['## What This Is', '## Core Value', '## Requirements'];
612
+ for (const section of requiredSections) {
613
+ if (!content.includes(section)) {
614
+ addIssue('warning', 'W001', `${PROJECT_FILE} missing section: ${section}`, 'Add section manually');
615
+ }
616
+ }
617
+ }
618
+
619
+ /**
620
+ * Check that roadmap.md exists.
621
+ * @param {string} cwd - Working directory
622
+ * @param {Function} addIssue - Issue recording callback
623
+ */
624
+ function checkRoadmapFile(cwd, addIssue) {
625
+ const roadmapFullPath = path.join(planningPath(cwd), ROADMAP_FILE);
626
+ if (!fileAccessible(roadmapFullPath)) {
627
+ addIssue('error', 'E003', ROADMAP_FILE + ' not found', 'Run /pan:milestone-new to create roadmap');
628
+ }
629
+ }
630
+
631
+ /**
632
+ * Check that state.md exists and its phase references match disk.
633
+ * @param {string} cwd - Working directory
634
+ * @param {Function} addIssue - Issue recording callback
635
+ * @param {string[]} repairs - Mutable array of repair actions to schedule
636
+ */
637
+ function checkStateFile(cwd, addIssue, repairs) {
638
+ const statePath = path.join(planningPath(cwd), STATE_FILE);
639
+ const phasesDirPath = phasesPath(cwd);
640
+
641
+ let stateContent;
642
+ try {
643
+ stateContent = fs.readFileSync(statePath, 'utf-8');
644
+ } catch {
645
+ addIssue('error', 'E004', STATE_FILE + ' not found', 'Run /pan:health --repair to regenerate', true);
646
+ repairs.push('regenerateState');
647
+ stateContent = null;
648
+ }
649
+ if (stateContent === null) {
650
+ // skip further state checks
651
+ } else {
652
+ // Extract phase references (e.g. "Phase 3" or "phase 01") from state.md
653
+ const phaseRefs = [...stateContent.matchAll(/[Pp]hase\s+(\d+(?:\.\d+)*)/g)].map(match => match[1]);
654
+ // Get disk phases for cross-reference
655
+ const diskPhases = new Set();
656
+ try {
657
+ const entries = fs.readdirSync(phasesDirPath, { withFileTypes: true });
658
+ for (const entry of entries) {
659
+ if (entry.isDirectory()) {
660
+ const dirMatch = entry.name.match(/^(\d+(?:\.\d+)*)/);
661
+ if (dirMatch) diskPhases.add(dirMatch[1]);
662
+ }
663
+ }
664
+ } catch {
665
+ // phases/ directory may not exist yet
666
+ }
667
+ // Check for invalid references -- only warn if there are phases on disk
668
+ for (const ref of phaseRefs) {
669
+ const normalizedRef = String(parseInt(ref, 10)).padStart(2, '0');
670
+ if (!diskPhases.has(ref) && !diskPhases.has(normalizedRef) && !diskPhases.has(String(parseInt(ref, 10)))) {
671
+ if (diskPhases.size > 0) {
672
+ addIssue('warning', 'W002', `${STATE_FILE} references phase ${ref}, but only phases ${[...diskPhases].sort().join(', ')} exist`, `Run /pan:health --repair to regenerate ${STATE_FILE}`, true);
673
+ if (!repairs.includes('regenerateState')) repairs.push('regenerateState');
674
+ }
675
+ }
676
+ }
677
+ }
678
+ }
679
+
680
+ /**
681
+ * Check that config.json is valid JSON with a recognized schema.
682
+ * @param {string} cwd - Working directory
683
+ * @param {Function} addIssue - Issue recording callback
684
+ * @param {string[]} repairs - Mutable array of repair actions to schedule
685
+ */
686
+ function checkConfigFile(cwd, addIssue, repairs) {
687
+ const configFullPath = path.join(planningPath(cwd), CONFIG_FILE);
688
+ let rawContent;
689
+ try {
690
+ rawContent = fs.readFileSync(configFullPath, 'utf-8');
691
+ } catch {
692
+ addIssue('warning', 'W003', CONFIG_FILE + ' not found', 'Run /pan:health --repair to create with defaults', true);
693
+ repairs.push('createConfig');
694
+ return;
695
+ }
696
+ try {
697
+ const parsed = JSON.parse(rawContent);
698
+ // Validate known fields against allowed values
699
+ const validProfiles = ['quality', 'balanced', 'budget'];
700
+ if (parsed.model_profile && !validProfiles.includes(parsed.model_profile)) {
701
+ addIssue('warning', 'W004', `${CONFIG_FILE}: invalid model_profile "${parsed.model_profile}"`, `Valid values: ${validProfiles.join(', ')}`);
702
+ }
703
+ } catch (err) {
704
+ // JSON parse failed -- config is corrupt and should be reset
705
+ addIssue('error', 'E005', `${CONFIG_FILE}: JSON parse error - ${err.message}`, 'Run /pan:health --repair to reset to defaults', true);
706
+ repairs.push('resetConfig');
707
+ }
708
+ }
709
+
710
+ /**
711
+ * Check that phase directories follow the NN-name naming convention.
712
+ * @param {string} cwd - Working directory
713
+ * @param {Function} addIssue - Issue recording callback
714
+ */
715
+ function checkPhaseDirectories(cwd, addIssue) {
716
+ const phasesDirPath = phasesPath(cwd);
717
+ try {
718
+ const entries = fs.readdirSync(phasesDirPath, { withFileTypes: true });
719
+ for (const entry of entries) {
720
+ if (entry.isDirectory() && !entry.name.match(/^\d{2}(?:\.\d+)*-[\w-]+$/)) {
721
+ addIssue('warning', 'W005', `Phase directory "${entry.name}" doesn't follow NN-name format`, 'Rename to match pattern (e.g., 01-setup)');
722
+ }
723
+ }
724
+ } catch {
725
+ // phases/ directory may not exist yet in a fresh project
726
+ }
727
+ }
728
+
729
+ /**
730
+ * Check each phase directory for orphaned plans (no matching summary) and
731
+ * validate ROADMAP/disk phase consistency.
732
+ * @param {string} cwd - Working directory
733
+ * @param {Function} addIssue - Issue recording callback
734
+ */
735
+ /**
736
+ * Cross-check roadmap phases against disk directories.
737
+ * @param {string} cwd - Working directory
738
+ * @param {string} phasesDirPath - Path to phases directory
739
+ * @param {Function} addIssue - Issue recorder
740
+ */
741
+ function crossCheckRoadmapDisk(cwd, phasesDirPath, addIssue) {
742
+ let roadmapContent;
743
+ try { roadmapContent = fs.readFileSync(path.join(planningPath(cwd), ROADMAP_FILE), 'utf-8'); }
744
+ catch { return; }
745
+
746
+ const roadmapPhases = new Set();
747
+ PHASE_HEADER_RE.lastIndex = 0;
748
+ let match;
749
+ while ((match = PHASE_HEADER_RE.exec(roadmapContent)) !== null) roadmapPhases.add(match[1]);
750
+
751
+ const diskPhases = new Set();
752
+ try {
753
+ for (const entry of fs.readdirSync(phasesDirPath, { withFileTypes: true })) {
754
+ if (entry.isDirectory()) {
755
+ const dm = entry.name.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
756
+ if (dm) diskPhases.add(dm[1]);
757
+ }
758
+ }
759
+ } catch { /* phases/ may not exist yet */ }
760
+
761
+ for (const p of roadmapPhases) {
762
+ const padded = String(parseInt(p, 10)).padStart(2, '0');
763
+ if (!diskPhases.has(p) && !diskPhases.has(padded))
764
+ addIssue('warning', 'W006', `Phase ${p} in ${ROADMAP_FILE} but no directory on disk`, 'Create phase directory or remove from roadmap');
765
+ }
766
+ for (const p of diskPhases) {
767
+ const unpadded = String(parseInt(p, 10));
768
+ if (!roadmapPhases.has(p) && !roadmapPhases.has(unpadded))
769
+ addIssue('warning', 'W007', `Phase ${p} exists on disk but not in ${ROADMAP_FILE}`, 'Add to roadmap or remove directory');
770
+ }
771
+ }
772
+
773
+ function checkPhaseContents(cwd, addIssue) {
774
+ const phasesDirPath = phasesPath(cwd);
775
+
776
+ try {
777
+ const entries = fs.readdirSync(phasesDirPath, { withFileTypes: true });
778
+ for (const entry of entries) {
779
+ if (!entry.isDirectory()) continue;
780
+ let phaseFiles;
781
+ try {
782
+ phaseFiles = fs.readdirSync(path.join(phasesDirPath, entry.name));
783
+ } catch { continue; }
784
+ const plans = phaseFiles.filter(f => isPlanFile(f));
785
+ const summaryBases = new Set(phaseFiles.filter(f => isSummaryFile(f)).map(s => s.replace(SUMMARY_SUFFIX, '').replace('summary.md', '')));
786
+ for (const plan of plans) {
787
+ const planBase = plan.replace(PLAN_SUFFIX, '').replace('plan.md', '');
788
+ if (!summaryBases.has(planBase)) addIssue('info', 'I001', `${entry.name}/${plan} has no summary.md`, 'May be in progress');
789
+ }
790
+ }
791
+ } catch { /* phases/ may not exist */ }
792
+
793
+ crossCheckRoadmapDisk(cwd, phasesDirPath, addIssue);
794
+ }
795
+
796
+ /**
797
+ * Perform auto-repair actions for issues flagged as repairable.
798
+ * Currently supports: createConfig, resetConfig (write default config.json),
799
+ * and regenerateState (rebuild state.md from ROADMAP structure).
800
+ * @param {string} cwd - Working directory
801
+ * @param {string[]} repairs - List of repair action names to perform
802
+ * @returns {Array<{action: string, success: boolean, path?: string, error?: string}>}
803
+ */
804
+ function repairIssues(cwd, repairs) {
805
+ const configFullPath = path.join(planningPath(cwd), CONFIG_FILE);
806
+ const statePath = path.join(planningPath(cwd), STATE_FILE);
807
+ const repairActions = [];
808
+
809
+ for (const repair of repairs) {
810
+ try {
811
+ switch (repair) {
812
+ case 'createConfig':
813
+ case 'resetConfig': {
814
+ // Write a fresh config.json with sensible defaults
815
+ const defaults = {
816
+ model_profile: 'balanced',
817
+ commit_docs: true,
818
+ search_gitignored: false,
819
+ branching_strategy: 'none',
820
+ research: true,
821
+ plan_checker: true,
822
+ verifier: true,
823
+ parallelization: true,
824
+ };
825
+ fs.writeFileSync(configFullPath, JSON.stringify(defaults, null, 2), 'utf-8');
826
+ repairActions.push({ action: repair, success: true, path: CONFIG_FILE });
827
+ break;
828
+ }
829
+ case 'regenerateState': {
830
+ // Create timestamped backup before overwriting to prevent data loss
831
+ try {
832
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
833
+ const backupPath = `${statePath}.bak-${timestamp}`;
834
+ fs.copyFileSync(statePath, backupPath);
835
+ repairActions.push({ action: 'backupState', success: true, path: backupPath });
836
+ } catch { /* state.md absent — nothing to back up */ }
837
+ // Generate minimal state.md scaffolding from roadmap.md structure
838
+ const milestone = getMilestoneInfo(cwd);
839
+ let stateContent = '# Session State\n\n';
840
+ stateContent += '## Project Reference\n\n';
841
+ stateContent += `See: ${PLANNING_DIR}/${PROJECT_FILE}\n\n`;
842
+ stateContent += '## Position\n\n';
843
+ stateContent += `**Milestone:** ${milestone.version} ${milestone.name}\n`;
844
+ stateContent += '**Current phase:** (determining...)\n';
845
+ stateContent += '**Status:** Resuming\n\n';
846
+ stateContent += '## Session Log\n\n';
847
+ stateContent += `- ${new Date().toISOString().split('T')[0]}: ${STATE_FILE} regenerated by /pan:health --repair\n`;
848
+ writeStateMd(statePath, stateContent, cwd);
849
+ repairActions.push({ action: repair, success: true, path: STATE_FILE });
850
+ break;
851
+ }
852
+ }
853
+ } catch (err) {
854
+ // Repair action failed -- record the error so callers can report it
855
+ repairActions.push({ action: repair, success: false, error: err.message });
856
+ }
857
+ }
858
+
859
+ return repairActions;
860
+ }
861
+
862
+ /**
863
+ * Check standards compliance status (optional health check dimension).
864
+ * Reads standards.md and reports per-standard coverage as info items.
865
+ * @param {string} cwd - Working directory
866
+ * @param {function} addIssue - Issue reporter (severity, code, message, fix, repairable)
867
+ */
868
+ function checkStandardsCompliance(cwd, addIssue) {
869
+ const stdPath = path.join(planningPath(cwd), STANDARDS_FILE);
870
+ const content = safeReadFile(stdPath);
871
+ if (!content) {
872
+ addIssue('info', 'STD-000', 'No standards.md found — no standards selected', 'Run "pan-tools standards select <id>" to add standards');
873
+ return;
874
+ }
875
+
876
+ // Parse which standards are in the file
877
+ const ids = [];
878
+ for (const [id, s] of Object.entries(STANDARDS_CATALOG)) {
879
+ if (content.includes('## ' + s.name)) ids.push(id);
880
+ }
881
+
882
+ if (ids.length === 0) {
883
+ addIssue('info', 'STD-001', 'standards.md exists but contains no recognized standards', 'Run "pan-tools standards select <id>" to add standards');
884
+ return;
885
+ }
886
+
887
+ let totalChecked = 0;
888
+ let totalItems = 0;
889
+ for (const id of ids) {
890
+ const s = STANDARDS_CATALOG[id];
891
+ const total = s.checklist.length;
892
+ const sectionStart = content.indexOf('## ' + s.name);
893
+ const nextSection = content.indexOf('\n## ', sectionStart + 1);
894
+ const section = nextSection > -1 ? content.slice(sectionStart, nextSection) : content.slice(sectionStart);
895
+ const checked = (section.match(/- \[x\]/gi) || []).length;
896
+ totalChecked += checked;
897
+ totalItems += total;
898
+
899
+ if (checked === 0) {
900
+ addIssue('warning', 'STD-' + id, s.name + ': 0/' + total + ' items verified (0%)', 'Review checklist in standards.md and mark completed items');
901
+ } else if (checked < total) {
902
+ addIssue('info', 'STD-' + id, s.name + ': ' + checked + '/' + total + ' items verified (' + Math.round((checked / total) * 100) + '%)', 'Continue verifying remaining items');
903
+ } else {
904
+ addIssue('info', 'STD-' + id, s.name + ': ' + checked + '/' + total + ' items verified (100%)', null);
905
+ }
906
+ }
907
+
908
+ const overallPct = totalItems > 0 ? Math.round((totalChecked / totalItems) * 100) : 0;
909
+ addIssue('info', 'STD-SUMMARY', ids.length + ' standard(s) selected, overall coverage: ' + overallPct + '%', null);
910
+ }
911
+
912
+ /**
913
+ * Check that completed phases with verifier enabled have verification records.
914
+ * Reports missing verification.md as warnings for phases marked complete in roadmap.
915
+ * @param {string} cwd - Working directory path
916
+ * @param {Function} addIssue - Issue recording callback
917
+ */
918
+ function checkVerificationGate(cwd, addIssue) {
919
+ const planDir = planningPath(cwd);
920
+ const configPath = path.join(planDir, CONFIG_FILE);
921
+ const phasesDirPath = phasesPath(cwd);
922
+
923
+ // Only check if verifier is enabled in config
924
+ let config;
925
+ try {
926
+ config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
927
+ } catch { return; }
928
+ if (!config || !config.workflow || config.workflow.verifier !== true) return;
929
+
930
+ // Scan phase directories for those with summaries but no verification
931
+ let phaseDirs;
932
+ try {
933
+ phaseDirs = fs.readdirSync(phasesDirPath, { withFileTypes: true })
934
+ .filter(d => d.isDirectory() && /^\d/.test(d.name));
935
+ } catch { return; }
936
+
937
+ for (const dir of phaseDirs) {
938
+ const dirPath = path.join(phasesDirPath, dir.name);
939
+ const files = fs.readdirSync(dirPath);
940
+ const hasSummary = files.some(f => /-summary\.md$/i.test(f));
941
+ const hasVerification = files.some(f => /-verification\.md$/i.test(f));
942
+
943
+ // Only flag phases that have summaries (executed) but no verification
944
+ if (hasSummary && !hasVerification) {
945
+ const phaseNum = dir.name.match(/^(\d+(?:\.\d+)?)/)?.[1] || dir.name;
946
+ addIssue('warning', 'VERIFICATION_GATE_MISSING',
947
+ `Phase ${phaseNum} has completed plans but no verification record (verifier is enabled)`,
948
+ `Run /pan:verify-phase ${phaseNum} to verify this phase`);
949
+ }
950
+ }
951
+ }
952
+
953
+ /**
954
+ * Cross-check STATE.md progress counts against REQUIREMENTS.md checkboxes and ROADMAP.md plan checkboxes.
955
+ * Reports mismatches as warnings so users can run --repair or investigate.
956
+ * @param {string} cwd - Working directory path
957
+ * @param {Function} addIssue - Issue recording callback
958
+ */
959
+ function checkStateConsistency(cwd, addIssue) {
960
+ const planDir = planningPath(cwd);
961
+ const statePath = path.join(planDir, STATE_FILE);
962
+ const reqPath = path.join(planDir, REQUIREMENTS_FILE);
963
+ const roadmapPath = path.join(planDir, ROADMAP_FILE);
964
+
965
+ // Read STATE.md frontmatter for progress counts
966
+ let stateFm;
967
+ try {
968
+ const stateContent = fs.readFileSync(statePath, 'utf-8');
969
+ stateFm = extractFrontmatter(stateContent);
970
+ } catch { return; } // No state file — other checks handle this
971
+ if (!stateFm || !stateFm.progress) return;
972
+
973
+ // Check REQUIREMENTS.md checkbox count vs STATE expectations
974
+ try {
975
+ const reqContent = fs.readFileSync(reqPath, 'utf-8');
976
+ const checkedBoxes = (reqContent.match(/- \[x\]/gi) || []).length;
977
+ const uncheckedBoxes = (reqContent.match(/- \[ \]/g) || []).length;
978
+ const totalBoxes = checkedBoxes + uncheckedBoxes;
979
+ if (totalBoxes > 0 && uncheckedBoxes > 0) {
980
+ const completedPlans = Number(stateFm.progress.completed_plans) || 0;
981
+ const totalPlans = Number(stateFm.progress.total_plans) || 0;
982
+ // Only warn if STATE says complete but REQUIREMENTS has unchecked items
983
+ if (completedPlans > 0 && completedPlans >= totalPlans && uncheckedBoxes > 0) {
984
+ addIssue('warning', 'STATE_REQ_DRIFT',
985
+ `STATE.md shows all plans complete but REQUIREMENTS.md has ${uncheckedBoxes}/${totalBoxes} unchecked checkboxes`,
986
+ 'Run syncRequirementCheckboxes or manually check completed requirement boxes');
987
+ }
988
+ }
989
+ } catch { /* no REQUIREMENTS.md — not required */ }
990
+
991
+ // Check ROADMAP.md plan checkboxes vs STATE expectations
992
+ try {
993
+ const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
994
+ // Count checked and unchecked plan lines (lines with plan filenames)
995
+ const planLines = roadmapContent.match(/- \[[ x]\]\s*\S+-(?:plan|PLAN)\S*/gi) || [];
996
+ const checkedPlans = planLines.filter(l => /- \[x\]/i.test(l)).length;
997
+ const uncheckedPlans = planLines.filter(l => /- \[ \]/.test(l)).length;
998
+ if (uncheckedPlans > 0) {
999
+ const completedPlans = Number(stateFm.progress.completed_plans) || 0;
1000
+ const totalPlans = Number(stateFm.progress.total_plans) || 0;
1001
+ if (completedPlans > 0 && completedPlans >= totalPlans && uncheckedPlans > 0) {
1002
+ addIssue('warning', 'STATE_ROADMAP_DRIFT',
1003
+ `STATE.md shows all plans complete but ROADMAP.md has ${uncheckedPlans} unchecked plan checkboxes`,
1004
+ 'Run cmdRoadmapUpdatePlanProgress for each phase or manually check completed plan boxes');
1005
+ }
1006
+ }
1007
+ } catch { /* no ROADMAP.md — other checks handle this */ }
1008
+ }
1009
+
1010
+ /**
1011
+ * Run comprehensive health checks on .planning/ structure with optional auto-repair.
1012
+ * Delegates to individual checker functions and aggregates results.
1013
+ * @param {string} cwd - Working directory path
1014
+ * @param {Object} options - Options (repair: attempt to fix repairable issues, standards: include standards check)
1015
+ * @param {boolean} raw - If true, output raw value instead of JSON
1016
+ * @returns {void}
1017
+ */
1018
+ function cmdValidateHealth(cwd, options, raw) {
1019
+ const errors = [];
1020
+ const warnings = [];
1021
+ const info = [];
1022
+ const repairs = [];
1023
+
1024
+ // Helper to add issue with severity, code, message, fix suggestion, and repairability
1025
+ const addIssue = (severity, code, message, fix, repairable = false) => {
1026
+ const issue = { code, message, fix, repairable };
1027
+ if (severity === 'error') errors.push(issue);
1028
+ else if (severity === 'warning') warnings.push(issue);
1029
+ else info.push(issue);
1030
+ };
1031
+
1032
+ // Check 1: .planning/ exists (fatal if missing -- skip remaining checks)
1033
+ if (!checkPlanningDirExists(cwd, addIssue)) {
1034
+ output({
1035
+ status: HEALTH_STATUS.BROKEN,
1036
+ errors,
1037
+ warnings,
1038
+ info,
1039
+ repairable_count: 0,
1040
+ }, raw);
1041
+ return;
1042
+ }
1043
+
1044
+ // Checks 2-8: individual structure and consistency checks
1045
+ checkProjectFile(cwd, addIssue);
1046
+ checkRoadmapFile(cwd, addIssue);
1047
+ checkStateFile(cwd, addIssue, repairs);
1048
+ checkConfigFile(cwd, addIssue, repairs);
1049
+ checkPhaseDirectories(cwd, addIssue);
1050
+ checkPhaseContents(cwd, addIssue);
1051
+
1052
+ // Check 8b: cross-document state consistency
1053
+ checkStateConsistency(cwd, addIssue);
1054
+
1055
+ // Check 8c: verification gate (phases with verifier enabled need verification.md)
1056
+ checkVerificationGate(cwd, addIssue);
1057
+
1058
+ // Check 9 (optional): standards compliance
1059
+ if (options.standards) {
1060
+ checkStandardsCompliance(cwd, addIssue);
1061
+ }
1062
+
1063
+ // Perform repairs if requested and there are repairable issues
1064
+ let repairActions = [];
1065
+ if (options.repair && repairs.length > 0) {
1066
+ repairActions = repairIssues(cwd, repairs);
1067
+ }
1068
+
1069
+ // Check 10 (optional): full validation — run tests and build
1070
+ let testStatus;
1071
+ let buildStatus;
1072
+ if (options.full) {
1073
+ testStatus = runFullTestCheck(cwd);
1074
+ buildStatus = runFullBuildCheck(cwd);
1075
+ if (testStatus.pass === false) {
1076
+ addIssue('error', 'TESTS_FAIL', `Tests failed (exit code ${testStatus.exitCode})`, 'Fix failing tests');
1077
+ }
1078
+ if (buildStatus.pass === false) {
1079
+ addIssue('error', 'BUILD_FAIL', `Build failed (exit code ${buildStatus.exitCode})`, 'Fix build errors');
1080
+ }
1081
+ }
1082
+
1083
+ // Determine overall status from error/warning counts
1084
+ let status;
1085
+ if (errors.length > 0) {
1086
+ status = HEALTH_STATUS.BROKEN;
1087
+ } else if (warnings.length > 0) {
1088
+ status = HEALTH_STATUS.DEGRADED;
1089
+ } else {
1090
+ status = HEALTH_STATUS.HEALTHY;
1091
+ }
1092
+
1093
+ const repairableCount = errors.filter(e => e.repairable).length +
1094
+ warnings.filter(w => w.repairable).length;
1095
+
1096
+ // Check 11 (optional): drift analysis
1097
+ let driftResult;
1098
+ if (options.drift) {
1099
+ driftResult = runDriftCheck(cwd);
1100
+ if (driftResult.verdict === 'high') {
1101
+ addIssue('warning', 'DRIFT_HIGH', `High drift score: ${driftResult.drift_score} (${driftResult.violation_count} violations)`, 'Run pan-tools drift-check for details');
1102
+ } else if (driftResult.verdict === 'medium') {
1103
+ addIssue('info', 'DRIFT_MEDIUM', `Medium drift score: ${driftResult.drift_score} (${driftResult.violation_count} violations)`, 'Run pan-tools drift-check for details');
1104
+ }
1105
+ }
1106
+
1107
+ const result = {
1108
+ status,
1109
+ errors,
1110
+ warnings,
1111
+ info,
1112
+ repairable_count: repairableCount,
1113
+ repairs_performed: repairActions.length > 0 ? repairActions : undefined,
1114
+ };
1115
+ if (options.full) {
1116
+ result.test_status = testStatus;
1117
+ result.build_status = buildStatus;
1118
+ }
1119
+ if (options.drift) {
1120
+ result.drift_status = driftResult;
1121
+ }
1122
+
1123
+ output(result, raw);
1124
+ }
1125
+
1126
+ /**
1127
+ * Run node --test and capture result.
1128
+ */
1129
+ function runFullTestCheck(cwd) {
1130
+ try {
1131
+ const result = execFileSync('node', ['--test'], {
1132
+ cwd,
1133
+ timeout: 120000,
1134
+ stdio: ['pipe', 'pipe', 'pipe'],
1135
+ encoding: 'utf-8',
1136
+ });
1137
+ const testMatch = result.match(/# tests (\d+)/);
1138
+ const passMatch = result.match(/# pass (\d+)/);
1139
+ return {
1140
+ pass: true,
1141
+ exitCode: 0,
1142
+ tests: testMatch ? parseInt(testMatch[1], 10) : null,
1143
+ passing: passMatch ? parseInt(passMatch[1], 10) : null,
1144
+ };
1145
+ } catch (err) {
1146
+ return {
1147
+ pass: false,
1148
+ exitCode: err.status || 1,
1149
+ tests: null,
1150
+ passing: null,
1151
+ };
1152
+ }
1153
+ }
1154
+
1155
+ /**
1156
+ * Run npm run build:hooks and capture result.
1157
+ */
1158
+ function runFullBuildCheck(cwd) {
1159
+ const pkgPath = path.join(cwd, 'package.json');
1160
+ try {
1161
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
1162
+ if (!pkg.scripts || !pkg.scripts['build:hooks']) {
1163
+ return { pass: null, exitCode: null, skipped: true };
1164
+ }
1165
+ } catch {
1166
+ return { pass: null, exitCode: null, skipped: true };
1167
+ }
1168
+
1169
+ try {
1170
+ execFileSync('npm', ['run', 'build:hooks'], {
1171
+ cwd,
1172
+ timeout: 60000,
1173
+ stdio: ['pipe', 'pipe', 'pipe'],
1174
+ shell: true,
1175
+ });
1176
+ return { pass: true, exitCode: 0, skipped: false };
1177
+ } catch (err) {
1178
+ return { pass: false, exitCode: err.status || 1, skipped: false };
1179
+ }
1180
+ }
1181
+
1182
+ /**
1183
+ * Pre-flight validation: check execution prerequisites before starting work.
1184
+ * Validates state consistency, git cleanliness, blockers, and error patterns.
1185
+ * @param {string} cwd - Working directory path
1186
+ * @param {string|null} target - Optional target (phase number or 'batch')
1187
+ * @param {boolean} raw - If true, output raw value instead of JSON
1188
+ * @returns {void}
1189
+ */
1190
+ function cmdPreflight(cwd, target, raw) {
1191
+ const checks = [];
1192
+ const blockers = [];
1193
+
1194
+ // Check 1: .planning/ directory exists
1195
+ const planDir = planningPath(cwd);
1196
+ if (fileAccessible(planDir)) {
1197
+ checks.push({ name: 'planning_dir', passed: true });
1198
+ } else {
1199
+ checks.push({ name: 'planning_dir', passed: false, detail: '.planning/ directory not found' });
1200
+ blockers.push('.planning/ directory not found — run /pan:new-project');
1201
+ }
1202
+
1203
+ // Check 2: state.md is parseable
1204
+ const statePath = path.join(planDir, STATE_FILE);
1205
+ const stateContent = readStateSafe(statePath);
1206
+ if (stateContent) {
1207
+ checks.push({ name: 'state_readable', passed: true });
1208
+ } else {
1209
+ checks.push({ name: 'state_readable', passed: false, detail: 'state.md not found or unreadable' });
1210
+ blockers.push('state.md not found — run /pan:new-project');
1211
+ }
1212
+
1213
+ // Check 3: no unresolved blockers in state.md
1214
+ if (stateContent) {
1215
+ const blockersMatch = stateContent.match(/##\s*Blockers\s*\n([\s\S]*?)(?=\n##|$)/i);
1216
+ const activeBlockers = [];
1217
+ if (blockersMatch) {
1218
+ const items = blockersMatch[1].match(/^-\s+(.+)$/gm) || [];
1219
+ for (const item of items) {
1220
+ const text = item.replace(/^-\s+/, '').trim();
1221
+ if (text && !/^none$/i.test(text)) {
1222
+ activeBlockers.push(text);
1223
+ }
1224
+ }
1225
+ }
1226
+ if (activeBlockers.length === 0) {
1227
+ checks.push({ name: 'no_blockers', passed: true });
1228
+ } else {
1229
+ checks.push({ name: 'no_blockers', passed: false, detail: activeBlockers.length + ' active blocker(s)' });
1230
+ for (const b of activeBlockers) blockers.push('Blocker: ' + b);
1231
+ }
1232
+ }
1233
+
1234
+ // Check 4: git working tree is clean
1235
+ const gitResult = execGit(cwd, ['status', '--porcelain']);
1236
+ if (gitResult.exitCode !== 0) {
1237
+ checks.push({ name: 'git_clean', passed: true, detail: 'not a git repo or git unavailable' });
1238
+ } else {
1239
+ const dirty = gitResult.stdout.split('\n').filter(l => l.trim()).length;
1240
+ if (dirty === 0) {
1241
+ checks.push({ name: 'git_clean', passed: true });
1242
+ } else {
1243
+ checks.push({ name: 'git_clean', passed: false, detail: dirty + ' uncommitted change(s)' });
1244
+ blockers.push(dirty + ' uncommitted changes — commit or stash before executing');
1245
+ }
1246
+ }
1247
+
1248
+ // Check 5: no known error patterns (check patterns.md exists and has entries)
1249
+ const patternsPath = path.join(planDir, PATTERNS_FILE);
1250
+ let patternCount = 0;
1251
+ try {
1252
+ const patternsContent = fs.readFileSync(patternsPath, 'utf-8');
1253
+ const patternMatches = patternsContent.match(/^### PAT-\d+:/gm);
1254
+ patternCount = patternMatches ? patternMatches.length : 0;
1255
+ } catch { /* no patterns file — that's fine */ }
1256
+ checks.push({ name: 'error_patterns', passed: true, detail: patternCount + ' known pattern(s)' });
1257
+
1258
+ // Check 6: config.json exists
1259
+ const configPath = path.join(planDir, CONFIG_FILE);
1260
+ if (fileAccessible(configPath)) {
1261
+ checks.push({ name: 'config_exists', passed: true });
1262
+ } else {
1263
+ checks.push({ name: 'config_exists', passed: false, detail: 'config.json not found' });
1264
+ }
1265
+
1266
+ // Check 7: target-specific checks
1267
+ if (target && stateContent) {
1268
+ const currentPhaseMatch = stateContent.match(/\*\*Current Phase:\*\*\s*(\S+)/);
1269
+ const currentPhase = currentPhaseMatch ? currentPhaseMatch[1] : null;
1270
+ if (target === 'batch') {
1271
+ // Check that a batch file exists
1272
+ const focusDir = path.join(planDir, 'focus');
1273
+ try {
1274
+ const files = fs.readdirSync(focusDir).filter(f => f.startsWith('batch-') && f.endsWith('.json'));
1275
+ if (files.length > 0) {
1276
+ checks.push({ name: 'batch_exists', passed: true, detail: files[files.length - 1] });
1277
+ } else {
1278
+ checks.push({ name: 'batch_exists', passed: false, detail: 'no batch file found' });
1279
+ blockers.push('No batch file — run /pan:focus-plan first');
1280
+ }
1281
+ } catch {
1282
+ checks.push({ name: 'batch_exists', passed: false, detail: 'focus/ directory not found' });
1283
+ blockers.push('No focus/ directory — run /pan:focus-scan first');
1284
+ }
1285
+ } else {
1286
+ // target is a phase number — check the phase directory exists
1287
+ const phaseResult = findPhaseInternal(cwd, target);
1288
+ if (phaseResult) {
1289
+ checks.push({ name: 'target_phase', passed: true, detail: 'Phase ' + target + ' found' });
1290
+ } else {
1291
+ checks.push({ name: 'target_phase', passed: false, detail: 'Phase ' + target + ' not found' });
1292
+ blockers.push('Phase ' + target + ' directory not found');
1293
+ }
1294
+ }
1295
+ }
1296
+
1297
+ const ready = blockers.length === 0;
1298
+
1299
+ output({
1300
+ ready,
1301
+ target: target || null,
1302
+ checks,
1303
+ blockers,
1304
+ passed: checks.filter(c => c.passed).length,
1305
+ total: checks.length,
1306
+ }, raw, ready ? 'ready' : 'blocked');
1307
+ }
1308
+
1309
+ /**
1310
+ * Dependency graph validation — cross-reference roadmap phases vs disk directories
1311
+ * and requirements vs phase summaries to detect drift.
1312
+ * @param {string} cwd - Working directory path
1313
+ * @param {boolean} raw - If true, output raw value instead of JSON
1314
+ * @returns {void}
1315
+ */
1316
+ function cmdDepsValidate(cwd, raw) {
1317
+ const planDir = planningPath(cwd);
1318
+ const issues = [];
1319
+ const orphanedReqs = [];
1320
+ const missingPhases = [];
1321
+ const orphanedDirs = [];
1322
+
1323
+ // Step 1: Parse roadmap phases
1324
+ const roadmapPath = path.join(planDir, ROADMAP_FILE);
1325
+ const roadmapContent = safeReadFile(roadmapPath);
1326
+ const roadmapPhases = new Map(); // number -> name
1327
+ if (roadmapContent) {
1328
+ const headerRe = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
1329
+ let match;
1330
+ while ((match = headerRe.exec(roadmapContent)) !== null) {
1331
+ roadmapPhases.set(match[1], match[2].trim());
1332
+ }
1333
+ } else {
1334
+ issues.push({ type: 'warning', message: 'roadmap.md not found' });
1335
+ }
1336
+
1337
+ // Step 2: Scan disk phase directories
1338
+ const diskPhases = new Map(); // number -> dirName
1339
+ try {
1340
+ const entries = fs.readdirSync(phasesPath(cwd), { withFileTypes: true });
1341
+ for (const entry of entries) {
1342
+ if (entry.isDirectory()) {
1343
+ const dirMatch = entry.name.match(PHASE_DIR_RE);
1344
+ if (dirMatch) {
1345
+ diskPhases.set(dirMatch[1], entry.name);
1346
+ }
1347
+ }
1348
+ }
1349
+ } catch { /* phases dir missing */ }
1350
+
1351
+ // Step 3: Cross-reference roadmap vs disk
1352
+ for (const [num, name] of roadmapPhases) {
1353
+ if (!diskPhases.has(num)) {
1354
+ missingPhases.push({ number: num, name, source: 'roadmap' });
1355
+ issues.push({ type: 'error', message: 'Phase ' + num + ' (' + name + ') in roadmap but no directory on disk' });
1356
+ }
1357
+ }
1358
+ for (const [num, dirName] of diskPhases) {
1359
+ if (!roadmapPhases.has(num)) {
1360
+ orphanedDirs.push({ number: num, directory: dirName });
1361
+ issues.push({ type: 'warning', message: 'Directory ' + dirName + ' exists on disk but Phase ' + num + ' not found in roadmap' });
1362
+ }
1363
+ }
1364
+
1365
+ // Step 4: Parse requirements
1366
+ const reqPath = path.join(planDir, 'requirements.md');
1367
+ const reqContent = safeReadFile(reqPath);
1368
+ const allReqIds = [];
1369
+ const completedReqIds = new Set();
1370
+ if (reqContent) {
1371
+ // Find all REQ-NN patterns in checkbox lines
1372
+ const reqLines = reqContent.match(/^-\s*\[[ x]\]\s*\*\*([A-Z]+-\d+)\*\*/gmi) || [];
1373
+ for (const line of reqLines) {
1374
+ const idMatch = line.match(/\*\*([A-Z]+-\d+)\*\*/i);
1375
+ if (idMatch) {
1376
+ allReqIds.push(idMatch[1]);
1377
+ if (/\[x\]/i.test(line)) {
1378
+ completedReqIds.add(idMatch[1]);
1379
+ }
1380
+ }
1381
+ }
1382
+ }
1383
+
1384
+ // Step 5: Check requirements traceability — find REQ IDs mentioned in summaries
1385
+ const tracedReqIds = new Set();
1386
+ for (const [, dirName] of diskPhases) {
1387
+ try {
1388
+ const files = fs.readdirSync(path.join(phasesPath(cwd), dirName));
1389
+ for (const file of filterSummaryFiles(files)) {
1390
+ const summaryContent = safeReadFile(path.join(phasesPath(cwd), dirName, file));
1391
+ if (summaryContent) {
1392
+ const mentions = summaryContent.match(/[A-Z]+-\d+/g) || [];
1393
+ for (const id of mentions) {
1394
+ if (allReqIds.includes(id)) tracedReqIds.add(id);
1395
+ }
1396
+ }
1397
+ }
1398
+ } catch { /* unreadable dir */ }
1399
+ }
1400
+
1401
+ // Find requirements that are neither completed nor traced in any summary
1402
+ for (const reqId of allReqIds) {
1403
+ if (!completedReqIds.has(reqId) && !tracedReqIds.has(reqId)) {
1404
+ orphanedReqs.push(reqId);
1405
+ issues.push({ type: 'info', message: 'Requirement ' + reqId + ' not marked complete and not referenced in any summary' });
1406
+ }
1407
+ }
1408
+
1409
+ const valid = issues.filter(i => i.type === 'error').length === 0;
1410
+
1411
+ output({
1412
+ valid,
1413
+ issues,
1414
+ roadmap_phases: roadmapPhases.size,
1415
+ disk_phases: diskPhases.size,
1416
+ requirements_total: allReqIds.length,
1417
+ requirements_completed: completedReqIds.size,
1418
+ orphaned_reqs: orphanedReqs,
1419
+ missing_phases: missingPhases,
1420
+ orphaned_dirs: orphanedDirs,
1421
+ }, raw, valid ? 'valid' : 'issues found');
1422
+ }
1423
+
1424
+ // ─── Drift detection ─────────────────────────────────────────────────────────
1425
+
1426
+ /**
1427
+ * Parse convention rules from CONVENTIONS.md markdown content.
1428
+ * Extracts anti-pattern rules from prose containing "instead of", "not", "never".
1429
+ * Run drift check internally and return result object (no output).
1430
+ * Used by cmdValidateHealth --drift.
1431
+ */
1432
+ function runDriftCheck(cwd) {
1433
+ const conventionsPath = path.join(planningPath(cwd), 'codebase', 'CONVENTIONS.md');
1434
+ const conventionsContent = safeReadFile(conventionsPath);
1435
+ const claudeMdContent = safeReadFile(path.join(cwd, 'CLAUDE.md'));
1436
+ const combined = [conventionsContent, claudeMdContent].filter(Boolean).join('\n');
1437
+ const rules = parseConventionRules(combined || null);
1438
+ const files = getChangedFiles(cwd);
1439
+ const allViolations = [];
1440
+ let filesChecked = 0;
1441
+ for (const filePath of files) {
1442
+ const fullPath = path.join(cwd, filePath);
1443
+ try {
1444
+ const stat = fs.statSync(fullPath);
1445
+ if (stat.size > DRIFT_MAX_FILE_SIZE) continue;
1446
+ } catch { continue; }
1447
+ const content = safeReadFile(fullPath);
1448
+ if (!content) continue;
1449
+ filesChecked++;
1450
+ allViolations.push(...checkFileConventions(filePath, content, rules));
1451
+ }
1452
+ const { score, verdict } = calculateDriftScore(allViolations, filesChecked, rules.length);
1453
+ return { drift_score: score, verdict, violation_count: allViolations.length, files_checked: filesChecked };
1454
+ }
1455
+
1456
+ /**
1457
+ * Always merges with BUILTIN_DRIFT_RULES.
1458
+ * @param {string|null} content - Markdown content from CONVENTIONS.md
1459
+ * @returns {Array} Array of rule objects {id, antiPattern, message, severity, fileGlob}
1460
+ */
1461
+ function parseConventionRules(content) {
1462
+ const parsed = [];
1463
+ if (content) {
1464
+ // Match lines with inline code containing negation patterns
1465
+ const lines = content.split(/\r?\n/);
1466
+ for (const line of lines) {
1467
+ // Pattern: "Use X instead of `Y`" or "Never use `Y`" or "Don't use `Y`"
1468
+ const negMatch = line.match(/(?:instead of|never use|don'?t use|avoid|not)\s+`([^`]+)`/i);
1469
+ if (negMatch) {
1470
+ const raw = negMatch[1].trim();
1471
+ try {
1472
+ const antiPattern = new RegExp('\\b' + raw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b');
1473
+ parsed.push({
1474
+ id: 'conv-' + raw.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase().slice(0, 30),
1475
+ antiPattern,
1476
+ message: line.trim().slice(0, 120),
1477
+ severity: 'warning',
1478
+ fileGlob: null,
1479
+ });
1480
+ } catch { /* invalid regex — skip */ }
1481
+ }
1482
+ }
1483
+ }
1484
+ // Merge: parsed + builtins, dedup by id (parsed takes priority)
1485
+ const ids = new Set(parsed.map(r => r.id));
1486
+ for (const rule of BUILTIN_DRIFT_RULES) {
1487
+ if (!ids.has(rule.id)) parsed.push(rule);
1488
+ }
1489
+ return parsed;
1490
+ }
1491
+
1492
+ /**
1493
+ * Check a single file's content against convention rules.
1494
+ * @param {string} filePath - Relative file path (for glob matching and output)
1495
+ * @param {string} content - File content
1496
+ * @param {Array} rules - Convention rules from parseConventionRules
1497
+ * @returns {Array} violations [{file, line, rule, message, severity}]
1498
+ */
1499
+ function checkFileConventions(filePath, content, rules) {
1500
+ const violations = [];
1501
+ const lines = content.split(/\r?\n/);
1502
+ for (const rule of rules) {
1503
+ // Check fileGlob match (simple endsWith check — zero-dep)
1504
+ if (rule.fileGlob && !filePath.endsWith(rule.fileGlob)) continue;
1505
+ for (let i = 0; i < lines.length; i++) {
1506
+ const line = lines[i];
1507
+ // Skip comment-only lines
1508
+ if (/^\s*(\/\/|\/?\*|\*)/.test(line)) continue;
1509
+ if (rule.antiPattern.test(line)) {
1510
+ violations.push({
1511
+ file: toPosix(filePath),
1512
+ line: i + 1,
1513
+ rule: rule.id,
1514
+ message: rule.message,
1515
+ severity: rule.severity,
1516
+ });
1517
+ }
1518
+ }
1519
+ }
1520
+ return violations;
1521
+ }
1522
+
1523
+ /**
1524
+ * Calculate drift score from violations.
1525
+ * @param {Array} violations - All violations across checked files
1526
+ * @param {number} filesChecked - Number of files checked
1527
+ * @param {number} rulesCount - Total rules applied
1528
+ * @returns {{score: number, verdict: string}}
1529
+ */
1530
+ function calculateDriftScore(violations, filesChecked, rulesCount) {
1531
+ if (filesChecked === 0 || rulesCount === 0) return { score: 0, verdict: 'clean' };
1532
+ let weighted = 0;
1533
+ for (const v of violations) {
1534
+ weighted += DRIFT_SEVERITY_WEIGHTS[v.severity] || 0;
1535
+ }
1536
+ const ceiling = filesChecked * rulesCount * 0.3;
1537
+ const score = Math.min(1.0, weighted / Math.max(ceiling, 1));
1538
+ const rounded = Math.round(score * 100) / 100;
1539
+ const verdict = DRIFT_VERDICTS.find(b => rounded <= b.max)?.verdict || 'high';
1540
+ return { score: rounded, verdict };
1541
+ }
1542
+
1543
+ /**
1544
+ * Get list of changed files from git diff.
1545
+ * @param {string} cwd - Working directory
1546
+ * @param {string} [sinceRef] - Git ref to diff against (default: HEAD)
1547
+ * @returns {string[]} Array of relative file paths
1548
+ */
1549
+ function getChangedFiles(cwd, sinceRef) {
1550
+ const ref = sinceRef || 'HEAD';
1551
+ // Try staged + unstaged first, then diff against ref
1552
+ const result = execGit(cwd, ['diff', '--name-only', ref]);
1553
+ if (result.exitCode !== 0) return [];
1554
+ const stagedResult = execGit(cwd, ['diff', '--name-only', '--cached']);
1555
+ const allFiles = new Set();
1556
+ for (const line of result.stdout.split(/\r?\n/)) {
1557
+ if (line.trim()) allFiles.add(line.trim());
1558
+ }
1559
+ if (stagedResult.exitCode === 0) {
1560
+ for (const line of stagedResult.stdout.split(/\r?\n/)) {
1561
+ if (line.trim()) allFiles.add(line.trim());
1562
+ }
1563
+ }
1564
+ // Filter out binary extensions and limit
1565
+ const filtered = [];
1566
+ for (const f of allFiles) {
1567
+ const ext = path.extname(f).toLowerCase();
1568
+ if (BINARY_EXTENSIONS.has(ext)) continue;
1569
+ filtered.push(f);
1570
+ if (filtered.length >= DRIFT_MAX_FILES) break;
1571
+ }
1572
+ return filtered;
1573
+ }
1574
+
1575
+ /**
1576
+ * Run drift check on changed files against project conventions.
1577
+ * @param {string} cwd - Working directory
1578
+ * @param {boolean} raw - Raw output mode
1579
+ * @param {string[]} args - CLI arguments
1580
+ */
1581
+ function cmdDriftCheck(cwd, raw, args) {
1582
+ // Parse flags
1583
+ let sinceRef = null;
1584
+ let threshold = 0.5;
1585
+ let specificFiles = null;
1586
+ const verbose = process.env.PAN_VERBOSE === '1';
1587
+ for (let i = 0; i < args.length; i++) {
1588
+ if (args[i] === '--since' && args[i + 1]) { sinceRef = args[++i]; }
1589
+ else if (args[i] === '--threshold' && args[i + 1]) {
1590
+ const t = parseFloat(args[++i]);
1591
+ if (isNaN(t) || t < 0 || t > 1) { error('threshold must be 0.0-1.0'); return; }
1592
+ threshold = t;
1593
+ }
1594
+ else if (args[i] === '--files' && args[i + 1]) { specificFiles = args[++i].split(',').map(f => f.trim()); }
1595
+ }
1596
+
1597
+ // Load convention rules
1598
+ const conventionsPath = path.join(planningPath(cwd), 'codebase', 'CONVENTIONS.md');
1599
+ const conventionsContent = safeReadFile(conventionsPath);
1600
+ const claudeMdContent = safeReadFile(path.join(cwd, 'CLAUDE.md'));
1601
+ const combined = [conventionsContent, claudeMdContent].filter(Boolean).join('\n');
1602
+ const rules = parseConventionRules(combined || null);
1603
+
1604
+ // Get files to check
1605
+ let files;
1606
+ if (specificFiles) {
1607
+ files = specificFiles;
1608
+ } else {
1609
+ files = getChangedFiles(cwd, sinceRef);
1610
+ }
1611
+
1612
+ // Check each file
1613
+ const allViolations = [];
1614
+ let filesChecked = 0;
1615
+ for (const filePath of files) {
1616
+ const fullPath = path.join(cwd, filePath);
1617
+ try {
1618
+ const stat = fs.statSync(fullPath);
1619
+ if (stat.size > DRIFT_MAX_FILE_SIZE) continue;
1620
+ } catch { continue; }
1621
+ const content = safeReadFile(fullPath);
1622
+ if (!content) continue;
1623
+ filesChecked++;
1624
+ const violations = checkFileConventions(filePath, content, rules);
1625
+ allViolations.push(...violations);
1626
+ }
1627
+
1628
+ // Calculate score
1629
+ const { score, verdict } = calculateDriftScore(allViolations, filesChecked, rules.length);
1630
+ const passed = score <= threshold;
1631
+ const summary = filesChecked === 0
1632
+ ? 'no files changed'
1633
+ : rules.length === 0
1634
+ ? 'no conventions loaded'
1635
+ : `drift: ${score} (${verdict}) — ${allViolations.length} violations in ${filesChecked} files`;
1636
+
1637
+ const result = {
1638
+ drift_score: score,
1639
+ verdict,
1640
+ passed,
1641
+ threshold,
1642
+ violations: allViolations,
1643
+ violation_count: allViolations.length,
1644
+ files_checked: filesChecked,
1645
+ conventions_loaded: rules.length,
1646
+ summary,
1647
+ };
1648
+ if (verbose) {
1649
+ const byFile = {};
1650
+ for (const v of allViolations) {
1651
+ if (!byFile[v.file]) byFile[v.file] = [];
1652
+ byFile[v.file].push({ line: v.line, rule: v.rule, message: v.message, severity: v.severity });
1653
+ }
1654
+ result.per_file = byFile;
1655
+ }
1656
+ output(result, raw, summary);
1657
+ }
1658
+
1659
+ // ─── Retrospective Analysis ─────────────────────────────────────────────────
1660
+
1661
+ /**
1662
+ * Scan verification files in a phases directory and collect stats.
1663
+ * @param {string} phasesDir - Absolute path to phases directory
1664
+ * @returns {{ total: number, passed: number, gaps_found: number, human_needed: number, gap_patterns: string[] }}
1665
+ */
1666
+ function collectVerificationStats(phasesDir) {
1667
+ const stats = { total: 0, passed: 0, gaps_found: 0, human_needed: 0, gap_patterns: [] };
1668
+ let dirs;
1669
+ try { dirs = fs.readdirSync(phasesDir, { withFileTypes: true }); } catch { return stats; }
1670
+ for (const d of dirs) {
1671
+ if (!d.isDirectory()) continue;
1672
+ const phaseDir = path.join(phasesDir, d.name);
1673
+ let files;
1674
+ try { files = fs.readdirSync(phaseDir); } catch { continue; }
1675
+ for (const f of files) {
1676
+ if (!isVerificationFile(f)) continue;
1677
+ stats.total++;
1678
+ const content = safeReadFile(path.join(phaseDir, f));
1679
+ if (!content) continue;
1680
+ const fm = extractFrontmatter(content);
1681
+ const status = (fm.status || '').toLowerCase();
1682
+ if (status === 'passed') stats.passed++;
1683
+ else if (status === 'gaps_found') stats.gaps_found++;
1684
+ else if (status === 'human_needed') stats.human_needed++;
1685
+ // Extract gap descriptions from ## Gaps section
1686
+ const gapsMatch = content.match(/## Gaps[\s\S]*?(?=\n## |$)/);
1687
+ if (gapsMatch) {
1688
+ const lines = gapsMatch[0].split('\n').filter(l => l.match(/^[-*]\s+/));
1689
+ for (const line of lines) {
1690
+ const desc = line.replace(/^[-*]\s+/, '').trim();
1691
+ if (desc) stats.gap_patterns.push(desc);
1692
+ }
1693
+ }
1694
+ }
1695
+ }
1696
+ return stats;
1697
+ }
1698
+
1699
+ /**
1700
+ * Count phases from roadmap: total planned, completed, and decimal (gap closure) phases.
1701
+ * @param {string} roadmapContent - Roadmap file content
1702
+ * @returns {{ planned: number, completed: number, decimal_phases: number }}
1703
+ */
1704
+ function countRoadmapPhases(roadmapContent) {
1705
+ const result = { planned: 0, completed: 0, decimal_phases: 0 };
1706
+ const checkboxRe = /- \[([ x])\]\s*(?:\*\*)?Phase\s+(\d+[A-Z]?(?:\.\d+)*)/gi;
1707
+ let m;
1708
+ while ((m = checkboxRe.exec(roadmapContent)) !== null) {
1709
+ result.planned++;
1710
+ if (m[1] === 'x') result.completed++;
1711
+ if (m[2].includes('.')) result.decimal_phases++;
1712
+ }
1713
+ return result;
1714
+ }
1715
+
1716
+ /**
1717
+ * Group gap patterns by similarity (simple keyword grouping).
1718
+ * @param {string[]} patterns - Raw gap descriptions
1719
+ * @returns {Array<{pattern: string, count: number}>}
1720
+ */
1721
+ function groupGapPatterns(patterns) {
1722
+ const groups = {};
1723
+ for (const p of patterns) {
1724
+ const key = p.toLowerCase().replace(/[^a-z0-9\s]/g, '').trim();
1725
+ const words = key.split(/\s+/).slice(0, 3).join(' ');
1726
+ groups[words] = (groups[words] || 0) + 1;
1727
+ }
1728
+ return Object.entries(groups)
1729
+ .map(([pattern, count]) => ({ pattern, count }))
1730
+ .sort((a, b) => b.count - a.count)
1731
+ .slice(0, 10);
1732
+ }
1733
+
1734
+ /**
1735
+ * Milestone retrospective — analyze historical .planning/ data for process improvement.
1736
+ * @param {string} cwd - Working directory
1737
+ * @param {boolean} raw - Raw output flag
1738
+ */
1739
+ function cmdRetro(cwd, raw) {
1740
+ const roadmapPath = path.join(planningPath(cwd), ROADMAP_FILE);
1741
+ const roadmapContent = safeReadFile(roadmapPath);
1742
+ if (!roadmapContent) {
1743
+ return output({ error: 'roadmap.md not found' }, raw, 'roadmap.md not found');
1744
+ }
1745
+
1746
+ const phases = countRoadmapPhases(roadmapContent);
1747
+ const pDir = phasesPath(cwd);
1748
+ const verification = collectVerificationStats(pDir);
1749
+ const gapGroups = groupGapPatterns(verification.gap_patterns);
1750
+
1751
+ // Estimation accuracy: planned phases vs actual (including decimal gap closures)
1752
+ const basePlanned = phases.planned - phases.decimal_phases;
1753
+ const estimationAccuracy = basePlanned > 0
1754
+ ? Math.round((basePlanned / phases.planned) * 100)
1755
+ : 100;
1756
+
1757
+ const result = {
1758
+ phases_planned: phases.planned,
1759
+ phases_completed: phases.completed,
1760
+ phases_decimal: phases.decimal_phases,
1761
+ estimation_accuracy_pct: estimationAccuracy,
1762
+ verifications_total: verification.total,
1763
+ verifications_passed_first_try: verification.passed,
1764
+ verifications_gaps_found: verification.gaps_found,
1765
+ verifications_human_needed: verification.human_needed,
1766
+ first_try_rate_pct: verification.total > 0
1767
+ ? Math.round((verification.passed / verification.total) * 100)
1768
+ : null,
1769
+ common_gap_patterns: gapGroups,
1770
+ };
1771
+
1772
+ const rawLines = [
1773
+ `Phases: ${phases.completed}/${phases.planned} completed (${phases.decimal_phases} gap closures)`,
1774
+ `Estimation accuracy: ${estimationAccuracy}%`,
1775
+ `Verifications: ${verification.passed}/${verification.total} passed first try`,
1776
+ `Gaps found: ${verification.gaps_found}, Human needed: ${verification.human_needed}`,
1777
+ ];
1778
+ if (gapGroups.length > 0) {
1779
+ rawLines.push('Common gap patterns:');
1780
+ for (const g of gapGroups) rawLines.push(` - ${g.pattern} (${g.count}x)`);
1781
+ }
1782
+
1783
+ output(result, raw, rawLines.join('\n'));
1784
+ }
1785
+
1786
+ module.exports = {
1787
+ cmdVerifySummary,
1788
+ cmdVerifyPlanStructure,
1789
+ cmdVerifyPhaseCompleteness,
1790
+ cmdVerifyReferences,
1791
+ cmdVerifyCommits,
1792
+ cmdVerifyArtifacts,
1793
+ cmdVerifyKeyLinks,
1794
+ cmdValidateConsistency,
1795
+ cmdValidateHealth,
1796
+ cmdPreflight,
1797
+ cmdDepsValidate,
1798
+ cmdDriftCheck,
1799
+ parseConventionRules,
1800
+ checkFileConventions,
1801
+ calculateDriftScore,
1802
+ getChangedFiles,
1803
+ cmdRetro,
1804
+ collectVerificationStats,
1805
+ countRoadmapPhases,
1806
+ groupGapPatterns,
1807
+ checkVerificationGate,
1808
+ };