pan-wizard 3.8.0 → 3.10.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 (49) hide show
  1. package/README.md +4 -1
  2. package/agents/pan-conductor.md +1 -2
  3. package/agents/pan-counterfactual.md +1 -2
  4. package/agents/pan-debugger.md +1 -2
  5. package/agents/pan-distiller.md +1 -2
  6. package/agents/pan-document_code.md +1 -0
  7. package/agents/pan-executor.md +1 -0
  8. package/agents/pan-experiment-runner.md +1 -2
  9. package/agents/pan-hardener.md +1 -2
  10. package/agents/pan-integration-checker.md +1 -2
  11. package/agents/pan-knowledge.md +1 -2
  12. package/agents/pan-meta-reviewer.md +1 -2
  13. package/agents/pan-optimizer.md +1 -0
  14. package/agents/pan-phase-researcher.md +1 -0
  15. package/agents/pan-plan-checker.md +1 -2
  16. package/agents/pan-planner.md +1 -0
  17. package/agents/pan-previewer.md +1 -2
  18. package/agents/pan-project-researcher.md +6 -0
  19. package/agents/pan-research-synthesizer.md +7 -0
  20. package/agents/pan-reviewer.md +2 -3
  21. package/agents/pan-roadmapper.md +1 -0
  22. package/agents/pan-verifier.md +1 -2
  23. package/bin/install-lib.cjs +661 -46
  24. package/bin/install.js +722 -116
  25. package/commands/pan/experiment.md +2 -0
  26. package/commands/pan/profile.md +2 -0
  27. package/hooks/dist/pan-cost-logger.js +22 -7
  28. package/package.json +5 -4
  29. package/pan-wizard-core/bin/lib/commands-learnings.cjs +544 -0
  30. package/pan-wizard-core/bin/lib/commands.cjs +12 -523
  31. package/pan-wizard-core/bin/lib/core.cjs +69 -0
  32. package/pan-wizard-core/bin/lib/cost.cjs +62 -8
  33. package/pan-wizard-core/bin/lib/git.cjs +6 -1
  34. package/pan-wizard-core/bin/lib/lock.cjs +108 -0
  35. package/pan-wizard-core/bin/lib/milestone.cjs +3 -2
  36. package/pan-wizard-core/bin/lib/phase-remove.cjs +392 -0
  37. package/pan-wizard-core/bin/lib/phase.cjs +4 -369
  38. package/pan-wizard-core/bin/lib/runner.cjs +5 -0
  39. package/pan-wizard-core/bin/lib/state.cjs +10 -1
  40. package/pan-wizard-core/bin/lib/verify-deploy.cjs +181 -0
  41. package/pan-wizard-core/bin/lib/verify-drift.cjs +255 -0
  42. package/pan-wizard-core/bin/lib/verify-preflight.cjs +261 -0
  43. package/pan-wizard-core/bin/lib/verify-retro.cjs +177 -0
  44. package/pan-wizard-core/bin/lib/verify.cjs +10 -797
  45. package/pan-wizard-core/bin/pan-tools.cjs +10 -0
  46. package/pan-wizard-core/workflows/plan-phase.md +11 -0
  47. package/scripts/build-plugin.js +105 -0
  48. package/scripts/install-git-hooks.js +64 -0
  49. package/scripts/release-check.js +13 -2
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Verify / Drift detection — convention-drift scoring for changed files.
3
+ * Extracted from verify.cjs (IMPROVEMENT-TODO P2 module decomposition);
4
+ * verify.cjs re-exports everything here, so consumers are unaffected.
5
+ */
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const { safeReadFile, execGit, toPosix, output, error } = require('./core.cjs');
10
+ const {
11
+ BUILTIN_DRIFT_RULES, DRIFT_VERDICTS, BINARY_EXTENSIONS, DRIFT_MAX_FILES, DRIFT_MAX_FILE_SIZE, DRIFT_SEVERITY_WEIGHTS,
12
+ } = require('./constants.cjs');
13
+ const { planningPath } = require('./utils.cjs');
14
+
15
+ /**
16
+ * Parse convention rules from CONVENTIONS.md markdown content.
17
+ * Extracts anti-pattern rules from prose containing "instead of", "not", "never".
18
+ * Run drift check internally and return result object (no output).
19
+ * Used by cmdValidateHealth --drift.
20
+ */
21
+ function runDriftCheck(cwd) {
22
+ const conventionsPath = path.join(planningPath(cwd), 'codebase', 'CONVENTIONS.md');
23
+ const conventionsContent = safeReadFile(conventionsPath);
24
+ const claudeMdContent = safeReadFile(path.join(cwd, 'CLAUDE.md'));
25
+ const combined = [conventionsContent, claudeMdContent].filter(Boolean).join('\n');
26
+ const rules = parseConventionRules(combined || null);
27
+ const files = getChangedFiles(cwd);
28
+ const allViolations = [];
29
+ let filesChecked = 0;
30
+ for (const filePath of files) {
31
+ const fullPath = path.join(cwd, filePath);
32
+ try {
33
+ const stat = fs.statSync(fullPath);
34
+ if (stat.size > DRIFT_MAX_FILE_SIZE) continue;
35
+ } catch { continue; }
36
+ const content = safeReadFile(fullPath);
37
+ if (!content) continue;
38
+ filesChecked++;
39
+ allViolations.push(...checkFileConventions(filePath, content, rules));
40
+ }
41
+ const { score, verdict } = calculateDriftScore(allViolations, filesChecked, rules.length);
42
+ return { drift_score: score, verdict, violation_count: allViolations.length, files_checked: filesChecked };
43
+ }
44
+
45
+ /**
46
+ * Always merges with BUILTIN_DRIFT_RULES.
47
+ * @param {string|null} content - Markdown content from CONVENTIONS.md
48
+ * @returns {Array} Array of rule objects {id, antiPattern, message, severity, fileGlob}
49
+ */
50
+ function parseConventionRules(content) {
51
+ const parsed = [];
52
+ if (content) {
53
+ // Match lines with inline code containing negation patterns
54
+ const lines = content.split(/\r?\n/);
55
+ for (const line of lines) {
56
+ // Pattern: "Use X instead of `Y`" or "Never use `Y`" or "Don't use `Y`"
57
+ const negMatch = line.match(/(?:instead of|never use|don'?t use|avoid|not)\s+`([^`]+)`/i);
58
+ if (negMatch) {
59
+ const raw = negMatch[1].trim();
60
+ try {
61
+ const antiPattern = new RegExp('\\b' + raw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b');
62
+ parsed.push({
63
+ id: 'conv-' + raw.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase().slice(0, 30),
64
+ antiPattern,
65
+ message: line.trim().slice(0, 120),
66
+ severity: 'warning',
67
+ fileGlob: null,
68
+ });
69
+ } catch { /* invalid regex — skip */ }
70
+ }
71
+ }
72
+ }
73
+ // Merge: parsed + builtins, dedup by id (parsed takes priority)
74
+ const ids = new Set(parsed.map(r => r.id));
75
+ for (const rule of BUILTIN_DRIFT_RULES) {
76
+ if (!ids.has(rule.id)) parsed.push(rule);
77
+ }
78
+ return parsed;
79
+ }
80
+
81
+ /**
82
+ * Check a single file's content against convention rules.
83
+ * @param {string} filePath - Relative file path (for glob matching and output)
84
+ * @param {string} content - File content
85
+ * @param {Array} rules - Convention rules from parseConventionRules
86
+ * @returns {Array} violations [{file, line, rule, message, severity}]
87
+ */
88
+ function checkFileConventions(filePath, content, rules) {
89
+ const violations = [];
90
+ const lines = content.split(/\r?\n/);
91
+ for (const rule of rules) {
92
+ // Check fileGlob match (simple endsWith check — zero-dep)
93
+ if (rule.fileGlob && !filePath.endsWith(rule.fileGlob)) continue;
94
+ for (let i = 0; i < lines.length; i++) {
95
+ const line = lines[i];
96
+ // Skip comment-only lines
97
+ if (/^\s*(\/\/|\/?\*|\*)/.test(line)) continue;
98
+ if (rule.antiPattern.test(line)) {
99
+ violations.push({
100
+ file: toPosix(filePath),
101
+ line: i + 1,
102
+ rule: rule.id,
103
+ message: rule.message,
104
+ severity: rule.severity,
105
+ });
106
+ }
107
+ }
108
+ }
109
+ return violations;
110
+ }
111
+
112
+ /**
113
+ * Calculate drift score from violations.
114
+ * @param {Array} violations - All violations across checked files
115
+ * @param {number} filesChecked - Number of files checked
116
+ * @param {number} rulesCount - Total rules applied
117
+ * @returns {{score: number, verdict: string}}
118
+ */
119
+ function calculateDriftScore(violations, filesChecked, rulesCount) {
120
+ if (filesChecked === 0 || rulesCount === 0) return { score: 0, verdict: 'clean' };
121
+ let weighted = 0;
122
+ for (const v of violations) {
123
+ weighted += DRIFT_SEVERITY_WEIGHTS[v.severity] || 0;
124
+ }
125
+ const ceiling = filesChecked * rulesCount * 0.3;
126
+ const score = Math.min(1.0, weighted / Math.max(ceiling, 1));
127
+ const rounded = Math.round(score * 100) / 100;
128
+ const verdict = DRIFT_VERDICTS.find(b => rounded <= b.max)?.verdict || 'high';
129
+ return { score: rounded, verdict };
130
+ }
131
+
132
+ /**
133
+ * Get list of changed files from git diff.
134
+ * @param {string} cwd - Working directory
135
+ * @param {string} [sinceRef] - Git ref to diff against (default: HEAD)
136
+ * @returns {string[]} Array of relative file paths
137
+ */
138
+ function getChangedFiles(cwd, sinceRef) {
139
+ const ref = sinceRef || 'HEAD';
140
+ // Try staged + unstaged first, then diff against ref
141
+ const result = execGit(cwd, ['diff', '--name-only', ref]);
142
+ if (result.exitCode !== 0) return [];
143
+ const stagedResult = execGit(cwd, ['diff', '--name-only', '--cached']);
144
+ const allFiles = new Set();
145
+ for (const line of result.stdout.split(/\r?\n/)) {
146
+ if (line.trim()) allFiles.add(line.trim());
147
+ }
148
+ if (stagedResult.exitCode === 0) {
149
+ for (const line of stagedResult.stdout.split(/\r?\n/)) {
150
+ if (line.trim()) allFiles.add(line.trim());
151
+ }
152
+ }
153
+ // Filter out binary extensions and limit
154
+ const filtered = [];
155
+ for (const f of allFiles) {
156
+ const ext = path.extname(f).toLowerCase();
157
+ if (BINARY_EXTENSIONS.has(ext)) continue;
158
+ filtered.push(f);
159
+ if (filtered.length >= DRIFT_MAX_FILES) break;
160
+ }
161
+ return filtered;
162
+ }
163
+
164
+ /**
165
+ * Run drift check on changed files against project conventions.
166
+ * @param {string} cwd - Working directory
167
+ * @param {boolean} raw - Raw output mode
168
+ * @param {string[]} args - CLI arguments
169
+ */
170
+ function cmdDriftCheck(cwd, raw, args) {
171
+ // Parse flags
172
+ let sinceRef = null;
173
+ let threshold = 0.5;
174
+ let specificFiles = null;
175
+ const verbose = process.env.PAN_VERBOSE === '1';
176
+ for (let i = 0; i < args.length; i++) {
177
+ if (args[i] === '--since' && args[i + 1]) { sinceRef = args[++i]; }
178
+ else if (args[i] === '--threshold' && args[i + 1]) {
179
+ const t = parseFloat(args[++i]);
180
+ if (isNaN(t) || t < 0 || t > 1) { error('threshold must be 0.0-1.0'); return; }
181
+ threshold = t;
182
+ }
183
+ else if (args[i] === '--files' && args[i + 1]) { specificFiles = args[++i].split(',').map(f => f.trim()); }
184
+ }
185
+
186
+ // Load convention rules
187
+ const conventionsPath = path.join(planningPath(cwd), 'codebase', 'CONVENTIONS.md');
188
+ const conventionsContent = safeReadFile(conventionsPath);
189
+ const claudeMdContent = safeReadFile(path.join(cwd, 'CLAUDE.md'));
190
+ const combined = [conventionsContent, claudeMdContent].filter(Boolean).join('\n');
191
+ const rules = parseConventionRules(combined || null);
192
+
193
+ // Get files to check
194
+ let files;
195
+ if (specificFiles) {
196
+ files = specificFiles;
197
+ } else {
198
+ files = getChangedFiles(cwd, sinceRef);
199
+ }
200
+
201
+ // Check each file
202
+ const allViolations = [];
203
+ let filesChecked = 0;
204
+ for (const filePath of files) {
205
+ const fullPath = path.join(cwd, filePath);
206
+ try {
207
+ const stat = fs.statSync(fullPath);
208
+ if (stat.size > DRIFT_MAX_FILE_SIZE) continue;
209
+ } catch { continue; }
210
+ const content = safeReadFile(fullPath);
211
+ if (!content) continue;
212
+ filesChecked++;
213
+ const violations = checkFileConventions(filePath, content, rules);
214
+ allViolations.push(...violations);
215
+ }
216
+
217
+ // Calculate score
218
+ const { score, verdict } = calculateDriftScore(allViolations, filesChecked, rules.length);
219
+ const passed = score <= threshold;
220
+ const summary = filesChecked === 0
221
+ ? 'no files changed'
222
+ : rules.length === 0
223
+ ? 'no conventions loaded'
224
+ : `drift: ${score} (${verdict}) — ${allViolations.length} violations in ${filesChecked} files`;
225
+
226
+ const result = {
227
+ drift_score: score,
228
+ verdict,
229
+ passed,
230
+ threshold,
231
+ violations: allViolations,
232
+ violation_count: allViolations.length,
233
+ files_checked: filesChecked,
234
+ conventions_loaded: rules.length,
235
+ summary,
236
+ };
237
+ if (verbose) {
238
+ const byFile = {};
239
+ for (const v of allViolations) {
240
+ if (!byFile[v.file]) byFile[v.file] = [];
241
+ byFile[v.file].push({ line: v.line, rule: v.rule, message: v.message, severity: v.severity });
242
+ }
243
+ result.per_file = byFile;
244
+ }
245
+ output(result, raw, summary);
246
+ }
247
+
248
+ module.exports = {
249
+ runDriftCheck,
250
+ parseConventionRules,
251
+ checkFileConventions,
252
+ calculateDriftScore,
253
+ getChangedFiles,
254
+ cmdDriftCheck,
255
+ };
@@ -0,0 +1,261 @@
1
+ /**
2
+ * Verify / Pre-execution gates — preflight checks and dependency-graph validation.
3
+ * Extracted from verify.cjs (IMPROVEMENT-TODO P2 module decomposition);
4
+ * verify.cjs re-exports everything here, so consumers are unaffected.
5
+ */
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const { safeReadFile, execGit, findPhaseInternal, output } = require('./core.cjs');
10
+ const { readStateSafe } = require('./state.cjs');
11
+ const {
12
+ STATE_FILE, ROADMAP_FILE, CONFIG_FILE, PATTERNS_FILE, PHASE_DIR_RE,
13
+ } = require('./constants.cjs');
14
+ const { planningPath, phasesPath, filterSummaryFiles, fileAccessible } = require('./utils.cjs');
15
+
16
+ /**
17
+ * Pre-flight validation: check execution prerequisites before starting work.
18
+ * Validates state consistency, git cleanliness, blockers, and error patterns.
19
+ * @param {string} cwd - Working directory path
20
+ * @param {string|null} target - Optional target (phase number or 'batch')
21
+ * @param {boolean} raw - If true, output raw value instead of JSON
22
+ * @returns {void}
23
+ */
24
+ function cmdPreflight(cwd, target, raw) {
25
+ const checks = [];
26
+ const blockers = [];
27
+
28
+ // Check 1: .planning/ directory exists
29
+ const planDir = planningPath(cwd);
30
+ if (fileAccessible(planDir)) {
31
+ checks.push({ name: 'planning_dir', passed: true });
32
+ } else {
33
+ checks.push({ name: 'planning_dir', passed: false, detail: '.planning/ directory not found' });
34
+ blockers.push('.planning/ directory not found — run /pan:new-project');
35
+ }
36
+
37
+ // Check 2: state.md is parseable
38
+ const statePath = path.join(planDir, STATE_FILE);
39
+ const stateContent = readStateSafe(statePath);
40
+ if (stateContent) {
41
+ checks.push({ name: 'state_readable', passed: true });
42
+ } else {
43
+ checks.push({ name: 'state_readable', passed: false, detail: 'state.md not found or unreadable' });
44
+ blockers.push('state.md not found — run /pan:new-project');
45
+ }
46
+
47
+ // Check 3: no unresolved blockers in state.md
48
+ if (stateContent) {
49
+ const blockersMatch = stateContent.match(/##\s*Blockers\s*\n([\s\S]*?)(?=\n##|$)/i);
50
+ const activeBlockers = [];
51
+ if (blockersMatch) {
52
+ const items = blockersMatch[1].match(/^-\s+(.+)$/gm) || [];
53
+ for (const item of items) {
54
+ const text = item.replace(/^-\s+/, '').trim();
55
+ if (text && !/^none$/i.test(text)) {
56
+ activeBlockers.push(text);
57
+ }
58
+ }
59
+ }
60
+ if (activeBlockers.length === 0) {
61
+ checks.push({ name: 'no_blockers', passed: true });
62
+ } else {
63
+ checks.push({ name: 'no_blockers', passed: false, detail: activeBlockers.length + ' active blocker(s)' });
64
+ for (const b of activeBlockers) blockers.push('Blocker: ' + b);
65
+ }
66
+ }
67
+
68
+ // Check 4: git working tree is clean
69
+ const gitResult = execGit(cwd, ['status', '--porcelain']);
70
+ if (gitResult.exitCode !== 0) {
71
+ checks.push({ name: 'git_clean', passed: true, detail: 'not a git repo or git unavailable' });
72
+ } else {
73
+ const dirty = gitResult.stdout.split('\n').filter(l => l.trim()).length;
74
+ if (dirty === 0) {
75
+ checks.push({ name: 'git_clean', passed: true });
76
+ } else {
77
+ checks.push({ name: 'git_clean', passed: false, detail: dirty + ' uncommitted change(s)' });
78
+ blockers.push(dirty + ' uncommitted changes — commit or stash before executing');
79
+ }
80
+ }
81
+
82
+ // Check 5: no known error patterns (check patterns.md exists and has entries)
83
+ const patternsPath = path.join(planDir, PATTERNS_FILE);
84
+ let patternCount = 0;
85
+ try {
86
+ const patternsContent = fs.readFileSync(patternsPath, 'utf-8');
87
+ const patternMatches = patternsContent.match(/^### PAT-\d+:/gm);
88
+ patternCount = patternMatches ? patternMatches.length : 0;
89
+ } catch { /* no patterns file — that's fine */ }
90
+ checks.push({ name: 'error_patterns', passed: true, detail: patternCount + ' known pattern(s)' });
91
+
92
+ // Check 6: config.json exists
93
+ const configPath = path.join(planDir, CONFIG_FILE);
94
+ if (fileAccessible(configPath)) {
95
+ checks.push({ name: 'config_exists', passed: true });
96
+ } else {
97
+ checks.push({ name: 'config_exists', passed: false, detail: 'config.json not found' });
98
+ }
99
+
100
+ // Check 7: target-specific checks
101
+ if (target && stateContent) {
102
+ const currentPhaseMatch = stateContent.match(/\*\*Current Phase:\*\*\s*(\S+)/);
103
+ const currentPhase = currentPhaseMatch ? currentPhaseMatch[1] : null;
104
+ if (target === 'batch') {
105
+ // Check that a batch file exists
106
+ const focusDir = path.join(planDir, 'focus');
107
+ try {
108
+ const files = fs.readdirSync(focusDir).filter(f => f.startsWith('batch-') && f.endsWith('.json'));
109
+ if (files.length > 0) {
110
+ checks.push({ name: 'batch_exists', passed: true, detail: files[files.length - 1] });
111
+ } else {
112
+ checks.push({ name: 'batch_exists', passed: false, detail: 'no batch file found' });
113
+ blockers.push('No batch file — run /pan:focus-plan first');
114
+ }
115
+ } catch {
116
+ checks.push({ name: 'batch_exists', passed: false, detail: 'focus/ directory not found' });
117
+ blockers.push('No focus/ directory — run /pan:focus-scan first');
118
+ }
119
+ } else {
120
+ // target is a phase number — check the phase directory exists
121
+ const phaseResult = findPhaseInternal(cwd, target);
122
+ if (phaseResult) {
123
+ checks.push({ name: 'target_phase', passed: true, detail: 'Phase ' + target + ' found' });
124
+ } else {
125
+ checks.push({ name: 'target_phase', passed: false, detail: 'Phase ' + target + ' not found' });
126
+ blockers.push('Phase ' + target + ' directory not found');
127
+ }
128
+ }
129
+ }
130
+
131
+ const ready = blockers.length === 0;
132
+
133
+ output({
134
+ ready,
135
+ target: target || null,
136
+ checks,
137
+ blockers,
138
+ passed: checks.filter(c => c.passed).length,
139
+ total: checks.length,
140
+ }, raw, ready ? 'ready' : 'blocked');
141
+ }
142
+
143
+ /**
144
+ * Dependency graph validation — cross-reference roadmap phases vs disk directories
145
+ * and requirements vs phase summaries to detect drift.
146
+ * @param {string} cwd - Working directory path
147
+ * @param {boolean} raw - If true, output raw value instead of JSON
148
+ * @returns {void}
149
+ */
150
+ function cmdDepsValidate(cwd, raw) {
151
+ const planDir = planningPath(cwd);
152
+ const issues = [];
153
+ const orphanedReqs = [];
154
+ const missingPhases = [];
155
+ const orphanedDirs = [];
156
+
157
+ // Step 1: Parse roadmap phases
158
+ const roadmapPath = path.join(planDir, ROADMAP_FILE);
159
+ const roadmapContent = safeReadFile(roadmapPath);
160
+ const roadmapPhases = new Map(); // number -> name
161
+ if (roadmapContent) {
162
+ const headerRe = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
163
+ let match;
164
+ while ((match = headerRe.exec(roadmapContent)) !== null) {
165
+ roadmapPhases.set(match[1], match[2].trim());
166
+ }
167
+ } else {
168
+ issues.push({ type: 'warning', message: 'roadmap.md not found' });
169
+ }
170
+
171
+ // Step 2: Scan disk phase directories
172
+ const diskPhases = new Map(); // number -> dirName
173
+ try {
174
+ const entries = fs.readdirSync(phasesPath(cwd), { withFileTypes: true });
175
+ for (const entry of entries) {
176
+ if (entry.isDirectory()) {
177
+ const dirMatch = entry.name.match(PHASE_DIR_RE);
178
+ if (dirMatch) {
179
+ diskPhases.set(dirMatch[1], entry.name);
180
+ }
181
+ }
182
+ }
183
+ } catch { /* phases dir missing */ }
184
+
185
+ // Step 3: Cross-reference roadmap vs disk
186
+ for (const [num, name] of roadmapPhases) {
187
+ if (!diskPhases.has(num)) {
188
+ missingPhases.push({ number: num, name, source: 'roadmap' });
189
+ issues.push({ type: 'error', message: 'Phase ' + num + ' (' + name + ') in roadmap but no directory on disk' });
190
+ }
191
+ }
192
+ for (const [num, dirName] of diskPhases) {
193
+ if (!roadmapPhases.has(num)) {
194
+ orphanedDirs.push({ number: num, directory: dirName });
195
+ issues.push({ type: 'warning', message: 'Directory ' + dirName + ' exists on disk but Phase ' + num + ' not found in roadmap' });
196
+ }
197
+ }
198
+
199
+ // Step 4: Parse requirements
200
+ const reqPath = path.join(planDir, 'requirements.md');
201
+ const reqContent = safeReadFile(reqPath);
202
+ const allReqIds = [];
203
+ const completedReqIds = new Set();
204
+ if (reqContent) {
205
+ // Find all REQ-NN patterns in checkbox lines
206
+ const reqLines = reqContent.match(/^-\s*\[[ x]\]\s*\*\*([A-Z]+-\d+)\*\*/gmi) || [];
207
+ for (const line of reqLines) {
208
+ const idMatch = line.match(/\*\*([A-Z]+-\d+)\*\*/i);
209
+ if (idMatch) {
210
+ allReqIds.push(idMatch[1]);
211
+ if (/\[x\]/i.test(line)) {
212
+ completedReqIds.add(idMatch[1]);
213
+ }
214
+ }
215
+ }
216
+ }
217
+
218
+ // Step 5: Check requirements traceability — find REQ IDs mentioned in summaries
219
+ const tracedReqIds = new Set();
220
+ for (const [, dirName] of diskPhases) {
221
+ try {
222
+ const files = fs.readdirSync(path.join(phasesPath(cwd), dirName));
223
+ for (const file of filterSummaryFiles(files)) {
224
+ const summaryContent = safeReadFile(path.join(phasesPath(cwd), dirName, file));
225
+ if (summaryContent) {
226
+ const mentions = summaryContent.match(/[A-Z]+-\d+/g) || [];
227
+ for (const id of mentions) {
228
+ if (allReqIds.includes(id)) tracedReqIds.add(id);
229
+ }
230
+ }
231
+ }
232
+ } catch { /* unreadable dir */ }
233
+ }
234
+
235
+ // Find requirements that are neither completed nor traced in any summary
236
+ for (const reqId of allReqIds) {
237
+ if (!completedReqIds.has(reqId) && !tracedReqIds.has(reqId)) {
238
+ orphanedReqs.push(reqId);
239
+ issues.push({ type: 'info', message: 'Requirement ' + reqId + ' not marked complete and not referenced in any summary' });
240
+ }
241
+ }
242
+
243
+ const valid = issues.filter(i => i.type === 'error').length === 0;
244
+
245
+ output({
246
+ valid,
247
+ issues,
248
+ roadmap_phases: roadmapPhases.size,
249
+ disk_phases: diskPhases.size,
250
+ requirements_total: allReqIds.length,
251
+ requirements_completed: completedReqIds.size,
252
+ orphaned_reqs: orphanedReqs,
253
+ missing_phases: missingPhases,
254
+ orphaned_dirs: orphanedDirs,
255
+ }, raw, valid ? 'valid' : 'issues found');
256
+ }
257
+
258
+ module.exports = {
259
+ cmdPreflight,
260
+ cmdDepsValidate,
261
+ };
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Verify / Retrospective analysis — milestone retro over historical .planning/ data.
3
+ * Extracted from verify.cjs (IMPROVEMENT-TODO P2 module decomposition);
4
+ * verify.cjs re-exports everything here, so consumers are unaffected.
5
+ */
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const { safeReadFile, output } = require('./core.cjs');
10
+ const { extractFrontmatter } = require('./frontmatter.cjs');
11
+ const { ROADMAP_FILE, isVerificationFile } = require('./constants.cjs');
12
+ const { planningPath, phasesPath } = require('./utils.cjs');
13
+
14
+ /**
15
+ * Scan verification files in a phases directory and collect stats.
16
+ * @param {string} phasesDir - Absolute path to phases directory
17
+ * @returns {{ total: number, passed: number, gaps_found: number, human_needed: number, gap_patterns: string[] }}
18
+ */
19
+ function collectVerificationStats(phasesDir) {
20
+ const stats = { total: 0, passed: 0, gaps_found: 0, human_needed: 0, gap_patterns: [] };
21
+ let dirs;
22
+ try { dirs = fs.readdirSync(phasesDir, { withFileTypes: true }); } catch { return stats; }
23
+ for (const d of dirs) {
24
+ if (!d.isDirectory()) continue;
25
+ const phaseDir = path.join(phasesDir, d.name);
26
+ let files;
27
+ try { files = fs.readdirSync(phaseDir); } catch { continue; }
28
+ for (const f of files) {
29
+ if (!isVerificationFile(f)) continue;
30
+ stats.total++;
31
+ const content = safeReadFile(path.join(phaseDir, f));
32
+ if (!content) continue;
33
+ const fm = extractFrontmatter(content);
34
+ const status = (fm.status || '').toLowerCase();
35
+ if (status === 'passed') stats.passed++;
36
+ else if (status === 'gaps_found') stats.gaps_found++;
37
+ else if (status === 'human_needed') stats.human_needed++;
38
+ // Extract gap descriptions from ## Gaps section
39
+ const gapsMatch = content.match(/## Gaps[\s\S]*?(?=\n## |$)/);
40
+ if (gapsMatch) {
41
+ const lines = gapsMatch[0].split('\n').filter(l => l.match(/^[-*]\s+/));
42
+ for (const line of lines) {
43
+ const desc = line.replace(/^[-*]\s+/, '').trim();
44
+ if (desc) stats.gap_patterns.push(desc);
45
+ }
46
+ }
47
+ }
48
+ }
49
+ return stats;
50
+ }
51
+
52
+ /**
53
+ * Count phases from roadmap: total planned, completed, and decimal (gap closure) phases.
54
+ * @param {string} roadmapContent - Roadmap file content
55
+ * @returns {{ planned: number, completed: number, decimal_phases: number }}
56
+ */
57
+ function countRoadmapPhases(roadmapContent) {
58
+ const result = { planned: 0, completed: 0, decimal_phases: 0 };
59
+ const checkboxRe = /- \[([ x])\]\s*(?:\*\*)?Phase\s+(\d+[A-Z]?(?:\.\d+)*)/gi;
60
+ let m;
61
+ while ((m = checkboxRe.exec(roadmapContent)) !== null) {
62
+ result.planned++;
63
+ if (m[1] === 'x') result.completed++;
64
+ if (m[2].includes('.')) result.decimal_phases++;
65
+ }
66
+ return result;
67
+ }
68
+
69
+ /**
70
+ * Group gap patterns by similarity (simple keyword grouping).
71
+ * @param {string[]} patterns - Raw gap descriptions
72
+ * @returns {Array<{pattern: string, count: number}>}
73
+ */
74
+ function groupGapPatterns(patterns) {
75
+ const groups = {};
76
+ for (const p of patterns) {
77
+ const key = p.toLowerCase().replace(/[^a-z0-9\s]/g, '').trim();
78
+ const words = key.split(/\s+/).slice(0, 3).join(' ');
79
+ groups[words] = (groups[words] || 0) + 1;
80
+ }
81
+ return Object.entries(groups)
82
+ .map(([pattern, count]) => ({ pattern, count }))
83
+ .sort((a, b) => b.count - a.count)
84
+ .slice(0, 10);
85
+ }
86
+
87
+ /**
88
+ * Milestone retrospective — analyze historical .planning/ data for process improvement.
89
+ * @param {string} cwd - Working directory
90
+ * @param {boolean} raw - Raw output flag
91
+ */
92
+ function cmdRetro(cwd, raw, args) {
93
+ const roadmapPath = path.join(planningPath(cwd), ROADMAP_FILE);
94
+ const roadmapContent = safeReadFile(roadmapPath);
95
+ if (!roadmapContent) {
96
+ return output({ error: 'roadmap.md not found' }, raw, 'roadmap.md not found');
97
+ }
98
+
99
+ const phases = countRoadmapPhases(roadmapContent);
100
+ const pDir = phasesPath(cwd);
101
+ const verification = collectVerificationStats(pDir);
102
+ const gapGroups = groupGapPatterns(verification.gap_patterns);
103
+
104
+ // Estimation accuracy: planned phases vs actual (including decimal gap closures)
105
+ const basePlanned = phases.planned - phases.decimal_phases;
106
+ const estimationAccuracy = basePlanned > 0
107
+ ? Math.round((basePlanned / phases.planned) * 100)
108
+ : 100;
109
+
110
+ const result = {
111
+ phases_planned: phases.planned,
112
+ phases_completed: phases.completed,
113
+ phases_decimal: phases.decimal_phases,
114
+ estimation_accuracy_pct: estimationAccuracy,
115
+ verifications_total: verification.total,
116
+ verifications_passed_first_try: verification.passed,
117
+ verifications_gaps_found: verification.gaps_found,
118
+ verifications_human_needed: verification.human_needed,
119
+ first_try_rate_pct: verification.total > 0
120
+ ? Math.round((verification.passed / verification.total) * 100)
121
+ : null,
122
+ common_gap_patterns: gapGroups,
123
+ };
124
+
125
+ // E-4: optional memory write. Top gap patterns become lessons for pan-planner
126
+ // (they surface what plans routinely miss). First-try rate deltas feed
127
+ // pan-verifier memory.
128
+ const argsList = Array.isArray(args) ? args : [];
129
+ if (argsList.includes('--write-memory')) {
130
+ const { appendMemory } = require('./memory.cjs');
131
+ const lessons_written = { 'pan-planner': 0, 'pan-verifier': 0 };
132
+ const maxIdx = argsList.indexOf('--max');
133
+ const maxLessons = maxIdx !== -1 && argsList[maxIdx + 1]
134
+ ? Math.max(1, Math.min(10, Number(argsList[maxIdx + 1]) || 3))
135
+ : 3;
136
+
137
+ // Top N gap patterns → planner memory as single-line lessons.
138
+ const top = gapGroups.slice(0, maxLessons);
139
+ for (const g of top) {
140
+ const lesson = `Recurring plan gap (${g.count}x across phases): "${g.pattern}" — factor into plan-checker inputs`;
141
+ const r = appendMemory(cwd, 'pan-planner', lesson);
142
+ if (r.appended) lessons_written['pan-planner'] += 1;
143
+ }
144
+
145
+ // Low first-try rate → verifier memory.
146
+ if (verification.total >= 3 && result.first_try_rate_pct != null && result.first_try_rate_pct < 60) {
147
+ const lesson = `First-try verification rate ${result.first_try_rate_pct}% over ${verification.total} runs — tighten verification criteria and pre-exec checks`;
148
+ const r = appendMemory(cwd, 'pan-verifier', lesson);
149
+ if (r.appended) lessons_written['pan-verifier'] += 1;
150
+ }
151
+
152
+ result.memory = { wrote: lessons_written, max: maxLessons };
153
+ }
154
+
155
+ const rawLines = [
156
+ `Phases: ${phases.completed}/${phases.planned} completed (${phases.decimal_phases} gap closures)`,
157
+ `Estimation accuracy: ${estimationAccuracy}%`,
158
+ `Verifications: ${verification.passed}/${verification.total} passed first try`,
159
+ `Gaps found: ${verification.gaps_found}, Human needed: ${verification.human_needed}`,
160
+ ];
161
+ if (gapGroups.length > 0) {
162
+ rawLines.push('Common gap patterns:');
163
+ for (const g of gapGroups) rawLines.push(` - ${g.pattern} (${g.count}x)`);
164
+ }
165
+ if (result.memory) {
166
+ rawLines.push(`Memory: wrote ${result.memory.wrote['pan-planner']} planner + ${result.memory.wrote['pan-verifier']} verifier lessons`);
167
+ }
168
+
169
+ output(result, raw, rawLines.join('\n'));
170
+ }
171
+
172
+ module.exports = {
173
+ collectVerificationStats,
174
+ countRoadmapPhases,
175
+ groupGapPatterns,
176
+ cmdRetro,
177
+ };