pan-wizard 3.8.0 → 3.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +80 -9
  2. package/agents/pan-conductor.md +15 -3
  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-release.md +58 -0
  20. package/agents/pan-research-synthesizer.md +7 -0
  21. package/agents/pan-reviewer.md +2 -3
  22. package/agents/pan-roadmapper.md +1 -0
  23. package/agents/pan-verifier.md +1 -2
  24. package/assets/pan-avatar.png +0 -0
  25. package/assets/pan-developer.png +0 -0
  26. package/assets/pan-docs-header.png +0 -0
  27. package/assets/pan-hero.png +0 -0
  28. package/assets/pan-logo-2000-transparent.svg +11 -30
  29. package/assets/pan-logo-2000.svg +12 -43
  30. package/assets/pan-logo-lockup.svg +11 -0
  31. package/assets/pan-mark.svg +7 -0
  32. package/assets/pan-orchestration.png +0 -0
  33. package/assets/pan-readme-hero.png +0 -0
  34. package/assets/terminal.svg +39 -119
  35. package/bin/install-lib.cjs +661 -46
  36. package/bin/install.js +722 -116
  37. package/commands/pan/army.md +169 -0
  38. package/commands/pan/dashboard.md +25 -0
  39. package/commands/pan/experiment.md +2 -0
  40. package/commands/pan/focus-auto.md +32 -4
  41. package/commands/pan/hud.md +91 -0
  42. package/commands/pan/profile.md +2 -0
  43. package/hooks/dist/pan-cost-logger.js +22 -7
  44. package/package.json +5 -4
  45. package/pan-wizard-core/bin/lib/campaign.cjs +198 -0
  46. package/pan-wizard-core/bin/lib/commands-learnings.cjs +544 -0
  47. package/pan-wizard-core/bin/lib/commands.cjs +12 -523
  48. package/pan-wizard-core/bin/lib/constants.cjs +8 -0
  49. package/pan-wizard-core/bin/lib/core.cjs +80 -0
  50. package/pan-wizard-core/bin/lib/cost.cjs +62 -8
  51. package/pan-wizard-core/bin/lib/focus.cjs +13 -1
  52. package/pan-wizard-core/bin/lib/git.cjs +6 -1
  53. package/pan-wizard-core/bin/lib/hud.cjs +887 -0
  54. package/pan-wizard-core/bin/lib/lock.cjs +108 -0
  55. package/pan-wizard-core/bin/lib/milestone.cjs +3 -2
  56. package/pan-wizard-core/bin/lib/phase-remove.cjs +392 -0
  57. package/pan-wizard-core/bin/lib/phase.cjs +4 -369
  58. package/pan-wizard-core/bin/lib/runner.cjs +5 -0
  59. package/pan-wizard-core/bin/lib/squads.cjs +152 -0
  60. package/pan-wizard-core/bin/lib/state.cjs +10 -1
  61. package/pan-wizard-core/bin/lib/verify-deploy.cjs +181 -0
  62. package/pan-wizard-core/bin/lib/verify-drift.cjs +255 -0
  63. package/pan-wizard-core/bin/lib/verify-preflight.cjs +261 -0
  64. package/pan-wizard-core/bin/lib/verify-retro.cjs +177 -0
  65. package/pan-wizard-core/bin/lib/verify.cjs +10 -797
  66. package/pan-wizard-core/bin/lib/worktree.cjs +123 -0
  67. package/pan-wizard-core/bin/pan-tools.cjs +78 -0
  68. package/pan-wizard-core/learnings/universal/autonomous-loop.md +56 -0
  69. package/pan-wizard-core/workflows/plan-phase.md +11 -0
  70. package/scripts/build-plugin.js +105 -0
  71. package/scripts/install-git-hooks.js +64 -0
  72. 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 {
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Squads — agent groupings for the bot-army model (ADR-0032).
3
+ *
4
+ * A squad is a named, tool-scoped, model-tiered grouping of existing PAN
5
+ * agents under the pan-conductor coordinator. This module is a registry +
6
+ * resolver only — it modifies no agent and changes no execution path. The
7
+ * army campaign command (ADR-0033) consumes it; until then it is inert.
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ const { output, error } = require('./core.cjs');
13
+
14
+ /**
15
+ * Coordinator + worker/utility agents that are NOT squad members.
16
+ * - coordinator: the top of the hierarchy (Tier 0).
17
+ * - workers: cheap narrow-job agents (Tier 2) + standalone utility agents
18
+ * invoked directly by commands, not delegated through a squad.
19
+ */
20
+ const COORDINATOR = 'pan-conductor';
21
+ const WORKERS = Object.freeze([
22
+ 'pan-document_code', // Haiku-tier codebase mapper
23
+ 'pan-distiller', // Haiku-tier code-bloat optimizer
24
+ 'pan-optimizer', // optimization loop
25
+ 'pan-experiment-runner', // self-improvement loop
26
+ 'pan-knowledge', // retrieval/Q&A
27
+ 'pan-counterfactual', // what-if worktree replay
28
+ 'pan-previewer', // foresight synthesis
29
+ ]);
30
+
31
+ /**
32
+ * The four squads, keyed by lifecycle role. `tier` is a PAN model tier
33
+ * (resolve-model maps it to a provider model); `access` is the least-privilege
34
+ * tool contract the conductor grants when delegating to the squad.
35
+ */
36
+ const SQUADS = Object.freeze({
37
+ architecture: Object.freeze({
38
+ label: 'Architecture',
39
+ tier: 'reasoning',
40
+ access: 'read-only',
41
+ summary: 'Designs the system before code — contract-first.',
42
+ agents: Object.freeze([
43
+ 'pan-roadmapper', 'pan-planner', 'pan-plan-checker',
44
+ 'pan-project-researcher', 'pan-phase-researcher', 'pan-research-synthesizer',
45
+ ]),
46
+ }),
47
+ build: Object.freeze({
48
+ label: 'Build',
49
+ tier: 'reasoning',
50
+ access: 'read-write-bash',
51
+ summary: 'Turns design and contracts into committed code.',
52
+ agents: Object.freeze(['pan-executor']),
53
+ }),
54
+ quality: Object.freeze({
55
+ label: 'Quality',
56
+ tier: 'mid',
57
+ access: 'read-only',
58
+ summary: 'Adversarially breaks what Build makes before users do.',
59
+ agents: Object.freeze([
60
+ 'pan-reviewer', 'pan-hardener', 'pan-meta-reviewer',
61
+ 'pan-verifier', 'pan-integration-checker', 'pan-debugger',
62
+ ]),
63
+ }),
64
+ release: Object.freeze({
65
+ label: 'Release',
66
+ tier: 'mid',
67
+ access: 'always-ask',
68
+ summary: 'Ships safely behind a human gate; rolls back fast.',
69
+ agents: Object.freeze(['pan-release']),
70
+ }),
71
+ });
72
+
73
+ const SQUAD_NAMES = Object.freeze(Object.keys(SQUADS));
74
+
75
+ /** @returns {Array<{name, label, tier, access, summary, agent_count}>} */
76
+ function listSquads() {
77
+ return SQUAD_NAMES.map(name => {
78
+ const s = SQUADS[name];
79
+ return { name, label: s.label, tier: s.tier, access: s.access, summary: s.summary, agent_count: s.agents.length };
80
+ });
81
+ }
82
+
83
+ /** @returns {object|null} the squad record (with name) or null if unknown. */
84
+ function getSquad(name) {
85
+ const s = SQUADS[name];
86
+ return s ? { name, ...s, agents: [...s.agents] } : null;
87
+ }
88
+
89
+ /** Reverse lookup: which squad owns an agent? @returns {string|null} */
90
+ function squadForAgent(agent) {
91
+ for (const name of SQUAD_NAMES) {
92
+ if (SQUADS[name].agents.includes(agent)) return name;
93
+ }
94
+ return null;
95
+ }
96
+
97
+ /**
98
+ * Validate the roster against the set of real agents.
99
+ * @param {string[]} knownAgents - agent names that exist on disk
100
+ * @returns {{ ok: boolean, missing: string[], unassigned: string[] }}
101
+ * missing = squad members with no agent file
102
+ * unassigned = real agents that are neither coordinator, worker, nor squad member
103
+ */
104
+ function validateRoster(knownAgents) {
105
+ const known = new Set(knownAgents);
106
+ const missing = [];
107
+ for (const name of SQUAD_NAMES) {
108
+ for (const a of SQUADS[name].agents) {
109
+ if (!known.has(a)) missing.push(a);
110
+ }
111
+ }
112
+ const assigned = new Set([COORDINATOR, ...WORKERS]);
113
+ for (const name of SQUAD_NAMES) for (const a of SQUADS[name].agents) assigned.add(a);
114
+ const unassigned = knownAgents.filter(a => !assigned.has(a));
115
+ return { ok: missing.length === 0 && unassigned.length === 0, missing, unassigned };
116
+ }
117
+
118
+ // ─── CLI ─────────────────────────────────────────────────────────────────────
119
+
120
+ function cmdSquadList(raw) {
121
+ const squads = listSquads();
122
+ const human = squads
123
+ .map(s => `${s.label.padEnd(13)} ${s.tier.padEnd(10)} ${s.access.padEnd(16)} ${s.agent_count} agents`)
124
+ .join('\n');
125
+ output({ squads, coordinator: COORDINATOR, workers: [...WORKERS] }, raw, human);
126
+ }
127
+
128
+ function cmdSquadShow(name, raw) {
129
+ const s = getSquad(name);
130
+ if (!s) {
131
+ return error(`Unknown squad "${name}". Available: ${SQUAD_NAMES.join(', ')}`);
132
+ }
133
+ const human = [
134
+ `${s.label} squad — ${s.tier} tier · ${s.access}`,
135
+ s.summary,
136
+ s.agents.length ? 'Agents: ' + s.agents.join(', ') : 'Agents: (none — git-tool driven)',
137
+ ].join('\n');
138
+ output(s, raw, human);
139
+ }
140
+
141
+ module.exports = {
142
+ SQUADS,
143
+ SQUAD_NAMES,
144
+ COORDINATOR,
145
+ WORKERS,
146
+ listSquads,
147
+ getSquad,
148
+ squadForAgent,
149
+ validateRoster,
150
+ cmdSquadList,
151
+ cmdSquadShow,
152
+ };
@@ -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
  }