pan-wizard 3.7.10 → 3.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -2
- package/agents/pan-conductor.md +1 -2
- package/agents/pan-counterfactual.md +1 -2
- package/agents/pan-debugger.md +1 -2
- package/agents/pan-distiller.md +1 -2
- package/agents/pan-document_code.md +1 -0
- package/agents/pan-executor.md +1 -0
- package/agents/pan-experiment-runner.md +1 -2
- package/agents/pan-hardener.md +1 -2
- package/agents/pan-integration-checker.md +1 -2
- package/agents/pan-knowledge.md +1 -2
- package/agents/pan-meta-reviewer.md +1 -2
- package/agents/pan-optimizer.md +1 -0
- package/agents/pan-phase-researcher.md +1 -0
- package/agents/pan-plan-checker.md +1 -2
- package/agents/pan-planner.md +1 -0
- package/agents/pan-previewer.md +1 -2
- package/agents/pan-project-researcher.md +6 -0
- package/agents/pan-research-synthesizer.md +7 -0
- package/agents/pan-reviewer.md +2 -3
- package/agents/pan-roadmapper.md +1 -0
- package/agents/pan-verifier.md +1 -2
- package/bin/install-lib.cjs +661 -46
- package/bin/install.js +722 -116
- package/commands/pan/experiment.md +2 -0
- package/commands/pan/links.md +102 -0
- package/commands/pan/profile.md +2 -0
- package/hooks/dist/pan-cost-logger.js +22 -7
- package/package.json +5 -4
- package/pan-wizard-core/bin/lib/codebase.cjs +2 -0
- package/pan-wizard-core/bin/lib/commands-learnings.cjs +544 -0
- package/pan-wizard-core/bin/lib/commands.cjs +12 -523
- package/pan-wizard-core/bin/lib/core.cjs +69 -0
- package/pan-wizard-core/bin/lib/cost.cjs +62 -8
- package/pan-wizard-core/bin/lib/experiment.cjs +1 -0
- package/pan-wizard-core/bin/lib/git.cjs +6 -1
- package/pan-wizard-core/bin/lib/links.cjs +549 -0
- package/pan-wizard-core/bin/lib/lock.cjs +108 -0
- package/pan-wizard-core/bin/lib/milestone.cjs +3 -2
- package/pan-wizard-core/bin/lib/phase-remove.cjs +392 -0
- package/pan-wizard-core/bin/lib/phase.cjs +4 -369
- package/pan-wizard-core/bin/lib/runner.cjs +6 -0
- package/pan-wizard-core/bin/lib/state.cjs +10 -1
- package/pan-wizard-core/bin/lib/verify-deploy.cjs +181 -0
- package/pan-wizard-core/bin/lib/verify-drift.cjs +255 -0
- package/pan-wizard-core/bin/lib/verify-preflight.cjs +261 -0
- package/pan-wizard-core/bin/lib/verify-retro.cjs +177 -0
- package/pan-wizard-core/bin/lib/verify.cjs +33 -797
- package/pan-wizard-core/bin/pan-tools.cjs +35 -1
- package/pan-wizard-core/workflows/plan-phase.md +11 -0
- package/scripts/build-plugin.js +105 -0
- package/scripts/git-hooks/pre-commit +40 -0
- package/scripts/install-git-hooks.js +64 -0
- package/scripts/release-check.js +13 -2
|
@@ -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
|
-
// ───
|
|
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
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
+
// @pan: ADR-0026
|
|
2
3
|
/**
|
|
3
4
|
* runner.cjs — Self-improvement loop W2: external agent runner.
|
|
4
5
|
*
|
|
@@ -312,6 +313,11 @@ function runExperiment(slug, opts = {}) {
|
|
|
312
313
|
output_tokens: envelope.usage?.output_tokens ?? null,
|
|
313
314
|
cache_creation_input_tokens: envelope.usage?.cache_creation_input_tokens ?? null,
|
|
314
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,
|
|
315
321
|
};
|
|
316
322
|
appendEvent(runState, 'metrics_captured', `cost=$${envelope.total_cost_usd ?? '?'}, turns=${envelope.num_turns ?? '?'}`);
|
|
317
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
|
-
|
|
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
|
+
};
|