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.
Files changed (54) hide show
  1. package/README.md +24 -2
  2. package/agents/pan-conductor.md +1 -2
  3. package/agents/pan-counterfactual.md +1 -2
  4. package/agents/pan-debugger.md +1 -2
  5. package/agents/pan-distiller.md +1 -2
  6. package/agents/pan-document_code.md +1 -0
  7. package/agents/pan-executor.md +1 -0
  8. package/agents/pan-experiment-runner.md +1 -2
  9. package/agents/pan-hardener.md +1 -2
  10. package/agents/pan-integration-checker.md +1 -2
  11. package/agents/pan-knowledge.md +1 -2
  12. package/agents/pan-meta-reviewer.md +1 -2
  13. package/agents/pan-optimizer.md +1 -0
  14. package/agents/pan-phase-researcher.md +1 -0
  15. package/agents/pan-plan-checker.md +1 -2
  16. package/agents/pan-planner.md +1 -0
  17. package/agents/pan-previewer.md +1 -2
  18. package/agents/pan-project-researcher.md +6 -0
  19. package/agents/pan-research-synthesizer.md +7 -0
  20. package/agents/pan-reviewer.md +2 -3
  21. package/agents/pan-roadmapper.md +1 -0
  22. package/agents/pan-verifier.md +1 -2
  23. package/bin/install-lib.cjs +661 -46
  24. package/bin/install.js +722 -116
  25. package/commands/pan/experiment.md +2 -0
  26. package/commands/pan/links.md +102 -0
  27. package/commands/pan/profile.md +2 -0
  28. package/hooks/dist/pan-cost-logger.js +22 -7
  29. package/package.json +5 -4
  30. package/pan-wizard-core/bin/lib/codebase.cjs +2 -0
  31. package/pan-wizard-core/bin/lib/commands-learnings.cjs +544 -0
  32. package/pan-wizard-core/bin/lib/commands.cjs +12 -523
  33. package/pan-wizard-core/bin/lib/core.cjs +69 -0
  34. package/pan-wizard-core/bin/lib/cost.cjs +62 -8
  35. package/pan-wizard-core/bin/lib/experiment.cjs +1 -0
  36. package/pan-wizard-core/bin/lib/git.cjs +6 -1
  37. package/pan-wizard-core/bin/lib/links.cjs +549 -0
  38. package/pan-wizard-core/bin/lib/lock.cjs +108 -0
  39. package/pan-wizard-core/bin/lib/milestone.cjs +3 -2
  40. package/pan-wizard-core/bin/lib/phase-remove.cjs +392 -0
  41. package/pan-wizard-core/bin/lib/phase.cjs +4 -369
  42. package/pan-wizard-core/bin/lib/runner.cjs +6 -0
  43. package/pan-wizard-core/bin/lib/state.cjs +10 -1
  44. package/pan-wizard-core/bin/lib/verify-deploy.cjs +181 -0
  45. package/pan-wizard-core/bin/lib/verify-drift.cjs +255 -0
  46. package/pan-wizard-core/bin/lib/verify-preflight.cjs +261 -0
  47. package/pan-wizard-core/bin/lib/verify-retro.cjs +177 -0
  48. package/pan-wizard-core/bin/lib/verify.cjs +33 -797
  49. package/pan-wizard-core/bin/pan-tools.cjs +35 -1
  50. package/pan-wizard-core/workflows/plan-phase.md +11 -0
  51. package/scripts/build-plugin.js +105 -0
  52. package/scripts/git-hooks/pre-commit +40 -0
  53. package/scripts/install-git-hooks.js +64 -0
  54. package/scripts/release-check.js +13 -2
@@ -15,6 +15,12 @@ const {
15
15
  BUILTIN_DRIFT_RULES, DRIFT_VERDICTS, BINARY_EXTENSIONS, DRIFT_MAX_FILES, DRIFT_MAX_FILE_SIZE, DRIFT_SEVERITY_WEIGHTS,
16
16
  } = require('./constants.cjs');
17
17
  const { planningPath, phasesPath, filterPlanFiles, filterSummaryFiles, fileAccessible } = require('./utils.cjs');
18
+ // Drift detection lives in verify-drift.cjs; re-exported below so consumers of
19
+ // verify.cjs are unaffected by the decomposition.
20
+ const { runDriftCheck, parseConventionRules, checkFileConventions, calculateDriftScore, getChangedFiles, cmdDriftCheck } = require('./verify-drift.cjs');
21
+ const { collectVerificationStats, countRoadmapPhases, groupGapPatterns, cmdRetro } = require('./verify-retro.cjs');
22
+ const { detectInstalledRuntimes, validateRuntimeInstall, cmdValidateDeployment } = require('./verify-deploy.cjs');
23
+ const { cmdPreflight, cmdDepsValidate } = require('./verify-preflight.cjs');
18
24
 
