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