pan-wizard 3.8.0 → 3.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +4 -1
  2. package/agents/pan-conductor.md +1 -2
  3. package/agents/pan-counterfactual.md +1 -2
  4. package/agents/pan-debugger.md +1 -2
  5. package/agents/pan-distiller.md +1 -2
  6. package/agents/pan-document_code.md +1 -0
  7. package/agents/pan-executor.md +1 -0
  8. package/agents/pan-experiment-runner.md +1 -2
  9. package/agents/pan-hardener.md +1 -2
  10. package/agents/pan-integration-checker.md +1 -2
  11. package/agents/pan-knowledge.md +1 -2
  12. package/agents/pan-meta-reviewer.md +1 -2
  13. package/agents/pan-optimizer.md +1 -0
  14. package/agents/pan-phase-researcher.md +1 -0
  15. package/agents/pan-plan-checker.md +1 -2
  16. package/agents/pan-planner.md +1 -0
  17. package/agents/pan-previewer.md +1 -2
  18. package/agents/pan-project-researcher.md +6 -0
  19. package/agents/pan-research-synthesizer.md +7 -0
  20. package/agents/pan-reviewer.md +2 -3
  21. package/agents/pan-roadmapper.md +1 -0
  22. package/agents/pan-verifier.md +1 -2
  23. package/bin/install-lib.cjs +661 -46
  24. package/bin/install.js +722 -116
  25. package/commands/pan/experiment.md +2 -0
  26. package/commands/pan/profile.md +2 -0
  27. package/hooks/dist/pan-cost-logger.js +22 -7
  28. package/package.json +5 -4
  29. package/pan-wizard-core/bin/lib/commands-learnings.cjs +544 -0
  30. package/pan-wizard-core/bin/lib/commands.cjs +12 -523
  31. package/pan-wizard-core/bin/lib/core.cjs +69 -0
  32. package/pan-wizard-core/bin/lib/cost.cjs +62 -8
  33. package/pan-wizard-core/bin/lib/git.cjs +6 -1
  34. package/pan-wizard-core/bin/lib/lock.cjs +108 -0
  35. package/pan-wizard-core/bin/lib/milestone.cjs +3 -2
  36. package/pan-wizard-core/bin/lib/phase-remove.cjs +392 -0
  37. package/pan-wizard-core/bin/lib/phase.cjs +4 -369
  38. package/pan-wizard-core/bin/lib/runner.cjs +5 -0
  39. package/pan-wizard-core/bin/lib/state.cjs +10 -1
  40. package/pan-wizard-core/bin/lib/verify-deploy.cjs +181 -0
  41. package/pan-wizard-core/bin/lib/verify-drift.cjs +255 -0
  42. package/pan-wizard-core/bin/lib/verify-preflight.cjs +261 -0
  43. package/pan-wizard-core/bin/lib/verify-retro.cjs +177 -0
  44. package/pan-wizard-core/bin/lib/verify.cjs +10 -797
  45. package/pan-wizard-core/bin/pan-tools.cjs +10 -0
  46. package/pan-wizard-core/workflows/plan-phase.md +11 -0
  47. package/scripts/build-plugin.js +105 -0
  48. package/scripts/install-git-hooks.js +64 -0
  49. package/scripts/release-check.js +13 -2
@@ -10,6 +10,9 @@ const { writeStateMd, readStateSafe } = require('./state.cjs');
10
10
  const { enumerateRoadmapPhases } = require('./roadmap.cjs');
11
11
  const { PLANNING_DIR, PHASES_DIR, ROADMAP_FILE, REQUIREMENTS_FILE, STATE_FILE, isPlanFile, isSummaryFile, getPlanId, PHASE_DIR_RE, ARCHIVE_DIR_RE } = require('./constants.cjs');
12
12
  const { planningPath, phasesPath, filterPlanFiles, filterSummaryFiles, parsePhaseDir, fileAccessible } = require('./utils.cjs');
13
+ // Phase removal lives in phase-remove.cjs; re-exported below so consumers of
14
+ // phase.cjs are unaffected by the decomposition.
15
+ const { removePhaseFromDisk, renumberDecimalPhases, renumberIntegerPhases, updateRoadmapAfterRemoval, cmdPhaseRemove } = require('./phase-remove.cjs');
13
16
 
