pan-wizard 3.8.0 → 3.12.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 +80 -9
- package/agents/pan-conductor.md +15 -3
- 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-release.md +58 -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/assets/pan-avatar.png +0 -0
- package/assets/pan-developer.png +0 -0
- package/assets/pan-docs-header.png +0 -0
- package/assets/pan-hero.png +0 -0
- package/assets/pan-logo-2000-transparent.svg +11 -30
- package/assets/pan-logo-2000.svg +12 -43
- package/assets/pan-logo-lockup.svg +11 -0
- package/assets/pan-mark.svg +7 -0
- package/assets/pan-orchestration.png +0 -0
- package/assets/pan-readme-hero.png +0 -0
- package/assets/terminal.svg +39 -119
- package/bin/install-lib.cjs +661 -46
- package/bin/install.js +722 -116
- package/commands/pan/army.md +169 -0
- package/commands/pan/dashboard.md +25 -0
- package/commands/pan/experiment.md +2 -0
- package/commands/pan/focus-auto.md +32 -4
- package/commands/pan/hud.md +91 -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/campaign.cjs +198 -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/constants.cjs +8 -0
- package/pan-wizard-core/bin/lib/core.cjs +80 -0
- package/pan-wizard-core/bin/lib/cost.cjs +62 -8
- package/pan-wizard-core/bin/lib/focus.cjs +13 -1
- package/pan-wizard-core/bin/lib/git.cjs +6 -1
- package/pan-wizard-core/bin/lib/hud.cjs +887 -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 +5 -0
- package/pan-wizard-core/bin/lib/squads.cjs +152 -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 +10 -797
- package/pan-wizard-core/bin/lib/worktree.cjs +123 -0
- package/pan-wizard-core/bin/pan-tools.cjs +78 -0
- package/pan-wizard-core/learnings/universal/autonomous-loop.md +56 -0
- package/pan-wizard-core/workflows/plan-phase.md +11 -0
- package/scripts/build-plugin.js +105 -0
- package/scripts/install-git-hooks.js +64 -0
- package/scripts/release-check.js +13 -2
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verify / Deployment validation — manifest + settings integrity per runtime.
|
|
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 { output } = require('./core.cjs');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Detect which PAN runtimes are installed in cwd.
|
|
13
|
+
* @param {string} cwd
|
|
14
|
+
* @returns {Array<{runtime: string, configDir: string}>}
|
|
15
|
+
*/
|
|
16
|
+
function detectInstalledRuntimes(cwd) {
|
|
17
|
+
const RUNTIME_DIRS = [
|
|
18
|
+
{ runtime: 'claude', configDir: '.claude' },
|
|
19
|
+
{ runtime: 'opencode', configDir: '.opencode' },
|
|
20
|
+
{ runtime: 'gemini', configDir: '.gemini' },
|
|
21
|
+
{ runtime: 'codex', configDir: '.codex' },
|
|
22
|
+
{ runtime: 'copilot', configDir: '.github' },
|
|
23
|
+
];
|
|
24
|
+
const found = [];
|
|
25
|
+
for (const rt of RUNTIME_DIRS) {
|
|
26
|
+
const manifestPath = path.join(cwd, rt.configDir, 'pan-file-manifest.json');
|
|
27
|
+
try {
|
|
28
|
+
fs.accessSync(manifestPath);
|
|
29
|
+
found.push(rt);
|
|
30
|
+
} catch (_) { /* not installed */ }
|
|
31
|
+
}
|
|
32
|
+
return found;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Validate a single PAN runtime installation.
|
|
37
|
+
* Checks: manifest files exist, hashes match, settings integrity.
|
|
38
|
+
* @param {string} cwd
|
|
39
|
+
* @param {string} configDir - e.g. '.claude'
|
|
40
|
+
* @param {string} runtime - e.g. 'claude'
|
|
41
|
+
* @returns {{ status: string, version: string, total_files: number, missing: string[], modified: string[], orphaned: string[], settings_ok: boolean, settings_issues: string[] }}
|
|
42
|
+
*/
|
|
43
|
+
function validateRuntimeInstall(cwd, configDir, runtime) {
|
|
44
|
+
const crypto = require('crypto');
|
|
45
|
+
const baseDir = path.join(cwd, configDir);
|
|
46
|
+
const manifestPath = path.join(baseDir, 'pan-file-manifest.json');
|
|
47
|
+
|
|
48
|
+
let manifest;
|
|
49
|
+
try {
|
|
50
|
+
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
51
|
+
} catch (e) {
|
|
52
|
+
return { status: 'broken', version: null, error: `Cannot read manifest: ${e.message}`, total_files: 0, missing: [], modified: [], orphaned: [], settings_ok: false, settings_issues: ['manifest unreadable'] };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const missing = [];
|
|
56
|
+
const modified = [];
|
|
57
|
+
const files = manifest.files || {};
|
|
58
|
+
const totalFiles = Object.keys(files).length;
|
|
59
|
+
|
|
60
|
+
for (const [relPath, expectedHash] of Object.entries(files)) {
|
|
61
|
+
const absPath = path.join(baseDir, relPath);
|
|
62
|
+
try {
|
|
63
|
+
const content = fs.readFileSync(absPath);
|
|
64
|
+
const actualHash = crypto.createHash('sha256').update(content).digest('hex');
|
|
65
|
+
if (actualHash !== expectedHash) {
|
|
66
|
+
modified.push(relPath);
|
|
67
|
+
}
|
|
68
|
+
} catch (_) {
|
|
69
|
+
missing.push(relPath);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Check settings integrity (hook paths resolve to real files).
|
|
74
|
+
// Copilot's user-editable settings moved to .github/copilot/settings.json
|
|
75
|
+
// (2026-06; .github/config.json was never a Copilot read path) and the file
|
|
76
|
+
// is optional — hooks live in .github/hooks/pan.json, so absence is fine.
|
|
77
|
+
const settingsIssues = [];
|
|
78
|
+
const settingsPath = runtime === 'copilot'
|
|
79
|
+
? path.join(baseDir, 'copilot', 'settings.json')
|
|
80
|
+
: path.join(baseDir, 'settings.json');
|
|
81
|
+
const settingsOptional = runtime === 'codex' || runtime === 'opencode' || runtime === 'copilot';
|
|
82
|
+
let settingsOk = true;
|
|
83
|
+
try {
|
|
84
|
+
const settingsContent = fs.readFileSync(settingsPath, 'utf8');
|
|
85
|
+
const settings = JSON.parse(settingsContent);
|
|
86
|
+
// Check hook paths in settings
|
|
87
|
+
// Collect all hook command strings from settings
|
|
88
|
+
const hookCommands = [];
|
|
89
|
+
const hooks = settings.hooks;
|
|
90
|
+
if (hooks && typeof hooks === 'object') {
|
|
91
|
+
for (const hookArr of Object.values(hooks)) {
|
|
92
|
+
if (!Array.isArray(hookArr)) continue;
|
|
93
|
+
for (const hook of hookArr) {
|
|
94
|
+
if (hook.command) hookCommands.push(hook.command);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// Copilot/Gemini statusLine
|
|
99
|
+
if (settings.statusLine && settings.statusLine.command) {
|
|
100
|
+
hookCommands.push(settings.statusLine.command);
|
|
101
|
+
}
|
|
102
|
+
// Claude statusline
|
|
103
|
+
if (settings.statusline && settings.statusline.command) {
|
|
104
|
+
hookCommands.push(settings.statusline.command);
|
|
105
|
+
}
|
|
106
|
+
for (const cmd of hookCommands) {
|
|
107
|
+
const parts = cmd.split(/\s+/);
|
|
108
|
+
const hookFile = parts.find(p => p.endsWith('.js'));
|
|
109
|
+
if (hookFile) {
|
|
110
|
+
// Hook paths are relative to cwd, not to config dir
|
|
111
|
+
const resolvedPath = path.isAbsolute(hookFile) ? hookFile : path.join(cwd, hookFile);
|
|
112
|
+
try { fs.accessSync(resolvedPath); } catch (_) {
|
|
113
|
+
settingsIssues.push(`Hook path not found: ${hookFile}`);
|
|
114
|
+
settingsOk = false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
} catch (_) {
|
|
119
|
+
// No settings file is OK for runtimes where settings are optional
|
|
120
|
+
if (!settingsOptional) {
|
|
121
|
+
settingsIssues.push(`${path.basename(settingsPath)} missing or unreadable`);
|
|
122
|
+
settingsOk = false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const status = missing.length > 0 ? 'broken' : modified.length > 0 ? 'modified' : 'clean';
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
status,
|
|
130
|
+
version: manifest.version || null,
|
|
131
|
+
total_files: totalFiles,
|
|
132
|
+
missing,
|
|
133
|
+
modified,
|
|
134
|
+
orphaned: [],
|
|
135
|
+
settings_ok: settingsOk,
|
|
136
|
+
settings_issues: settingsIssues,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* CLI command: validate deployment
|
|
142
|
+
* Validates PAN installations in the current directory.
|
|
143
|
+
* @param {string} cwd
|
|
144
|
+
* @param {boolean} raw
|
|
145
|
+
*/
|
|
146
|
+
function cmdValidateDeployment(cwd, raw) {
|
|
147
|
+
const runtimes = detectInstalledRuntimes(cwd);
|
|
148
|
+
if (runtimes.length === 0) {
|
|
149
|
+
output({ error: 'No PAN installations found in this directory' }, raw);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const results = {};
|
|
154
|
+
let overallStatus = 'clean';
|
|
155
|
+
|
|
156
|
+
for (const { runtime, configDir } of runtimes) {
|
|
157
|
+
const result = validateRuntimeInstall(cwd, configDir, runtime);
|
|
158
|
+
results[runtime] = result;
|
|
159
|
+
if (result.status === 'broken') overallStatus = 'broken';
|
|
160
|
+
else if (result.status === 'modified' && overallStatus !== 'broken') overallStatus = 'modified';
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const summary = {
|
|
164
|
+
status: overallStatus,
|
|
165
|
+
runtimes_found: runtimes.length,
|
|
166
|
+
runtimes: results,
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const rawLines = [`Deployment status: ${overallStatus} (${runtimes.length} runtimes)`];
|
|
170
|
+
for (const [rt, r] of Object.entries(results)) {
|
|
171
|
+
rawLines.push(` ${rt}: ${r.status} (${r.total_files} files, ${r.missing.length} missing, ${r.modified.length} modified)`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
output(summary, raw, rawLines.join('\n'));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
module.exports = {
|
|
178
|
+
detectInstalledRuntimes,
|
|
179
|
+
validateRuntimeInstall,
|
|
180
|
+
cmdValidateDeployment,
|
|
181
|
+
};
|
|
@@ -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
|
+
};
|