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.
- package/LICENSE +21 -0
- package/README.md +772 -0
- package/agents/pan-debugger.md +1246 -0
- package/agents/pan-document_code.md +965 -0
- package/agents/pan-executor.md +469 -0
- package/agents/pan-integration-checker.md +443 -0
- package/agents/pan-phase-researcher.md +572 -0
- package/agents/pan-plan-checker.md +763 -0
- package/agents/pan-planner.md +1297 -0
- package/agents/pan-project-researcher.md +647 -0
- package/agents/pan-research-synthesizer.md +239 -0
- package/agents/pan-reviewer.md +112 -0
- package/agents/pan-roadmapper.md +642 -0
- package/agents/pan-verifier.md +672 -0
- package/assets/pan-logo-2000-transparent.svg +30 -0
- package/assets/pan-logo-2000.svg +43 -0
- package/assets/terminal.svg +119 -0
- package/bin/install-lib.cjs +616 -0
- package/bin/install.js +1936 -0
- package/commands/pan/add-phase.md +44 -0
- package/commands/pan/assumptions.md +47 -0
- package/commands/pan/audit-deployment.md +378 -0
- package/commands/pan/debug.md +168 -0
- package/commands/pan/discord.md +19 -0
- package/commands/pan/discuss-phase.md +84 -0
- package/commands/pan/exec-phase.md +45 -0
- package/commands/pan/focus-auto.md +323 -0
- package/commands/pan/focus-design.md +816 -0
- package/commands/pan/focus-exec.md +316 -0
- package/commands/pan/focus-plan.md +101 -0
- package/commands/pan/focus-scan.md +272 -0
- package/commands/pan/focus-sync.md +104 -0
- package/commands/pan/health.md +23 -0
- package/commands/pan/help.md +23 -0
- package/commands/pan/insert-phase.md +33 -0
- package/commands/pan/map-codebase.md +72 -0
- package/commands/pan/milestone-audit.md +37 -0
- package/commands/pan/milestone-cleanup.md +19 -0
- package/commands/pan/milestone-done.md +137 -0
- package/commands/pan/milestone-gaps.md +35 -0
- package/commands/pan/milestone-new.md +45 -0
- package/commands/pan/new-project.md +43 -0
- package/commands/pan/patches.md +110 -0
- package/commands/pan/pause.md +39 -0
- package/commands/pan/phase-budget.md +23 -0
- package/commands/pan/phase-tests.md +42 -0
- package/commands/pan/plan-phase.md +46 -0
- package/commands/pan/profile.md +36 -0
- package/commands/pan/progress.md +25 -0
- package/commands/pan/quick.md +42 -0
- package/commands/pan/remove-phase.md +32 -0
- package/commands/pan/research-phase.md +190 -0
- package/commands/pan/resume.md +41 -0
- package/commands/pan/retro.md +33 -0
- package/commands/pan/settings.md +37 -0
- package/commands/pan/todo-add.md +48 -0
- package/commands/pan/todo-check.md +46 -0
- package/commands/pan/update.md +38 -0
- package/commands/pan/verify-phase.md +39 -0
- package/hooks/dist/pan-check-update.js +62 -0
- package/hooks/dist/pan-context-monitor.js +122 -0
- package/hooks/dist/pan-statusline.js +108 -0
- package/package.json +66 -0
- package/pan-wizard-core/bin/lib/codebase.cjs +746 -0
- package/pan-wizard-core/bin/lib/commands.cjs +1435 -0
- package/pan-wizard-core/bin/lib/config.cjs +611 -0
- package/pan-wizard-core/bin/lib/constants.cjs +696 -0
- package/pan-wizard-core/bin/lib/context-budget.cjs +150 -0
- package/pan-wizard-core/bin/lib/core.cjs +650 -0
- package/pan-wizard-core/bin/lib/focus.cjs +900 -0
- package/pan-wizard-core/bin/lib/frontmatter.cjs +442 -0
- package/pan-wizard-core/bin/lib/init.cjs +881 -0
- package/pan-wizard-core/bin/lib/milestone.cjs +276 -0
- package/pan-wizard-core/bin/lib/phase.cjs +1212 -0
- package/pan-wizard-core/bin/lib/roadmap.cjs +470 -0
- package/pan-wizard-core/bin/lib/state.cjs +1029 -0
- package/pan-wizard-core/bin/lib/template.cjs +314 -0
- package/pan-wizard-core/bin/lib/utils.cjs +171 -0
- package/pan-wizard-core/bin/lib/verify.cjs +1808 -0
- package/pan-wizard-core/bin/pan-tools.cjs +773 -0
- package/pan-wizard-core/references/checkpoints.md +776 -0
- package/pan-wizard-core/references/continuation-format.md +249 -0
- package/pan-wizard-core/references/decimal-phase-calculation.md +65 -0
- package/pan-wizard-core/references/git-integration.md +248 -0
- package/pan-wizard-core/references/git-planning-commit.md +38 -0
- package/pan-wizard-core/references/model-profile-resolution.md +34 -0
- package/pan-wizard-core/references/model-profiles.md +111 -0
- package/pan-wizard-core/references/phase-argument-parsing.md +61 -0
- package/pan-wizard-core/references/planning-config.md +196 -0
- package/pan-wizard-core/references/questioning.md +145 -0
- package/pan-wizard-core/references/tdd.md +263 -0
- package/pan-wizard-core/references/ui-brand.md +160 -0
- package/pan-wizard-core/references/verification-patterns.md +612 -0
- package/pan-wizard-core/templates/codebase/architecture.md +283 -0
- package/pan-wizard-core/templates/codebase/best-practices.md +133 -0
- package/pan-wizard-core/templates/codebase/concerns.md +325 -0
- package/pan-wizard-core/templates/codebase/conventions.md +307 -0
- package/pan-wizard-core/templates/codebase/integrations.md +305 -0
- package/pan-wizard-core/templates/codebase/relationships.md +124 -0
- package/pan-wizard-core/templates/codebase/stack.md +199 -0
- package/pan-wizard-core/templates/codebase/structure.md +298 -0
- package/pan-wizard-core/templates/codebase/testing.md +480 -0
- package/pan-wizard-core/templates/config.json +37 -0
- package/pan-wizard-core/templates/context.md +283 -0
- package/pan-wizard-core/templates/continue-here.md +78 -0
- package/pan-wizard-core/templates/debug-subagent-prompt.md +91 -0
- package/pan-wizard-core/templates/debug.md +164 -0
- package/pan-wizard-core/templates/discovery.md +146 -0
- package/pan-wizard-core/templates/milestone-archive.md +123 -0
- package/pan-wizard-core/templates/milestone.md +115 -0
- package/pan-wizard-core/templates/phase-prompt.md +593 -0
- package/pan-wizard-core/templates/planner-subagent-prompt.md +117 -0
- package/pan-wizard-core/templates/project.md +184 -0
- package/pan-wizard-core/templates/requirements.md +231 -0
- package/pan-wizard-core/templates/research-project/architecture.md +204 -0
- package/pan-wizard-core/templates/research-project/features.md +147 -0
- package/pan-wizard-core/templates/research-project/pitfalls.md +200 -0
- package/pan-wizard-core/templates/research-project/stack.md +120 -0
- package/pan-wizard-core/templates/research-project/summary.md +170 -0
- package/pan-wizard-core/templates/research.md +552 -0
- package/pan-wizard-core/templates/retrospective.md +54 -0
- package/pan-wizard-core/templates/roadmap.md +202 -0
- package/pan-wizard-core/templates/standards.md +24 -0
- package/pan-wizard-core/templates/state.md +176 -0
- package/pan-wizard-core/templates/summary-complex.md +59 -0
- package/pan-wizard-core/templates/summary-minimal.md +41 -0
- package/pan-wizard-core/templates/summary-standard.md +49 -0
- package/pan-wizard-core/templates/summary.md +249 -0
- package/pan-wizard-core/templates/uat.md +247 -0
- package/pan-wizard-core/templates/user-setup.md +311 -0
- package/pan-wizard-core/templates/validation.md +76 -0
- package/pan-wizard-core/templates/verification-report.md +322 -0
- package/pan-wizard-core/workflows/add-phase.md +111 -0
- package/pan-wizard-core/workflows/assumptions.md +178 -0
- package/pan-wizard-core/workflows/diagnose-issues.md +219 -0
- package/pan-wizard-core/workflows/discuss-phase.md +542 -0
- package/pan-wizard-core/workflows/exec-phase.md +572 -0
- package/pan-wizard-core/workflows/execute-plan.md +448 -0
- package/pan-wizard-core/workflows/health.md +156 -0
- package/pan-wizard-core/workflows/help.md +431 -0
- package/pan-wizard-core/workflows/insert-phase.md +129 -0
- package/pan-wizard-core/workflows/map-codebase.md +401 -0
- package/pan-wizard-core/workflows/milestone-audit.md +297 -0
- package/pan-wizard-core/workflows/milestone-cleanup.md +152 -0
- package/pan-wizard-core/workflows/milestone-gaps.md +274 -0
- package/pan-wizard-core/workflows/milestone-new.md +382 -0
- package/pan-wizard-core/workflows/new-project.md +1178 -0
- package/pan-wizard-core/workflows/pause.md +122 -0
- package/pan-wizard-core/workflows/phase-tests.md +388 -0
- package/pan-wizard-core/workflows/plan-phase.md +569 -0
- package/pan-wizard-core/workflows/profile.md +115 -0
- package/pan-wizard-core/workflows/progress.md +381 -0
- package/pan-wizard-core/workflows/quick.md +453 -0
- package/pan-wizard-core/workflows/remove-phase.md +154 -0
- package/pan-wizard-core/workflows/research-phase.md +73 -0
- package/pan-wizard-core/workflows/resume-project.md +306 -0
- package/pan-wizard-core/workflows/retro.md +121 -0
- package/pan-wizard-core/workflows/settings.md +213 -0
- package/pan-wizard-core/workflows/todo-add.md +157 -0
- package/pan-wizard-core/workflows/todo-check.md +176 -0
- package/pan-wizard-core/workflows/transition.md +544 -0
- package/pan-wizard-core/workflows/update.md +219 -0
- package/pan-wizard-core/workflows/verify-phase.md +301 -0
- 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
|
+
};
|