14
17
  /**
15
18
  * List phase directories or files within phases, with optional type and archive filtering.
@@ -516,375 +519,7 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
516
519
  output(result, raw, decimalPhase);
517
520
  }
518
521
 
519
- // ─── cmdPhaseRemove helpers ─────────────────────────────────────────────────
520
-
521
- /**
522
- * Delete a phase directory from disk.
523
- * @param {string} phaseDir - Absolute path to the phase directory to remove
524
- */
525
- function removePhaseFromDisk(phaseDir) {
526
- try {
527
- fs.rmSync(phaseDir, { recursive: true, force: true });
528
- } catch (e) {
529
- return { removed: false, error: e.message };
530
- }
531
- return { removed: true };
532
- }
533
-
534
- /**
535
- * Renumber sibling decimal phases after one is removed.
536
- *
537
- * Algorithm: When a decimal phase like 06.2 is removed, all higher-numbered
538
- * siblings under the same base integer (06.3, 06.4, ...) must be decremented
539
- * by 1 to fill the gap. We process in descending order to avoid directory
540
- * name collisions during rename (e.g. rename 06.4 -> 06.3 before 06.3 -> 06.2).
541
- *
542
- * @param {string} phasesDir - Absolute path to the phases directory
543
- * @param {string} baseInt - The integer portion of the removed phase (e.g. "06")
544
- * @param {number} removedDecimal - The decimal portion that was removed (e.g. 2)
545
- * @returns {{ renamedDirs: Array, renamedFiles: Array }}
546
- */
547
- function renumberDecimalPhases(phasesDir, baseInt, removedDecimal) {
548
- const renamedDirs = [];
549
- const renamedFiles = [];
550
-
551
- try {
552
- const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
553
- const dirs = entries.filter(entry => entry.isDirectory()).map(entry => entry.name).sort((left, right) => comparePhaseNum(left, right));
554
-
555
- // Find sibling decimals with higher numbers than the removed one
556
- const decPattern = new RegExp(`^${baseInt}\\.(\\d+)-(.+)$`);
557
- const toRename = [];
558
- for (const dir of dirs) {
559
- const decMatch = dir.match(decPattern);
560
- if (decMatch && parseInt(decMatch[1], 10) > removedDecimal) {
561
- toRename.push({ dir, oldDecimal: parseInt(decMatch[1], 10), slug: decMatch[2] });
562
- }
563
- }
564
-
565
- // Sort descending so higher-numbered dirs are renamed first,
566
- // preventing collisions (e.g. 06.4 -> 06.3 before 06.3 -> 06.2)
567
- toRename.sort((left, right) => right.oldDecimal - left.oldDecimal);
568
-
569
- for (const item of toRename) {
570
- const newDecimal = item.oldDecimal - 1;
571
- const oldPhaseId = `${baseInt}.${item.oldDecimal}`;
572
- const newPhaseId = `${baseInt}.${newDecimal}`;
573
- const newDirName = `${baseInt}.${newDecimal}-${item.slug}`;
574
-
575
- // Rename the directory itself (e.g. 06.3-foo -> 06.2-foo)
576
- fs.renameSync(path.join(phasesDir, item.dir), path.join(phasesDir, newDirName));
577
- renamedDirs.push({ from: item.dir, to: newDirName });
578
-
579
- // Rename files inside that contain the old phase ID prefix
580
- const dirFiles = fs.readdirSync(path.join(phasesDir, newDirName));
581
- for (const file of dirFiles) {
582
- // Files may have phase prefix like "06.2-01-plan.md"
583
- if (file.includes(oldPhaseId)) {
584
- const newFileName = file.replace(oldPhaseId, newPhaseId);
585
- fs.renameSync(
586
- path.join(phasesDir, newDirName, file),
587
- path.join(phasesDir, newDirName, newFileName)
588
- );
589
- renamedFiles.push({ from: file, to: newFileName });
590
- }
591
- }
592
- }
593
- } catch (e) {
594
- return { renamedDirs, renamedFiles, error: `Partial rename: ${e.message}` };
595
- }
596
-
597
- return { renamedDirs, renamedFiles };
598
- }
599
-
600
- /**
601
- * Renumber integer phases (and their decimal/letter children) after one is removed.
602
- *
603
- * Algorithm: When an integer phase like 05 is removed, all phases with a higher
604
- * integer base (06, 06.1, 06A, 07, ...) must be decremented by 1. We process
605
- * in descending order to avoid directory name collisions during the rename
606
- * cascade (e.g. rename 07 -> 06 before 06 -> 05). Each directory and its
607
- * contained files with the old phase prefix are renamed to reflect the new number.
608
- *
609
- * @param {string} phasesDir - Absolute path to the phases directory
610
- * @param {number} removedInt - The integer phase number that was removed
611
- * @returns {{ renamedDirs: Array, renamedFiles: Array }}
612
- */
613
- /**
614
- * Collect phase directories that need renumbering (integer > removedInt).
615
- * @param {string[]} dirs - Sorted directory names
616
- * @param {number} removedInt - Removed phase integer
617
- * @returns {Array} Items to rename, sorted descending to avoid collisions
618
- */
619
- function collectDirsToRenumber(dirs, removedInt) {
620
- const toRename = [];
621
- for (const dir of dirs) {
622
- const dirMatch = dir.match(/^(\d+)([A-Z])?(?:\.(\d+))?-(.+)$/i);
623
- if (!dirMatch) continue;
624
- const dirInt = parseInt(dirMatch[1], 10);
625
- if (dirInt > removedInt) {
626
- toRename.push({
627
- dir, oldInt: dirInt,
628
- letter: dirMatch[2] ? dirMatch[2].toUpperCase() : '',
629
- decimal: dirMatch[3] ? parseInt(dirMatch[3], 10) : null,
630
- slug: dirMatch[4],
631
- });
632
- }
633
- }
634
- toRename.sort((left, right) => {
635
- if (left.oldInt !== right.oldInt) return right.oldInt - left.oldInt;
636
- return (right.decimal || 0) - (left.decimal || 0);
637
- });
638
- return toRename;
639
- }
640
-
641
- /**
642
- * Rename a single phase directory and its internal files.
643
- * @param {string} phasesDir - Phases directory path
644
- * @param {Object} item - Rename item from collectDirsToRenumber
645
- * @param {Array} renamedDirs - Accumulator for renamed dirs
646
- * @param {Array} renamedFiles - Accumulator for renamed files
647
- */
648
- function renamePhaseDir(phasesDir, item, renamedDirs, renamedFiles) {
649
- const newPadded = String(item.oldInt - 1).padStart(2, '0');
650
- const oldPadded = String(item.oldInt).padStart(2, '0');
651
- const suffix = (item.letter || '') + (item.decimal !== null ? `.${item.decimal}` : '');
652
- const oldPrefix = oldPadded + suffix;
653
- const newPrefix = newPadded + suffix;
654
- const newDirName = `${newPrefix}-${item.slug}`;
655
-
656
- fs.renameSync(path.join(phasesDir, item.dir), path.join(phasesDir, newDirName));
657
- renamedDirs.push({ from: item.dir, to: newDirName });
658
-
659
- for (const file of fs.readdirSync(path.join(phasesDir, newDirName))) {
660
- if (file.startsWith(oldPrefix)) {
661
- const newFileName = newPrefix + file.slice(oldPrefix.length);
662
- fs.renameSync(path.join(phasesDir, newDirName, file), path.join(phasesDir, newDirName, newFileName));
663
- renamedFiles.push({ from: file, to: newFileName });
664
- }
665
- }
666
- }
667
-
668
- function renumberIntegerPhases(phasesDir, removedInt) {
669
- const renamedDirs = [];
670
- const renamedFiles = [];
671
- try {
672
- const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
673
- const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b));
674
- const toRename = collectDirsToRenumber(dirs, removedInt);
675
- for (const item of toRename) renamePhaseDir(phasesDir, item, renamedDirs, renamedFiles);
676
- } catch (e) {
677
- return { renamedDirs, renamedFiles, error: `Partial rename: ${e.message}` };
678
- }
679
- return { renamedDirs, renamedFiles };
680
- }
681
-
682
- /**
683
- * Rewrite roadmap.md after a phase is removed: delete the target section,
684
- * remove checkbox/table references, and renumber subsequent phase references.
685
- *
686
- * @param {string} cwd - Working directory path
687
- * @param {string} phaseNum - The phase number that was removed (as originally specified)
688
- * @param {boolean} isDecimal - Whether the removed phase was a decimal phase
689
- * @param {string} normalized - The zero-padded normalized phase number
690
- */
691
- function updateRoadmapAfterRemoval(cwd, phaseNum, isDecimal, normalized) {
692
- const roadmapPath = path.join(planningPath(cwd), ROADMAP_FILE);
693
- let roadmapContent;
694
- try {
695
- roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
696
- } catch { return; }
697
-
698
- // Remove the target phase section from roadmap.md.
699
- // Matches from the phase heading to the next phase heading (or end of file).
700
- const targetEscaped = escapeRegex(phaseNum);
701
- const sectionPattern = new RegExp(
702
- `\\n?#{2,4}\\s*Phase\\s+${targetEscaped}\\s*:[\\s\\S]*?(?=\\n#{2,4}\\s+Phase\\s+\\d|$)`,
703
- 'i'
704
- );
705
- roadmapContent = roadmapContent.replace(sectionPattern, '');
706
-
707
- // Remove checkbox list items referencing this phase
708
- const checkboxPattern = new RegExp(`\\n?-\\s*\\[[ x]\\]\\s*.*Phase\\s+${targetEscaped}[:\\s][^\\n]*`, 'gi');
709
- roadmapContent = roadmapContent.replace(checkboxPattern, '');
710
-
711
- // Remove progress table rows referencing this phase
712
- const tableRowPattern = new RegExp(`\\n?\\|\\s*${targetEscaped}\\.?\\s[^|]*\\|[^\\n]*`, 'gi');
713
- roadmapContent = roadmapContent.replace(tableRowPattern, '');
714
-
715
- // For integer phase removal, renumber all references to subsequent phases.
716
- // Walk from highest phase number down to removedInt+1, decrementing each by 1.
717
- // This avoids double-renaming (e.g. 8->7 then 7->6 would break if done ascending).
718
- if (!isDecimal) {
719
- const removedInt = parseInt(normalized, 10);
720
-
721
- // Reasonable upper bound for phase numbers
722
- const maxPhase = 99;
723
- for (let oldNum = maxPhase; oldNum > removedInt; oldNum--) {
724
- const newNum = oldNum - 1;
725
- const oldStr = String(oldNum);
726
- const newStr = String(newNum);
727
- const oldPad = oldStr.padStart(2, '0');
728
- const newPad = newStr.padStart(2, '0');
729
-
730
- // Phase headings: ## Phase 18: or ### Phase 18: -> ## Phase 17:
731
- roadmapContent = roadmapContent.replace(
732
- new RegExp(`(#{2,4}\\s*Phase\\s+)${oldStr}(\\s*:)`, 'gi'),
733
- `$1${newStr}$2`
734
- );
735
-
736
- // Inline phase references: "Phase 18:" or "Phase 18 " -> "Phase 17:"
737
- roadmapContent = roadmapContent.replace(
738
- new RegExp(`(Phase\\s+)${oldStr}([:\\s])`, 'g'),
739
- `$1${newStr}$2`
740
- );
741
-
742
- // Plan references in padded form: 18-01 -> 17-01
743
- roadmapContent = roadmapContent.replace(
744
- new RegExp(`${oldPad}-(\\d{2})`, 'g'),
745
- `${newPad}-$1`
746
- );
747
-
748
- // Progress table row numbers: | 18. -> | 17.
749
- roadmapContent = roadmapContent.replace(
750
- new RegExp(`(\\|\\s*)${oldStr}\\.\\s`, 'g'),
751
- `$1${newStr}. `
752
- );
753
-
754
- // Depends-on references: "Depends on:** Phase 18" -> "Phase 17"
755
- roadmapContent = roadmapContent.replace(
756
- new RegExp(`(Depends on:\\*\\*\\s*Phase\\s+)${oldStr}\\b`, 'gi'),
757
- `$1${newStr}`
758
- );
759
- }
760
- }
761
-
762
- try { fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8'); } catch { /* best-effort */ }
763
- }
764
-
765
- /**
766
- * Remove a phase directory, renumber subsequent phases, and update ROADMAP/STATE.
767
- * @param {string} cwd - Working directory path
768
- * @param {string} targetPhase - Phase number to remove
769
- * @param {Object} options - Options (force: skip executed-work check)
770
- * @param {boolean} raw - If true, output raw value instead of JSON
771
- * @returns {void}
772
- */
773
- function cmdPhaseRemove(cwd, targetPhase, options, raw) {
774
- if (!targetPhase) {
775
- error('phase number required for phase remove');
776
- }
777
-
778
- const roadmapPath = path.join(planningPath(cwd), ROADMAP_FILE);
779
- const phasesDir = phasesPath(cwd);
780
- const force = options.force || false;
781
-
782
- if (!fileAccessible(roadmapPath)) {
783
- error('roadmap.md not found');
784
- }
785
-
786
- // Normalize the target
787
- const normalized = normalizePhaseName(targetPhase);
788
- const isDecimal = targetPhase.includes('.');
789
-
790
- // Find and validate target directory
791
- let targetDir = null;
792
- try {
793
- const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
794
- const dirs = entries.filter(entry => entry.isDirectory()).map(entry => entry.name).sort((left, right) => comparePhaseNum(left, right));
795
- targetDir = dirs.find(dir => dir.startsWith(normalized + '-') || dir === normalized);
796
- } catch {
797
- // Phases directory does not exist; targetDir remains null
798
- }
799
-
800
- // Check for executed work (summary.md files)
801
- if (targetDir && !force) {
802
- const targetPath = path.join(phasesDir, targetDir);
803
- const files = fs.readdirSync(targetPath);
804
- const summaries = files.filter(isSummaryFile);
805
- if (summaries.length > 0) {
806
- error(`Phase ${targetPhase} has ${summaries.length} executed plan(s). Use --force to remove anyway.`);
807
- }
808
- }
809
-
810
- // Delete target directory
811
- let removeWarning = null;
812
- if (targetDir) {
813
- const removeResult = removePhaseFromDisk(path.join(phasesDir, targetDir));
814
- if (!removeResult.removed) {
815
- removeWarning = removeResult.error;
816
- }
817
- }
818
-
819
- // Renumber subsequent phases using the appropriate strategy
820
- let renamedDirs = [];
821
- let renamedFiles = [];
822
-
823
- let renameError = null;
824
- if (isDecimal) {
825
- // Decimal removal: renumber sibling decimals (e.g., removing 06.2 -> 06.3 becomes 06.2)
826
- const baseParts = normalized.split('.');
827
- const baseInt = baseParts[0];
828
- const removedDecimal = parseInt(baseParts[1], 10);
829
- const result = renumberDecimalPhases(phasesDir, baseInt, removedDecimal);
830
- renamedDirs = result.renamedDirs;
831
- renamedFiles = result.renamedFiles;
832
- renameError = result.error || null;
833
- } else {
834
- // Integer removal: renumber all subsequent integer phases
835
- const removedInt = parseInt(normalized, 10);
836
- const result = renumberIntegerPhases(phasesDir, removedInt);
837
- renamedDirs = result.renamedDirs;
838
- renamedFiles = result.renamedFiles;
839
- renameError = result.error || null;
840
- }
841
-
842
- // Update roadmap.md: remove section and renumber references
843
- updateRoadmapAfterRemoval(cwd, targetPhase, isDecimal, normalized);
844
-
845
- // Update state.md phase count
846
- const stateUpdated = updateStateAfterPhaseRemoval(cwd);
847
-
848
- const result = {
849
- removed: targetPhase,
850
- directory_deleted: targetDir || null,
851
- renamed_directories: renamedDirs,
852
- renamed_files: renamedFiles,
853
- roadmap_updated: true,
854
- state_updated: stateUpdated,
855
- };
856
- if (renameError) result.rename_warning = renameError;
857
- if (removeWarning) result.remove_warning = removeWarning;
858
-
859
- output(result, raw);
860
- }
861
-
862
- /**
863
- * Decrement phase count in state.md after a phase is removed.
864
- * @param {string} cwd - Working directory path
865
- * @returns {boolean} true if state.md was updated
866
- */
867
- function updateStateAfterPhaseRemoval(cwd) {
868
- const statePath = path.join(planningPath(cwd), STATE_FILE);
869
- const content = readStateSafe(statePath);
870
- if (content === null) return false;
871
-
872
- let updated = content;
873
- // Decrement "Total Phases" field
874
- const totalPattern = /(\*\*Total Phases:\*\*\s*)(\d+)/;
875
- const totalMatch = updated.match(totalPattern);
876
- if (totalMatch) {
877
- updated = updated.replace(totalPattern, `$1${parseInt(totalMatch[2], 10) - 1}`);
878
- }
879
- // Decrement "Phase: X of Y" pattern
880
- const ofPattern = /(\bof\s+)(\d+)(\s*(?:\(|phases?))/i;
881
- const ofMatch = updated.match(ofPattern);
882
- if (ofMatch) {
883
- updated = updated.replace(ofPattern, `$1${parseInt(ofMatch[2], 10) - 1}$3`);
884
- }
885
- writeStateMd(statePath, updated, cwd);
886
- return true;
887
- }
522
+ // ─── Phase removal — extracted to phase-remove.cjs (re-exported below) ──────
888
523
 
889
524
  // ─── cmdPhaseComplete helpers ───────────────────────────────────────────────
890
525
 
@@ -313,6 +313,11 @@ function runExperiment(slug, opts = {}) {
313
313
  output_tokens: envelope.usage?.output_tokens ?? null,
314
314
  cache_creation_input_tokens: envelope.usage?.cache_creation_input_tokens ?? null,
315
315
  cache_read_input_tokens: envelope.usage?.cache_read_input_tokens ?? null,
316
+ // Headless `claude -p` usage bills against the Claude Agent SDK
317
+ // credit pool — separate from interactive subscription limits since
318
+ // June 15, 2026. Tagged so /pan:learn and billing reconciliation can
319
+ // separate experiment spend from interactive-session spend.
320
+ billing_pool: (manifest.runtime === 'claude') ? 'agent_sdk' : null,
316
321
  };
317
322
  appendEvent(runState, 'metrics_captured', `cost=$${envelope.total_cost_usd ?? '?'}, turns=${envelope.num_turns ?? '?'}`);
318
323
  } else {
@@ -6,6 +6,7 @@ const fs = require('fs');
6
6
  const path = require('path');
7
7
  const { loadConfig, getMilestoneInfo, escapeRegex, safeReadFile, output, error } = require('./core.cjs');
8
8
  const { extractFrontmatter, reconstructFrontmatter } = require('./frontmatter.cjs');
9
+ const { withFileLock, writeFileAtomic } = require('./lock.cjs');
9
10
  const {
10
11
  PLANNING_DIR,
11
12
  STATE_FILE,
@@ -855,11 +856,19 @@ function syncStateFrontmatter(content, cwd) {
855
856
  /**
856
857
  * Write state.md with synchronized YAML frontmatter.
857
858
  * All state.md writes should use this instead of raw writeFileSync.
859
+ *
860
+ * Concurrency (ADR-0030): the write is serialized behind an advisory
861
+ * state.md.lock and lands atomically (temp + rename), so concurrent agents
862
+ * cannot tear the file or interleave read-modify-write cycles. Lock
863
+ * acquisition is best-effort — on timeout the write proceeds unlocked,
864
+ * preserving single-agent behavior exactly.
858
865
  */
859
866
  function writeStateMd(statePath, content, cwd) {
860
867
  const synced = syncStateFrontmatter(content, cwd);
861
868
  try {
862
- fs.writeFileSync(statePath, synced, 'utf-8');
869
+ withFileLock(statePath, () => {
870
+ writeFileAtomic(statePath, synced);
871
+ });
863
872
  } catch (err) {
864
873
  throw new Error('Failed to write state.md: ' + err.message);
865
874
  }
@@ -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
+ };