19
25
  /**
20
26
  * Spot-check files mentioned in summary content.
@@ -1215,6 +1221,26 @@ function cmdValidateHealth(cwd, options, raw) {
1215
1221
  }
1216
1222
  }
1217
1223
 
1224
+ // Check 12 (optional): doc-code link graph (ADR-0027)
1225
+ let linkGraphResult;
1226
+ if (options.links) {
1227
+ const links = require('./links.cjs');
1228
+ const r = links.validateAll(cwd);
1229
+ linkGraphResult = {
1230
+ status: r.summary.status,
1231
+ errors: r.summary.errors,
1232
+ warnings: r.summary.warnings,
1233
+ doc_files_scanned: r.summary.doc_files_scanned,
1234
+ source_files_scanned: r.summary.source_files_scanned,
1235
+ anchors_found: r.summary.anchors_found,
1236
+ forward_links_found: r.summary.forward_links_found,
1237
+ backlink_contracts_checked: r.summary.backlink_contracts_checked,
1238
+ };
1239
+ if (r.summary.errors > 0) {
1240
+ addIssue('warning', 'LINKS_ERR', `Link graph has ${r.summary.errors} errors (broken refs or uncovered backlink contracts)`, 'Run pan-tools links validate for details');
1241
+ }
1242
+ }
1243
+
1218
1244
  const result = {
1219
1245
  status,
1220
1246
  errors,
@@ -1230,6 +1256,9 @@ function cmdValidateHealth(cwd, options, raw) {
1230
1256
  if (options.drift) {
1231
1257
  result.drift_status = driftResult;
1232
1258
  }
1259
+ if (options.links) {
1260
+ result.link_graph = linkGraphResult;
1261
+ }
1233
1262
 
1234
1263
  output(result, raw);
1235
1264
  }
@@ -1290,805 +1319,12 @@ function runFullBuildCheck(cwd) {
1290
1319
  }
1291
1320
  }
1292
1321
 
1293
- /**
1294
- * Pre-flight validation: check execution prerequisites before starting work.
1295
- * Validates state consistency, git cleanliness, blockers, and error patterns.
1296
- * @param {string} cwd - Working directory path
1297
- * @param {string|null} target - Optional target (phase number or 'batch')
1298
- * @param {boolean} raw - If true, output raw value instead of JSON
1299
- * @returns {void}
1300
- */
1301
- function cmdPreflight(cwd, target, raw) {
1302
- const checks = [];
1303
- const blockers = [];
1304
-
1305
- // Check 1: .planning/ directory exists
1306
- const planDir = planningPath(cwd);
1307
- if (fileAccessible(planDir)) {
1308
- checks.push({ name: 'planning_dir', passed: true });
1309
- } else {
1310
- checks.push({ name: 'planning_dir', passed: false, detail: '.planning/ directory not found' });
1311
- blockers.push('.planning/ directory not found — run /pan:new-project');
1312
- }
1313
-
1314
- // Check 2: state.md is parseable
1315
- const statePath = path.join(planDir, STATE_FILE);
1316
- const stateContent = readStateSafe(statePath);
1317
- if (stateContent) {
1318
- checks.push({ name: 'state_readable', passed: true });
1319
- } else {
1320
- checks.push({ name: 'state_readable', passed: false, detail: 'state.md not found or unreadable' });
1321
- blockers.push('state.md not found — run /pan:new-project');
1322
- }
1323
-
1324
- // Check 3: no unresolved blockers in state.md
1325
- if (stateContent) {
1326
- const blockersMatch = stateContent.match(/##\s*Blockers\s*\n([\s\S]*?)(?=\n##|$)/i);
1327
- const activeBlockers = [];
1328
- if (blockersMatch) {
1329
- const items = blockersMatch[1].match(/^-\s+(.+)$/gm) || [];
1330
- for (const item of items) {
1331
- const text = item.replace(/^-\s+/, '').trim();
1332
- if (text && !/^none$/i.test(text)) {
1333
- activeBlockers.push(text);
1334
- }
1335
- }
1336
- }
1337
- if (activeBlockers.length === 0) {
1338
- checks.push({ name: 'no_blockers', passed: true });
1339
- } else {
1340
- checks.push({ name: 'no_blockers', passed: false, detail: activeBlockers.length + ' active blocker(s)' });
1341
- for (const b of activeBlockers) blockers.push('Blocker: ' + b);
1342
- }
1343
- }
1344
-
1345
- // Check 4: git working tree is clean
1346
- const gitResult = execGit(cwd, ['status', '--porcelain']);
1347
- if (gitResult.exitCode !== 0) {
1348
- checks.push({ name: 'git_clean', passed: true, detail: 'not a git repo or git unavailable' });
1349
- } else {
1350
- const dirty = gitResult.stdout.split('\n').filter(l => l.trim()).length;
1351
- if (dirty === 0) {
1352
- checks.push({ name: 'git_clean', passed: true });
1353
- } else {
1354
- checks.push({ name: 'git_clean', passed: false, detail: dirty + ' uncommitted change(s)' });
1355
- blockers.push(dirty + ' uncommitted changes — commit or stash before executing');
1356
- }
1357
- }
1358
-
1359
- // Check 5: no known error patterns (check patterns.md exists and has entries)
1360
- const patternsPath = path.join(planDir, PATTERNS_FILE);
1361
- let patternCount = 0;
1362
- try {
1363
- const patternsContent = fs.readFileSync(patternsPath, 'utf-8');
1364
- const patternMatches = patternsContent.match(/^### PAT-\d+:/gm);
1365
- patternCount = patternMatches ? patternMatches.length : 0;
1366
- } catch { /* no patterns file — that's fine */ }
1367
- checks.push({ name: 'error_patterns', passed: true, detail: patternCount + ' known pattern(s)' });
1368
-
1369
- // Check 6: config.json exists
1370
- const configPath = path.join(planDir, CONFIG_FILE);
1371
- if (fileAccessible(configPath)) {
1372
- checks.push({ name: 'config_exists', passed: true });
1373
- } else {
1374
- checks.push({ name: 'config_exists', passed: false, detail: 'config.json not found' });
1375
- }
1376
-
1377
- // Check 7: target-specific checks
1378
- if (target && stateContent) {
1379
- const currentPhaseMatch = stateContent.match(/\*\*Current Phase:\*\*\s*(\S+)/);
1380
- const currentPhase = currentPhaseMatch ? currentPhaseMatch[1] : null;
1381
- if (target === 'batch') {
1382
- // Check that a batch file exists
1383
- const focusDir = path.join(planDir, 'focus');
1384
- try {
1385
- const files = fs.readdirSync(focusDir).filter(f => f.startsWith('batch-') && f.endsWith('.json'));
1386
- if (files.length > 0) {
1387
- checks.push({ name: 'batch_exists', passed: true, detail: files[files.length - 1] });
1388
- } else {
1389
- checks.push({ name: 'batch_exists', passed: false, detail: 'no batch file found' });
1390
- blockers.push('No batch file — run /pan:focus-plan first');
1391
- }
1392
- } catch {
1393
- checks.push({ name: 'batch_exists', passed: false, detail: 'focus/ directory not found' });
1394
- blockers.push('No focus/ directory — run /pan:focus-scan first');
1395
- }
1396
- } else {
1397
- // target is a phase number — check the phase directory exists
1398
- const phaseResult = findPhaseInternal(cwd, target);
1399
- if (phaseResult) {
1400
- checks.push({ name: 'target_phase', passed: true, detail: 'Phase ' + target + ' found' });
1401
- } else {
1402
- checks.push({ name: 'target_phase', passed: false, detail: 'Phase ' + target + ' not found' });
1403
- blockers.push('Phase ' + target + ' directory not found');
1404
- }
1405
- }
1406
- }
1407
-
1408
- const ready = blockers.length === 0;
1409
-
1410
- output({
1411
- ready,
1412
- target: target || null,
1413
- checks,
1414
- blockers,
1415
- passed: checks.filter(c => c.passed).length,
1416
- total: checks.length,
1417
- }, raw, ready ? 'ready' : 'blocked');
1418
- }
1419
-
1420
- /**
1421
- * Dependency graph validation — cross-reference roadmap phases vs disk directories
1422
- * and requirements vs phase summaries to detect drift.
1423
- * @param {string} cwd - Working directory path
1424
- * @param {boolean} raw - If true, output raw value instead of JSON
1425
- * @returns {void}
1426
- */
1427
- function cmdDepsValidate(cwd, raw) {
1428
- const planDir = planningPath(cwd);
1429
- const issues = [];
1430
- const orphanedReqs = [];
1431
- const missingPhases = [];
1432
- const orphanedDirs = [];
1433
-
1434
- // Step 1: Parse roadmap phases
1435
- const roadmapPath = path.join(planDir, ROADMAP_FILE);
1436
- const roadmapContent = safeReadFile(roadmapPath);
1437
- const roadmapPhases = new Map(); // number -> name
1438
- if (roadmapContent) {
1439
- const headerRe = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
1440
- let match;
1441
- while ((match = headerRe.exec(roadmapContent)) !== null) {
1442
- roadmapPhases.set(match[1], match[2].trim());
1443
- }
1444
- } else {
1445
- issues.push({ type: 'warning', message: 'roadmap.md not found' });
1446
- }
1447
-
1448
- // Step 2: Scan disk phase directories
1449
- const diskPhases = new Map(); // number -> dirName
1450
- try {
1451
- const entries = fs.readdirSync(phasesPath(cwd), { withFileTypes: true });
1452
- for (const entry of entries) {
1453
- if (entry.isDirectory()) {
1454
- const dirMatch = entry.name.match(PHASE_DIR_RE);
1455
- if (dirMatch) {
1456
- diskPhases.set(dirMatch[1], entry.name);
1457
- }
1458
- }
1459
- }
1460
- } catch { /* phases dir missing */ }
1461
-
1462
- // Step 3: Cross-reference roadmap vs disk
1463
- for (const [num, name] of roadmapPhases) {
1464
- if (!diskPhases.has(num)) {
1465
- missingPhases.push({ number: num, name, source: 'roadmap' });
1466
- issues.push({ type: 'error', message: 'Phase ' + num + ' (' + name + ') in roadmap but no directory on disk' });
1467
- }
1468
- }
1469
- for (const [num, dirName] of diskPhases) {
1470
- if (!roadmapPhases.has(num)) {
1471
- orphanedDirs.push({ number: num, directory: dirName });
1472
- issues.push({ type: 'warning', message: 'Directory ' + dirName + ' exists on disk but Phase ' + num + ' not found in roadmap' });
1473
- }
1474
- }
1475
-
1476
- // Step 4: Parse requirements
1477
- const reqPath = path.join(planDir, 'requirements.md');
1478
- const reqContent = safeReadFile(reqPath);
1479
- const allReqIds = [];
1480
- const completedReqIds = new Set();
1481
- if (reqContent) {
1482
- // Find all REQ-NN patterns in checkbox lines
1483
- const reqLines = reqContent.match(/^-\s*\[[ x]\]\s*\*\*([A-Z]+-\d+)\*\*/gmi) || [];
1484
- for (const line of reqLines) {
1485
- const idMatch = line.match(/\*\*([A-Z]+-\d+)\*\*/i);
1486
- if (idMatch) {
1487
- allReqIds.push(idMatch[1]);
1488
- if (/\[x\]/i.test(line)) {
1489
- completedReqIds.add(idMatch[1]);
1490
- }
1491
- }
1492
- }
1493
- }
1494
-
1495
- // Step 5: Check requirements traceability — find REQ IDs mentioned in summaries
1496
- const tracedReqIds = new Set();
1497
- for (const [, dirName] of diskPhases) {
1498
- try {
1499
- const files = fs.readdirSync(path.join(phasesPath(cwd), dirName));
1500
- for (const file of filterSummaryFiles(files)) {
1501
- const summaryContent = safeReadFile(path.join(phasesPath(cwd), dirName, file));
1502
- if (summaryContent) {
1503
- const mentions = summaryContent.match(/[A-Z]+-\d+/g) || [];
1504
- for (const id of mentions) {
1505
- if (allReqIds.includes(id)) tracedReqIds.add(id);
1506
- }
1507
- }
1508
- }
1509
- } catch { /* unreadable dir */ }
1510
- }
1511
-
1512
- // Find requirements that are neither completed nor traced in any summary
1513
- for (const reqId of allReqIds) {
1514
- if (!completedReqIds.has(reqId) && !tracedReqIds.has(reqId)) {
1515
- orphanedReqs.push(reqId);
1516
- issues.push({ type: 'info', message: 'Requirement ' + reqId + ' not marked complete and not referenced in any summary' });
1517
- }
1518
- }
1519
-
1520
- const valid = issues.filter(i => i.type === 'error').length === 0;
1521
-
1522
- output({
1523
- valid,
1524
- issues,
1525
- roadmap_phases: roadmapPhases.size,
1526
- disk_phases: diskPhases.size,
1527
- requirements_total: allReqIds.length,
1528
- requirements_completed: completedReqIds.size,
1529
- orphaned_reqs: orphanedReqs,
1530
- missing_phases: missingPhases,
1531
- orphaned_dirs: orphanedDirs,
1532
- }, raw, valid ? 'valid' : 'issues found');
1533
- }
1534
-
1535
- // ─── Drift detection ─────────────────────────────────────────────────────────
1536
-
1537
- /**
1538
- * Parse convention rules from CONVENTIONS.md markdown content.
1539
- * Extracts anti-pattern rules from prose containing "instead of", "not", "never".
1540
- * Run drift check internally and return result object (no output).
1541
- * Used by cmdValidateHealth --drift.
1542
- */
1543
- function runDriftCheck(cwd) {
1544
- const conventionsPath = path.join(planningPath(cwd), 'codebase', 'CONVENTIONS.md');
1545
- const conventionsContent = safeReadFile(conventionsPath);
1546
- const claudeMdContent = safeReadFile(path.join(cwd, 'CLAUDE.md'));
1547
- const combined = [conventionsContent, claudeMdContent].filter(Boolean).join('\n');
1548
- const rules = parseConventionRules(combined || null);
1549
- const files = getChangedFiles(cwd);
1550
- const allViolations = [];
1551
- let filesChecked = 0;
1552
- for (const filePath of files) {
1553
- const fullPath = path.join(cwd, filePath);
1554
- try {
1555
- const stat = fs.statSync(fullPath);
1556
- if (stat.size > DRIFT_MAX_FILE_SIZE) continue;
1557
- } catch { continue; }
1558
- const content = safeReadFile(fullPath);
1559
- if (!content) continue;
1560
- filesChecked++;
1561
- allViolations.push(...checkFileConventions(filePath, content, rules));
1562
- }
1563
- const { score, verdict } = calculateDriftScore(allViolations, filesChecked, rules.length);
1564
- return { drift_score: score, verdict, violation_count: allViolations.length, files_checked: filesChecked };
1565
- }
1566
-
1567
- /**
1568
- * Always merges with BUILTIN_DRIFT_RULES.
1569
- * @param {string|null} content - Markdown content from CONVENTIONS.md
1570
- * @returns {Array} Array of rule objects {id, antiPattern, message, severity, fileGlob}
1571
- */
1572
- function parseConventionRules(content) {
1573
- const parsed = [];
1574
- if (content) {
1575
- // Match lines with inline code containing negation patterns
1576
- const lines = content.split(/\r?\n/);
1577
- for (const line of lines) {
1578
- // Pattern: "Use X instead of `Y`" or "Never use `Y`" or "Don't use `Y`"
1579
- const negMatch = line.match(/(?:instead of|never use|don'?t use|avoid|not)\s+`([^`]+)`/i);
1580
- if (negMatch) {
1581
- const raw = negMatch[1].trim();
1582
- try {
1583
- const antiPattern = new RegExp('\\b' + raw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b');
1584
- parsed.push({
1585
- id: 'conv-' + raw.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase().slice(0, 30),
1586
- antiPattern,
1587
- message: line.trim().slice(0, 120),
1588
- severity: 'warning',
1589
- fileGlob: null,
1590
- });
1591
- } catch { /* invalid regex — skip */ }
1592
- }
1593
- }
1594
- }
1595
- // Merge: parsed + builtins, dedup by id (parsed takes priority)
1596
- const ids = new Set(parsed.map(r => r.id));
1597
- for (const rule of BUILTIN_DRIFT_RULES) {
1598
- if (!ids.has(rule.id)) parsed.push(rule);
1599
- }
1600
- return parsed;
1601
- }
1602
-
1603
- /**
1604
- * Check a single file's content against convention rules.
1605
- * @param {string} filePath - Relative file path (for glob matching and output)
1606
- * @param {string} content - File content
1607
- * @param {Array} rules - Convention rules from parseConventionRules
1608
- * @returns {Array} violations [{file, line, rule, message, severity}]
1609
- */
1610
- function checkFileConventions(filePath, content, rules) {
1611
- const violations = [];
1612
- const lines = content.split(/\r?\n/);
1613
- for (const rule of rules) {
1614
- // Check fileGlob match (simple endsWith check — zero-dep)
1615
- if (rule.fileGlob && !filePath.endsWith(rule.fileGlob)) continue;
1616
- for (let i = 0; i < lines.length; i++) {
1617
- const line = lines[i];
1618
- // Skip comment-only lines
1619
- if (/^\s*(\/\/|\/?\*|\*)/.test(line)) continue;
1620
- if (rule.antiPattern.test(line)) {
1621
- violations.push({
1622
- file: toPosix(filePath),
1623
- line: i + 1,
1624
- rule: rule.id,
1625
- message: rule.message,
1626
- severity: rule.severity,
1627
- });
1628
- }
1629
- }
1630
- }
1631
- return violations;
1632
- }
1633
-
1634
- /**
1635
- * Calculate drift score from violations.
1636
- * @param {Array} violations - All violations across checked files
1637
- * @param {number} filesChecked - Number of files checked
1638
- * @param {number} rulesCount - Total rules applied
1639
- * @returns {{score: number, verdict: string}}
1640
- */
1641
- function calculateDriftScore(violations, filesChecked, rulesCount) {
1642
- if (filesChecked === 0 || rulesCount === 0) return { score: 0, verdict: 'clean' };
1643
- let weighted = 0;
1644
- for (const v of violations) {
1645
- weighted += DRIFT_SEVERITY_WEIGHTS[v.severity] || 0;
1646
- }
1647
- const ceiling = filesChecked * rulesCount * 0.3;
1648
- const score = Math.min(1.0, weighted / Math.max(ceiling, 1));
1649
- const rounded = Math.round(score * 100) / 100;
1650
- const verdict = DRIFT_VERDICTS.find(b => rounded <= b.max)?.verdict || 'high';
1651
- return { score: rounded, verdict };
1652
- }
1653
-
1654
- /**
1655
- * Get list of changed files from git diff.
1656
- * @param {string} cwd - Working directory
1657
- * @param {string} [sinceRef] - Git ref to diff against (default: HEAD)
1658
- * @returns {string[]} Array of relative file paths
1659
- */
1660
- function getChangedFiles(cwd, sinceRef) {
1661
- const ref = sinceRef || 'HEAD';
1662
- // Try staged + unstaged first, then diff against ref
1663
- const result = execGit(cwd, ['diff', '--name-only', ref]);
1664
- if (result.exitCode !== 0) return [];
1665
- const stagedResult = execGit(cwd, ['diff', '--name-only', '--cached']);
1666
- const allFiles = new Set();
1667
- for (const line of result.stdout.split(/\r?\n/)) {
1668
- if (line.trim()) allFiles.add(line.trim());
1669
- }
1670
- if (stagedResult.exitCode === 0) {
1671
- for (const line of stagedResult.stdout.split(/\r?\n/)) {
1672
- if (line.trim()) allFiles.add(line.trim());
1673
- }
1674
- }
1675
- // Filter out binary extensions and limit
1676
- const filtered = [];
1677
- for (const f of allFiles) {
1678
- const ext = path.extname(f).toLowerCase();
1679
- if (BINARY_EXTENSIONS.has(ext)) continue;
1680
- filtered.push(f);
1681
- if (filtered.length >= DRIFT_MAX_FILES) break;
1682
- }
1683
- return filtered;
1684
- }
1685
-
1686
- /**
1687
- * Run drift check on changed files against project conventions.
1688
- * @param {string} cwd - Working directory
1689
- * @param {boolean} raw - Raw output mode
1690
- * @param {string[]} args - CLI arguments
1691
- */
1692
- function cmdDriftCheck(cwd, raw, args) {
1693
- // Parse flags
1694
- let sinceRef = null;
1695
- let threshold = 0.5;
1696
- let specificFiles = null;
1697
- const verbose = process.env.PAN_VERBOSE === '1';
1698
- for (let i = 0; i < args.length; i++) {
1699
- if (args[i] === '--since' && args[i + 1]) { sinceRef = args[++i]; }
1700
- else if (args[i] === '--threshold' && args[i + 1]) {
1701
- const t = parseFloat(args[++i]);
1702
- if (isNaN(t) || t < 0 || t > 1) { error('threshold must be 0.0-1.0'); return; }
1703
- threshold = t;
1704
- }
1705
- else if (args[i] === '--files' && args[i + 1]) { specificFiles = args[++i].split(',').map(f => f.trim()); }
1706
- }
1707
-
1708
- // Load convention rules
1709
- const conventionsPath = path.join(planningPath(cwd), 'codebase', 'CONVENTIONS.md');
1710
- const conventionsContent = safeReadFile(conventionsPath);
1711
- const claudeMdContent = safeReadFile(path.join(cwd, 'CLAUDE.md'));
1712
- const combined = [conventionsContent, claudeMdContent].filter(Boolean).join('\n');
1713
- const rules = parseConventionRules(combined || null);
1714
-
1715
- // Get files to check
1716
- let files;
1717
- if (specificFiles) {
1718
- files = specificFiles;
1719
- } else {
1720
- files = getChangedFiles(cwd, sinceRef);
1721
- }
1722
-
1723
- // Check each file
1724
- const allViolations = [];
1725
- let filesChecked = 0;
1726
- for (const filePath of files) {
1727
- const fullPath = path.join(cwd, filePath);
1728
- try {
1729
- const stat = fs.statSync(fullPath);
1730
- if (stat.size > DRIFT_MAX_FILE_SIZE) continue;
1731
- } catch { continue; }
1732
- const content = safeReadFile(fullPath);
1733
- if (!content) continue;
1734
- filesChecked++;
1735
- const violations = checkFileConventions(filePath, content, rules);
1736
- allViolations.push(...violations);
1737
- }
1738
-
1739
- // Calculate score
1740
- const { score, verdict } = calculateDriftScore(allViolations, filesChecked, rules.length);
1741
- const passed = score <= threshold;
1742
- const summary = filesChecked === 0
1743
- ? 'no files changed'
1744
- : rules.length === 0
1745
- ? 'no conventions loaded'
1746
- : `drift: ${score} (${verdict}) — ${allViolations.length} violations in ${filesChecked} files`;
1747
-
1748
- const result = {
1749
- drift_score: score,
1750
- verdict,
1751
- passed,
1752
- threshold,
1753
- violations: allViolations,
1754
- violation_count: allViolations.length,
1755
- files_checked: filesChecked,
1756
- conventions_loaded: rules.length,
1757
- summary,
1758
- };
1759
- if (verbose) {
1760
- const byFile = {};
1761
- for (const v of allViolations) {
1762
- if (!byFile[v.file]) byFile[v.file] = [];
1763
- byFile[v.file].push({ line: v.line, rule: v.rule, message: v.message, severity: v.severity });
1764
- }
1765
- result.per_file = byFile;
1766
- }
1767
- output(result, raw, summary);
1768
- }
1769
-
1770
- // ─── Retrospective Analysis ─────────────────────────────────────────────────
1771
-
1772
- /**
1773
- * Scan verification files in a phases directory and collect stats.
1774
- * @param {string} phasesDir - Absolute path to phases directory
1775
- * @returns {{ total: number, passed: number, gaps_found: number, human_needed: number, gap_patterns: string[] }}
1776
- */
1777
- function collectVerificationStats(phasesDir) {
1778
- const stats = { total: 0, passed: 0, gaps_found: 0, human_needed: 0, gap_patterns: [] };
1779
- let dirs;
1780
- try { dirs = fs.readdirSync(phasesDir, { withFileTypes: true }); } catch { return stats; }
1781
- for (const d of dirs) {
1782
- if (!d.isDirectory()) continue;
1783
- const phaseDir = path.join(phasesDir, d.name);
1784
- let files;
1785
- try { files = fs.readdirSync(phaseDir); } catch { continue; }
1786
- for (const f of files) {
1787
- if (!isVerificationFile(f)) continue;
1788
- stats.total++;
1789
- const content = safeReadFile(path.join(phaseDir, f));
1790
- if (!content) continue;
1791
- const fm = extractFrontmatter(content);
1792
- const status = (fm.status || '').toLowerCase();
1793
- if (status === 'passed') stats.passed++;
1794
- else if (status === 'gaps_found') stats.gaps_found++;
1795
- else if (status === 'human_needed') stats.human_needed++;
1796
- // Extract gap descriptions from ## Gaps section
1797
- const gapsMatch = content.match(/## Gaps[\s\S]*?(?=\n## |$)/);
1798
- if (gapsMatch) {
1799
- const lines = gapsMatch[0].split('\n').filter(l => l.match(/^[-*]\s+/));
1800
- for (const line of lines) {
1801
- const desc = line.replace(/^[-*]\s+/, '').trim();
1802
- if (desc) stats.gap_patterns.push(desc);
1803
- }
1804
- }
1805
- }
1806
- }
1807
- return stats;
1808
- }
1809
-
1810
- /**
1811
- * Count phases from roadmap: total planned, completed, and decimal (gap closure) phases.
1812
- * @param {string} roadmapContent - Roadmap file content
1813
- * @returns {{ planned: number, completed: number, decimal_phases: number }}
1814
- */
1815
- function countRoadmapPhases(roadmapContent) {
1816
- const result = { planned: 0, completed: 0, decimal_phases: 0 };
1817
- const checkboxRe = /- \[([ x])\]\s*(?:\*\*)?Phase\s+(\d+[A-Z]?(?:\.\d+)*)/gi;
1818
- let m;
1819
- while ((m = checkboxRe.exec(roadmapContent)) !== null) {
1820
- result.planned++;
1821
- if (m[1] === 'x') result.completed++;
1822
- if (m[2].includes('.')) result.decimal_phases++;
1823
- }
1824
- return result;
1825
- }
1826
-
1827
- /**
1828
- * Group gap patterns by similarity (simple keyword grouping).
1829
- * @param {string[]} patterns - Raw gap descriptions
1830
- * @returns {Array<{pattern: string, count: number}>}
1831
- */
1832
- function groupGapPatterns(patterns) {
1833
- const groups = {};
1834
- for (const p of patterns) {
1835
- const key = p.toLowerCase().replace(/[^a-z0-9\s]/g, '').trim();
1836
- const words = key.split(/\s+/).slice(0, 3).join(' ');
1837
- groups[words] = (groups[words] || 0) + 1;
1838
- }
1839
- return Object.entries(groups)
1840
- .map(([pattern, count]) => ({ pattern, count }))
1841
- .sort((a, b) => b.count - a.count)
1842
- .slice(0, 10);
1843
- }
1844
-
1845
- /**
1846
- * Milestone retrospective — analyze historical .planning/ data for process improvement.
1847
- * @param {string} cwd - Working directory
1848
- * @param {boolean} raw - Raw output flag
1849
- */
1850
- function cmdRetro(cwd, raw, args) {
1851
- const roadmapPath = path.join(planningPath(cwd), ROADMAP_FILE);
1852
- const roadmapContent = safeReadFile(roadmapPath);
1853
- if (!roadmapContent) {
1854
- return output({ error: 'roadmap.md not found' }, raw, 'roadmap.md not found');
1855
- }
1856
-
1857
- const phases = countRoadmapPhases(roadmapContent);
1858
- const pDir = phasesPath(cwd);
1859
- const verification = collectVerificationStats(pDir);
1860
- const gapGroups = groupGapPatterns(verification.gap_patterns);
1861
-
1862
- // Estimation accuracy: planned phases vs actual (including decimal gap closures)
1863
- const basePlanned = phases.planned - phases.decimal_phases;
1864
- const estimationAccuracy = basePlanned > 0
1865
- ? Math.round((basePlanned / phases.planned) * 100)
1866
- : 100;
1867
-
1868
- const result = {
1869
- phases_planned: phases.planned,
1870
- phases_completed: phases.completed,
1871
- phases_decimal: phases.decimal_phases,
1872
- estimation_accuracy_pct: estimationAccuracy,
1873
- verifications_total: verification.total,
1874
- verifications_passed_first_try: verification.passed,
1875
- verifications_gaps_found: verification.gaps_found,
1876
- verifications_human_needed: verification.human_needed,
1877
- first_try_rate_pct: verification.total > 0
1878
- ? Math.round((verification.passed / verification.total) * 100)
1879
- : null,
1880
- common_gap_patterns: gapGroups,
1881
- };
1882
-
1883
- // E-4: optional memory write. Top gap patterns become lessons for pan-planner
1884
- // (they surface what plans routinely miss). First-try rate deltas feed
1885
- // pan-verifier memory.
1886
- const argsList = Array.isArray(args) ? args : [];
1887
- if (argsList.includes('--write-memory')) {
1888
- const { appendMemory } = require('./memory.cjs');
1889
- const lessons_written = { 'pan-planner': 0, 'pan-verifier': 0 };
1890
- const maxIdx = argsList.indexOf('--max');
1891
- const maxLessons = maxIdx !== -1 && argsList[maxIdx + 1]
1892
- ? Math.max(1, Math.min(10, Number(argsList[maxIdx + 1]) || 3))
1893
- : 3;
1894
-
1895
- // Top N gap patterns → planner memory as single-line lessons.
1896
- const top = gapGroups.slice(0, maxLessons);
1897
- for (const g of top) {
1898
- const lesson = `Recurring plan gap (${g.count}x across phases): "${g.pattern}" — factor into plan-checker inputs`;
1899
- const r = appendMemory(cwd, 'pan-planner', lesson);
1900
- if (r.appended) lessons_written['pan-planner'] += 1;
1901
- }
1902
-
1903
- // Low first-try rate → verifier memory.
1904
- if (verification.total >= 3 && result.first_try_rate_pct != null && result.first_try_rate_pct < 60) {
1905
- const lesson = `First-try verification rate ${result.first_try_rate_pct}% over ${verification.total} runs — tighten verification criteria and pre-exec checks`;
1906
- const r = appendMemory(cwd, 'pan-verifier', lesson);
1907
- if (r.appended) lessons_written['pan-verifier'] += 1;
1908
- }
1909
-
1910
- result.memory = { wrote: lessons_written, max: maxLessons };
1911
- }
1912
-
1913
- const rawLines = [
1914
- `Phases: ${phases.completed}/${phases.planned} completed (${phases.decimal_phases} gap closures)`,
1915
- `Estimation accuracy: ${estimationAccuracy}%`,
1916
- `Verifications: ${verification.passed}/${verification.total} passed first try`,
1917
- `Gaps found: ${verification.gaps_found}, Human needed: ${verification.human_needed}`,
1918
- ];
1919
- if (gapGroups.length > 0) {
1920
- rawLines.push('Common gap patterns:');
1921
- for (const g of gapGroups) rawLines.push(` - ${g.pattern} (${g.count}x)`);
1922
- }
1923
- if (result.memory) {
1924
- rawLines.push(`Memory: wrote ${result.memory.wrote['pan-planner']} planner + ${result.memory.wrote['pan-verifier']} verifier lessons`);
1925
- }
1926
-
1927
- output(result, raw, rawLines.join('\n'));
1928
- }
1929
-
1930
- // ─── Deployment Validation ──────────────────────────────────────────────────
1931
-
1932
- /**
1933
- * Detect which PAN runtimes are installed in cwd.
1934
- * @param {string} cwd
1935
- * @returns {Array<{runtime: string, configDir: string}>}
1936
- */
1937
- function detectInstalledRuntimes(cwd) {
1938
- const RUNTIME_DIRS = [
1939
- { runtime: 'claude', configDir: '.claude' },
1940
- { runtime: 'opencode', configDir: '.opencode' },
1941
- { runtime: 'gemini', configDir: '.gemini' },
1942
- { runtime: 'codex', configDir: '.codex' },
1943
- { runtime: 'copilot', configDir: '.github' },
1944
- ];
1945
- const found = [];
1946
- for (const rt of RUNTIME_DIRS) {
1947
- const manifestPath = path.join(cwd, rt.configDir, 'pan-file-manifest.json');
1948
- try {
1949
- fs.accessSync(manifestPath);
1950
- found.push(rt);
1951
- } catch (_) { /* not installed */ }
1952
- }
1953
- return found;
1954
- }
1955
-
1956
- /**
1957
- * Validate a single PAN runtime installation.
1958
- * Checks: manifest files exist, hashes match, settings integrity.
1959
- * @param {string} cwd
1960
- * @param {string} configDir - e.g. '.claude'
1961
- * @param {string} runtime - e.g. 'claude'
1962
- * @returns {{ status: string, version: string, total_files: number, missing: string[], modified: string[], orphaned: string[], settings_ok: boolean, settings_issues: string[] }}
1963
- */
1964
- function validateRuntimeInstall(cwd, configDir, runtime) {
1965
- const crypto = require('crypto');
1966
- const baseDir = path.join(cwd, configDir);
1967
- const manifestPath = path.join(baseDir, 'pan-file-manifest.json');
1968
-
1969
- let manifest;
1970
- try {
1971
- manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
1972
- } catch (e) {
1973
- return { status: 'broken', version: null, error: `Cannot read manifest: ${e.message}`, total_files: 0, missing: [], modified: [], orphaned: [], settings_ok: false, settings_issues: ['manifest unreadable'] };
1974
- }
1975
-
1976
- const missing = [];
1977
- const modified = [];
1978
- const files = manifest.files || {};
1979
- const totalFiles = Object.keys(files).length;
1322
+ // ─── Preflight + deps validation — extracted to verify-preflight.cjs (re-exported below)
1980
1323
 
1981
- for (const [relPath, expectedHash] of Object.entries(files)) {
1982
- const absPath = path.join(baseDir, relPath);
1983
- try {
1984
- const content = fs.readFileSync(absPath);
1985
- const actualHash = crypto.createHash('sha256').update(content).digest('hex');
1986
- if (actualHash !== expectedHash) {
1987
- modified.push(relPath);
1988
- }
1989
- } catch (_) {
1990
- missing.push(relPath);
1991
- }
1992
- }
1324
+ // ─── Drift detection extracted to verify-drift.cjs (re-exported below) ─────
1993
1325
 
1994
- // Check settings integrity (hook paths resolve to real files)
1995
- const settingsIssues = [];
1996
- const settingsFile = runtime === 'copilot' ? 'config.json' : 'settings.json';
1997
- const settingsPath = path.join(baseDir, settingsFile);
1998
- let settingsOk = true;
1999
- try {
2000
- const settingsContent = fs.readFileSync(settingsPath, 'utf8');
2001
- const settings = JSON.parse(settingsContent);
2002
- // Check hook paths in settings
2003
- // Collect all hook command strings from settings
2004
- const hookCommands = [];
2005
- const hooks = settings.hooks;
2006
- if (hooks && typeof hooks === 'object') {
2007
- for (const hookArr of Object.values(hooks)) {
2008
- if (!Array.isArray(hookArr)) continue;
2009
- for (const hook of hookArr) {
2010
- if (hook.command) hookCommands.push(hook.command);
2011
- }
2012
- }
2013
- }
2014
- // Copilot/Gemini statusLine
2015
- if (settings.statusLine && settings.statusLine.command) {
2016
- hookCommands.push(settings.statusLine.command);
2017
- }
2018
- // Claude statusline
2019
- if (settings.statusline && settings.statusline.command) {
2020
- hookCommands.push(settings.statusline.command);
2021
- }
2022
- for (const cmd of hookCommands) {
2023
- const parts = cmd.split(/\s+/);
2024
- const hookFile = parts.find(p => p.endsWith('.js'));
2025
- if (hookFile) {
2026
- // Hook paths are relative to cwd, not to config dir
2027
- const resolvedPath = path.isAbsolute(hookFile) ? hookFile : path.join(cwd, hookFile);
2028
- try { fs.accessSync(resolvedPath); } catch (_) {
2029
- settingsIssues.push(`Hook path not found: ${hookFile}`);
2030
- settingsOk = false;
2031
- }
2032
- }
2033
- }
2034
- } catch (_) {
2035
- // No settings file is OK for some runtimes (codex, opencode)
2036
- if (runtime !== 'codex' && runtime !== 'opencode') {
2037
- settingsIssues.push(`${settingsFile} missing or unreadable`);
2038
- settingsOk = false;
2039
- }
2040
- }
2041
-
2042
- const status = missing.length > 0 ? 'broken' : modified.length > 0 ? 'modified' : 'clean';
2043
-
2044
- return {
2045
- status,
2046
- version: manifest.version || null,
2047
- total_files: totalFiles,
2048
- missing,
2049
- modified,
2050
- orphaned: [],
2051
- settings_ok: settingsOk,
2052
- settings_issues: settingsIssues,
2053
- };
2054
- }
2055
-
2056
- /**
2057
- * CLI command: validate deployment
2058
- * Validates PAN installations in the current directory.
2059
- * @param {string} cwd
2060
- * @param {boolean} raw
2061
- */
2062
- function cmdValidateDeployment(cwd, raw) {
2063
- const runtimes = detectInstalledRuntimes(cwd);
2064
- if (runtimes.length === 0) {
2065
- output({ error: 'No PAN installations found in this directory' }, raw);
2066
- return;
2067
- }
2068
-
2069
- const results = {};
2070
- let overallStatus = 'clean';
2071
-
2072
- for (const { runtime, configDir } of runtimes) {
2073
- const result = validateRuntimeInstall(cwd, configDir, runtime);
2074
- results[runtime] = result;
2075
- if (result.status === 'broken') overallStatus = 'broken';
2076
- else if (result.status === 'modified' && overallStatus !== 'broken') overallStatus = 'modified';
2077
- }
2078
-
2079
- const summary = {
2080
- status: overallStatus,
2081
- runtimes_found: runtimes.length,
2082
- runtimes: results,
2083
- };
2084
-
2085
- const rawLines = [`Deployment status: ${overallStatus} (${runtimes.length} runtimes)`];
2086
- for (const [rt, r] of Object.entries(results)) {
2087
- rawLines.push(` ${rt}: ${r.status} (${r.total_files} files, ${r.missing.length} missing, ${r.modified.length} modified)`);
2088
- }
2089
-
2090
- output(summary, raw, rawLines.join('\n'));
2091
- }
1326
+ // ─── Retrospective analysis extracted to verify-retro.cjs (re-exported below)
1327
+ // ─── Deployment validation — extracted to verify-deploy.cjs (re-exported below)
2092
1328
 
2093
1329
  module.exports = {
2094
1330
  cmdVerifySummary,