@sienklogic/plan-build-run 2.34.0 → 2.38.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 (160) hide show
  1. package/CHANGELOG.md +683 -0
  2. package/dashboard/public/css/command-center.css +152 -65
  3. package/dashboard/public/css/explorer.css +22 -41
  4. package/dashboard/public/css/layout.css +119 -1
  5. package/dashboard/public/css/tokens.css +13 -0
  6. package/dashboard/src/components/Layout.tsx +32 -6
  7. package/dashboard/src/components/explorer/tabs/PhasesTab.tsx +11 -1
  8. package/dashboard/src/components/explorer/tabs/TodosTab.tsx +18 -2
  9. package/dashboard/src/components/partials/AttentionPanel.tsx +7 -1
  10. package/dashboard/src/components/partials/CurrentPhaseCard.tsx +26 -24
  11. package/dashboard/src/components/partials/QuickActions.tsx +21 -11
  12. package/dashboard/src/components/partials/StatCardGrid.tsx +67 -0
  13. package/dashboard/src/components/partials/StatusHeader.tsx +1 -0
  14. package/dashboard/src/routes/command-center.routes.tsx +8 -7
  15. package/dashboard/src/routes/index.routes.tsx +32 -29
  16. package/package.json +2 -2
  17. package/plugins/copilot-pbr/agents/audit.agent.md +129 -16
  18. package/plugins/copilot-pbr/agents/codebase-mapper.agent.md +49 -1
  19. package/plugins/copilot-pbr/agents/debugger.agent.md +50 -1
  20. package/plugins/copilot-pbr/agents/dev-sync.agent.md +23 -0
  21. package/plugins/copilot-pbr/agents/executor.agent.md +153 -8
  22. package/plugins/copilot-pbr/agents/general.agent.md +46 -1
  23. package/plugins/copilot-pbr/agents/integration-checker.agent.md +55 -2
  24. package/plugins/copilot-pbr/agents/plan-checker.agent.md +50 -2
  25. package/plugins/copilot-pbr/agents/planner.agent.md +80 -1
  26. package/plugins/copilot-pbr/agents/researcher.agent.md +50 -2
  27. package/plugins/copilot-pbr/agents/synthesizer.agent.md +49 -1
  28. package/plugins/copilot-pbr/agents/verifier.agent.md +114 -13
  29. package/plugins/copilot-pbr/commands/test.md +5 -0
  30. package/plugins/copilot-pbr/hooks/hooks.json +11 -0
  31. package/plugins/copilot-pbr/plugin.json +1 -1
  32. package/plugins/copilot-pbr/references/agent-contracts.md +27 -0
  33. package/plugins/copilot-pbr/references/checkpoints.md +32 -1
  34. package/plugins/copilot-pbr/references/context-quality-tiers.md +45 -0
  35. package/plugins/copilot-pbr/references/pbr-tools-cli.md +115 -0
  36. package/plugins/copilot-pbr/references/questioning.md +21 -1
  37. package/plugins/copilot-pbr/references/verification-patterns.md +96 -18
  38. package/plugins/copilot-pbr/skills/audit/SKILL.md +19 -3
  39. package/plugins/copilot-pbr/skills/begin/SKILL.md +57 -4
  40. package/plugins/copilot-pbr/skills/build/SKILL.md +39 -2
  41. package/plugins/copilot-pbr/skills/config/SKILL.md +12 -2
  42. package/plugins/copilot-pbr/skills/debug/SKILL.md +12 -1
  43. package/plugins/copilot-pbr/skills/explore/SKILL.md +13 -2
  44. package/plugins/copilot-pbr/skills/health/SKILL.md +13 -5
  45. package/plugins/copilot-pbr/skills/import/SKILL.md +26 -1
  46. package/plugins/copilot-pbr/skills/milestone/SKILL.md +15 -3
  47. package/plugins/copilot-pbr/skills/plan/SKILL.md +50 -0
  48. package/plugins/copilot-pbr/skills/quick/SKILL.md +21 -0
  49. package/plugins/copilot-pbr/skills/review/SKILL.md +45 -0
  50. package/plugins/copilot-pbr/skills/scan/SKILL.md +20 -0
  51. package/plugins/copilot-pbr/skills/setup/SKILL.md +9 -1
  52. package/plugins/copilot-pbr/skills/shared/context-budget.md +10 -0
  53. package/plugins/copilot-pbr/skills/shared/universal-anti-patterns.md +6 -0
  54. package/plugins/copilot-pbr/skills/test/SKILL.md +210 -0
  55. package/plugins/copilot-pbr/templates/SUMMARY-complex.md.tmpl +95 -0
  56. package/plugins/copilot-pbr/templates/SUMMARY-minimal.md.tmpl +48 -0
  57. package/plugins/cursor-pbr/.cursor-plugin/plugin.json +1 -1
  58. package/plugins/cursor-pbr/agents/audit.md +52 -5
  59. package/plugins/cursor-pbr/agents/codebase-mapper.md +49 -1
  60. package/plugins/cursor-pbr/agents/debugger.md +50 -1
  61. package/plugins/cursor-pbr/agents/dev-sync.md +23 -0
  62. package/plugins/cursor-pbr/agents/executor.md +153 -8
  63. package/plugins/cursor-pbr/agents/general.md +46 -1
  64. package/plugins/cursor-pbr/agents/integration-checker.md +54 -1
  65. package/plugins/cursor-pbr/agents/plan-checker.md +49 -1
  66. package/plugins/cursor-pbr/agents/planner.md +80 -1
  67. package/plugins/cursor-pbr/agents/researcher.md +49 -1
  68. package/plugins/cursor-pbr/agents/synthesizer.md +49 -1
  69. package/plugins/cursor-pbr/agents/verifier.md +113 -12
  70. package/plugins/cursor-pbr/commands/test.md +5 -0
  71. package/plugins/cursor-pbr/hooks/hooks.json +9 -0
  72. package/plugins/cursor-pbr/references/agent-contracts.md +27 -0
  73. package/plugins/cursor-pbr/references/checkpoints.md +32 -1
  74. package/plugins/cursor-pbr/references/context-quality-tiers.md +45 -0
  75. package/plugins/cursor-pbr/references/pbr-tools-cli.md +115 -0
  76. package/plugins/cursor-pbr/references/questioning.md +21 -1
  77. package/plugins/cursor-pbr/references/verification-patterns.md +96 -18
  78. package/plugins/cursor-pbr/skills/audit/SKILL.md +19 -3
  79. package/plugins/cursor-pbr/skills/begin/SKILL.md +57 -4
  80. package/plugins/cursor-pbr/skills/build/SKILL.md +37 -2
  81. package/plugins/cursor-pbr/skills/config/SKILL.md +12 -2
  82. package/plugins/cursor-pbr/skills/debug/SKILL.md +12 -1
  83. package/plugins/cursor-pbr/skills/explore/SKILL.md +13 -2
  84. package/plugins/cursor-pbr/skills/health/SKILL.md +14 -5
  85. package/plugins/cursor-pbr/skills/import/SKILL.md +26 -1
  86. package/plugins/cursor-pbr/skills/milestone/SKILL.md +15 -3
  87. package/plugins/cursor-pbr/skills/plan/SKILL.md +50 -0
  88. package/plugins/cursor-pbr/skills/quick/SKILL.md +21 -0
  89. package/plugins/cursor-pbr/skills/review/SKILL.md +45 -0
  90. package/plugins/cursor-pbr/skills/scan/SKILL.md +20 -0
  91. package/plugins/cursor-pbr/skills/setup/SKILL.md +9 -1
  92. package/plugins/cursor-pbr/skills/shared/context-budget.md +10 -0
  93. package/plugins/cursor-pbr/skills/shared/universal-anti-patterns.md +6 -0
  94. package/plugins/cursor-pbr/skills/test/SKILL.md +211 -0
  95. package/plugins/cursor-pbr/templates/SUMMARY-complex.md.tmpl +95 -0
  96. package/plugins/cursor-pbr/templates/SUMMARY-minimal.md.tmpl +48 -0
  97. package/plugins/pbr/.claude-plugin/plugin.json +1 -1
  98. package/plugins/pbr/agents/audit.md +45 -0
  99. package/plugins/pbr/agents/codebase-mapper.md +48 -0
  100. package/plugins/pbr/agents/debugger.md +49 -0
  101. package/plugins/pbr/agents/dev-sync.md +23 -0
  102. package/plugins/pbr/agents/executor.md +151 -6
  103. package/plugins/pbr/agents/general.md +45 -0
  104. package/plugins/pbr/agents/integration-checker.md +53 -0
  105. package/plugins/pbr/agents/plan-checker.md +48 -0
  106. package/plugins/pbr/agents/planner.md +78 -1
  107. package/plugins/pbr/agents/researcher.md +48 -0
  108. package/plugins/pbr/agents/synthesizer.md +48 -0
  109. package/plugins/pbr/agents/verifier.md +112 -11
  110. package/plugins/pbr/commands/test.md +5 -0
  111. package/plugins/pbr/hooks/hooks.json +9 -0
  112. package/plugins/pbr/references/agent-contracts.md +27 -0
  113. package/plugins/pbr/references/checkpoints.md +32 -0
  114. package/plugins/pbr/references/context-quality-tiers.md +45 -0
  115. package/plugins/pbr/references/pbr-tools-cli.md +115 -0
  116. package/plugins/pbr/references/questioning.md +21 -0
  117. package/plugins/pbr/references/verification-patterns.md +96 -17
  118. package/plugins/pbr/scripts/check-plan-format.js +13 -1
  119. package/plugins/pbr/scripts/check-state-sync.js +26 -7
  120. package/plugins/pbr/scripts/check-subagent-output.js +30 -2
  121. package/plugins/pbr/scripts/config-schema.json +11 -1
  122. package/plugins/pbr/scripts/context-bridge.js +265 -0
  123. package/plugins/pbr/scripts/lib/config.js +271 -0
  124. package/plugins/pbr/scripts/lib/core.js +587 -0
  125. package/plugins/pbr/scripts/lib/history.js +73 -0
  126. package/plugins/pbr/scripts/lib/init.js +166 -0
  127. package/plugins/pbr/scripts/lib/migrate.js +169 -0
  128. package/plugins/pbr/scripts/lib/phase.js +364 -0
  129. package/plugins/pbr/scripts/lib/roadmap.js +175 -0
  130. package/plugins/pbr/scripts/lib/state.js +397 -0
  131. package/plugins/pbr/scripts/lib/todo.js +300 -0
  132. package/plugins/pbr/scripts/pbr-tools.js +425 -1310
  133. package/plugins/pbr/scripts/post-write-dispatch.js +5 -4
  134. package/plugins/pbr/scripts/pre-write-dispatch.js +1 -1
  135. package/plugins/pbr/scripts/progress-tracker.js +1 -1
  136. package/plugins/pbr/scripts/suggest-compact.js +1 -1
  137. package/plugins/pbr/scripts/track-context-budget.js +53 -2
  138. package/plugins/pbr/scripts/validate-task.js +20 -28
  139. package/plugins/pbr/skills/audit/SKILL.md +19 -3
  140. package/plugins/pbr/skills/begin/SKILL.md +48 -2
  141. package/plugins/pbr/skills/build/SKILL.md +39 -2
  142. package/plugins/pbr/skills/config/SKILL.md +12 -2
  143. package/plugins/pbr/skills/debug/SKILL.md +12 -1
  144. package/plugins/pbr/skills/debug/templates/continuation-prompt.md.tmpl +12 -1
  145. package/plugins/pbr/skills/debug/templates/initial-investigation-prompt.md.tmpl +12 -5
  146. package/plugins/pbr/skills/explore/SKILL.md +13 -2
  147. package/plugins/pbr/skills/health/SKILL.md +14 -3
  148. package/plugins/pbr/skills/help/SKILL.md +2 -0
  149. package/plugins/pbr/skills/import/SKILL.md +26 -1
  150. package/plugins/pbr/skills/milestone/SKILL.md +15 -3
  151. package/plugins/pbr/skills/plan/SKILL.md +52 -2
  152. package/plugins/pbr/skills/quick/SKILL.md +21 -0
  153. package/plugins/pbr/skills/review/SKILL.md +46 -0
  154. package/plugins/pbr/skills/scan/SKILL.md +20 -0
  155. package/plugins/pbr/skills/setup/SKILL.md +9 -1
  156. package/plugins/pbr/skills/shared/context-budget.md +10 -0
  157. package/plugins/pbr/skills/shared/universal-anti-patterns.md +6 -0
  158. package/plugins/pbr/skills/test/SKILL.md +212 -0
  159. package/plugins/pbr/templates/SUMMARY-complex.md.tmpl +95 -0
  160. package/plugins/pbr/templates/SUMMARY-minimal.md.tmpl +48 -0
@@ -0,0 +1,166 @@
1
+ /**
2
+ * lib/init.js — Compound init commands for Plan-Build-Run tools.
3
+ *
4
+ * These aggregate state from multiple sources into single JSON payloads
5
+ * for skill initialization. Each init function returns everything a skill
6
+ * needs to start work without additional file reads.
7
+ */
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const { stateLoad, stateCheckProgress } = require('./state');
12
+ const { configLoad, resolveDepthProfile } = require('./config');
13
+ const { phaseInfo, planIndex } = require('./phase');
14
+
15
+ /**
16
+ * Initialize context for executing a phase (building plans).
17
+ *
18
+ * @param {string} phaseNum - Phase number
19
+ * @param {string} [planningDir] - Path to .planning directory
20
+ */
21
+ function initExecutePhase(phaseNum, planningDir) {
22
+ const dir = planningDir || path.join(process.env.PBR_PROJECT_ROOT || process.cwd(), '.planning');
23
+ const state = stateLoad(dir);
24
+ if (!state.exists) return { error: "No .planning/ directory found" };
25
+ const phase = phaseInfo(phaseNum, dir);
26
+ if (phase.error) return { error: phase.error };
27
+ const plans = planIndex(phaseNum, dir);
28
+ const config = configLoad(dir) || {};
29
+ const depthProfile = resolveDepthProfile(config);
30
+ const models = config.models || {};
31
+ let gitState = { branch: null, clean: null };
32
+ try {
33
+ const { execSync } = require("child_process");
34
+ const branch = execSync("git rev-parse --abbrev-ref HEAD", { encoding: "utf8", timeout: 5000 }).trim();
35
+ const status = execSync("git status --porcelain", { encoding: "utf8", timeout: 5000 }).trim();
36
+ gitState = { branch, clean: status === "" };
37
+ } catch (_e) { /* not a git repo */ }
38
+ return {
39
+ executor_model: models.executor || "sonnet",
40
+ verifier_model: models.verifier || "sonnet",
41
+ config: { depth: depthProfile.depth, mode: config.mode || "interactive", parallelization: config.parallelization || { enabled: false }, planning: config.planning || {}, gates: config.gates || {}, features: config.features || {} },
42
+ phase: { num: phaseNum, dir: phase.phase, name: phase.name, goal: phase.goal, has_context: phase.has_context, status: phase.filesystem_status, plan_count: phase.plan_count, completed: phase.completed },
43
+ plans: (plans.plans || []).map(p => ({ file: p.file, plan_id: p.plan_id, wave: p.wave, autonomous: p.autonomous, has_summary: p.has_summary, must_haves_count: p.must_haves_count, depends_on: p.depends_on })),
44
+ waves: plans.waves || {},
45
+ branch_name: gitState.branch, git_clean: gitState.clean
46
+ };
47
+ }
48
+
49
+ /**
50
+ * Initialize context for planning a phase.
51
+ *
52
+ * @param {string} phaseNum - Phase number
53
+ * @param {string} [planningDir] - Path to .planning directory
54
+ */
55
+ function initPlanPhase(phaseNum, planningDir) {
56
+ const dir = planningDir || path.join(process.env.PBR_PROJECT_ROOT || process.cwd(), '.planning');
57
+ const state = stateLoad(dir);
58
+ if (!state.exists) return { error: "No .planning/ directory found" };
59
+ const config = configLoad(dir) || {};
60
+ const models = config.models || {};
61
+ const depthProfile = resolveDepthProfile(config);
62
+ const phasesDir = path.join(dir, "phases");
63
+ const paddedPhase = String(phaseNum).padStart(2, "0");
64
+ let existingArtifacts = [], phaseDirName = null;
65
+ if (fs.existsSync(phasesDir)) {
66
+ const dirs = fs.readdirSync(phasesDir).filter(d => d.startsWith(paddedPhase + "-"));
67
+ if (dirs.length > 0) { phaseDirName = dirs[0]; existingArtifacts = fs.readdirSync(path.join(phasesDir, phaseDirName)).filter(f => f.endsWith(".md")); }
68
+ }
69
+ let phaseGoal = null, phaseDeps = null;
70
+ if (state.roadmap && state.roadmap.phases) {
71
+ const rp = state.roadmap.phases.find(p => p.number === paddedPhase);
72
+ if (rp) { phaseGoal = rp.goal; phaseDeps = rp.depends_on; }
73
+ }
74
+ return {
75
+ researcher_model: models.researcher || "sonnet", planner_model: models.planner || "sonnet", checker_model: models.planner || "sonnet",
76
+ config: { depth: depthProfile.depth, profile: depthProfile.profile, features: config.features || {}, planning: config.planning || {} },
77
+ phase: { num: phaseNum, dir: phaseDirName, goal: phaseGoal, depends_on: phaseDeps },
78
+ existing_artifacts: existingArtifacts,
79
+ workflow: { research_phase: (config.features || {}).research_phase !== false, plan_checking: (config.features || {}).plan_checking !== false }
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Initialize context for a quick task.
85
+ *
86
+ * @param {string} description - Task description
87
+ * @param {string} [planningDir] - Path to .planning directory
88
+ */
89
+ function initQuick(description, planningDir) {
90
+ const dir = planningDir || path.join(process.env.PBR_PROJECT_ROOT || process.cwd(), '.planning');
91
+ const config = configLoad(dir) || {};
92
+ const quickDir = path.join(dir, "quick");
93
+ let nextNum = 1;
94
+ if (fs.existsSync(quickDir)) {
95
+ const dirs = fs.readdirSync(quickDir).filter(d => /^\d{3}-/.test(d)).sort();
96
+ if (dirs.length > 0) { nextNum = parseInt(dirs[dirs.length - 1].substring(0, 3), 10) + 1; }
97
+ }
98
+ const slug = (description || "task").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").substring(0, 30);
99
+ const paddedNum = String(nextNum).padStart(3, "0");
100
+ return {
101
+ next_task_number: paddedNum, slug, dir: path.join(".planning", "quick", paddedNum + "-" + slug),
102
+ dir_name: paddedNum + "-" + slug, timestamp: new Date().toISOString(),
103
+ config: { depth: config.depth || "standard", mode: config.mode || "interactive", models: config.models || {}, planning: config.planning || {} }
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Initialize context for verifying phase work.
109
+ *
110
+ * @param {string} phaseNum - Phase number
111
+ * @param {string} [planningDir] - Path to .planning directory
112
+ */
113
+ function initVerifyWork(phaseNum, planningDir) {
114
+ const dir = planningDir || path.join(process.env.PBR_PROJECT_ROOT || process.cwd(), '.planning');
115
+ const phase = phaseInfo(phaseNum, dir);
116
+ if (phase.error) return { error: phase.error };
117
+ const config = configLoad(dir) || {};
118
+ const models = config.models || {};
119
+ let priorAttempts = 0;
120
+ if (phase.verification) { priorAttempts = parseInt(phase.verification.attempt, 10) || 0; }
121
+ return {
122
+ verifier_model: models.verifier || "sonnet",
123
+ phase: { num: phaseNum, dir: phase.phase, name: phase.name, goal: phase.goal, plan_count: phase.plan_count, completed: phase.completed },
124
+ has_verification: !!phase.verification, prior_attempts: priorAttempts,
125
+ prior_status: phase.verification ? (phase.verification.status || "unknown") : null,
126
+ summaries: phase.summaries || []
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Initialize context for resuming work.
132
+ *
133
+ * @param {string} [planningDir] - Path to .planning directory
134
+ */
135
+ function initResume(planningDir) {
136
+ const dir = planningDir || path.join(process.env.PBR_PROJECT_ROOT || process.cwd(), '.planning');
137
+ const state = stateLoad(dir);
138
+ if (!state.exists) return { error: "No .planning/ directory found" };
139
+ let autoNext = null, continueHere = null, activeSkill = null;
140
+ try { autoNext = fs.readFileSync(path.join(dir, ".auto-next"), "utf8").trim(); } catch (_e) { /* file not found */ }
141
+ try { continueHere = fs.readFileSync(path.join(dir, ".continue-here"), "utf8").trim(); } catch (_e) { /* file not found */ }
142
+ try { activeSkill = fs.readFileSync(path.join(dir, ".active-skill"), "utf8").trim(); } catch (_e) { /* file not found */ }
143
+ return { state: state.state, auto_next: autoNext, continue_here: continueHere, active_skill: activeSkill, current_phase: state.current_phase, progress: state.progress };
144
+ }
145
+
146
+ /**
147
+ * Initialize context for progress display.
148
+ *
149
+ * @param {string} [planningDir] - Path to .planning directory
150
+ */
151
+ function initProgress(planningDir) {
152
+ const dir = planningDir || path.join(process.env.PBR_PROJECT_ROOT || process.cwd(), '.planning');
153
+ const state = stateLoad(dir);
154
+ if (!state.exists) return { error: "No .planning/ directory found" };
155
+ const progress = stateCheckProgress(dir);
156
+ return { current_phase: state.current_phase, total_phases: state.phase_count, status: state.state ? state.state.status : null, phases: progress.phases, total_plans: progress.total_plans, completed_plans: progress.completed_plans, percentage: progress.percentage };
157
+ }
158
+
159
+ module.exports = {
160
+ initExecutePhase,
161
+ initPlanPhase,
162
+ initQuick,
163
+ initVerifyWork,
164
+ initResume,
165
+ initProgress
166
+ };
@@ -0,0 +1,169 @@
1
+ /**
2
+ * lib/migrate.js — Schema migration for Plan-Build-Run config.json.
3
+ *
4
+ * Tracks config.json schema version and applies sequential migrations
5
+ * to bring outdated configs up to the current version.
6
+ *
7
+ * Usage:
8
+ * const { applyMigrations, CURRENT_SCHEMA_VERSION } = require('./migrate');
9
+ * const result = await applyMigrations(planningDir, { dryRun: false });
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const { atomicWrite } = require('./core');
15
+
16
+ /** The current schema version supported by this version of PBR. */
17
+ const CURRENT_SCHEMA_VERSION = 1;
18
+
19
+ /**
20
+ * Migration registry. Each entry describes one schema version step.
21
+ * Migrations MUST be listed in ascending `from` order.
22
+ *
23
+ * @type {Array<{ from: number, to: number, description: string, migrate: function }>}
24
+ */
25
+ const MIGRATIONS = [
26
+ {
27
+ from: 0,
28
+ to: 1,
29
+ description: 'Add schema_version field',
30
+ migrate(config) {
31
+ config.schema_version = 1;
32
+ }
33
+ }
34
+ ];
35
+
36
+ /**
37
+ * Detect the current schema version from a config object.
38
+ * Returns 0 if schema_version is absent or non-numeric.
39
+ *
40
+ * @param {object} config - Parsed config.json object
41
+ * @returns {number} Detected schema version
42
+ */
43
+ function detectSchemaVersion(config) {
44
+ const v = config && config.schema_version;
45
+ if (typeof v === 'number' && Number.isFinite(v)) return v;
46
+ return 0;
47
+ }
48
+
49
+ /**
50
+ * Return the ordered list of migrations needed to go from `fromVersion` to `toVersion`.
51
+ * Returns an empty array if versions are equal.
52
+ * Throws if fromVersion > toVersion (downgrade not supported).
53
+ *
54
+ * @param {number} fromVersion - Current schema version
55
+ * @param {number} toVersion - Target schema version
56
+ * @returns {Array} Ordered migrations to apply
57
+ */
58
+ function getMigrationPath(fromVersion, toVersion) {
59
+ if (fromVersion > toVersion) {
60
+ throw new Error(`Cannot downgrade schema from version ${fromVersion} to ${toVersion}`);
61
+ }
62
+ if (fromVersion === toVersion) return [];
63
+ return MIGRATIONS.filter(m => m.from >= fromVersion && m.to <= toVersion);
64
+ }
65
+
66
+ /**
67
+ * Apply pending migrations to config.json in planningDir.
68
+ *
69
+ * Options:
70
+ * dryRun {boolean} — If true, simulate migration without writing files (default: false)
71
+ * force {boolean} — Reserved for future use (default: false)
72
+ *
73
+ * Returns:
74
+ * { migrated: false, version: N } — already current
75
+ * { migrated: false, message: string } — future version, no-op
76
+ * { migrated: true, fromVersion, toVersion, applied, backupPath } — success
77
+ * { error: string } — failure
78
+ *
79
+ * @param {string} planningDir - Path to .planning directory
80
+ * @param {object} [options] - Options { dryRun, force }
81
+ * @returns {Promise<object>} Result object
82
+ */
83
+ async function applyMigrations(planningDir, options) {
84
+ const opts = options || {};
85
+ const dryRun = opts.dryRun === true;
86
+
87
+ const configPath = path.join(planningDir, 'config.json');
88
+
89
+ // Load config.json
90
+ if (!fs.existsSync(configPath)) {
91
+ return { error: 'config.json not found in ' + planningDir };
92
+ }
93
+
94
+ let config;
95
+ try {
96
+ config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
97
+ } catch (e) {
98
+ return { error: 'config.json is not valid JSON: ' + e.message };
99
+ }
100
+
101
+ const currentVersion = detectSchemaVersion(config);
102
+
103
+ // Future version — don't touch it
104
+ if (currentVersion > CURRENT_SCHEMA_VERSION) {
105
+ return {
106
+ migrated: false,
107
+ version: currentVersion,
108
+ message: `config.json schema_version (${currentVersion}) is newer than this PBR version supports (${CURRENT_SCHEMA_VERSION}). No migration applied.`
109
+ };
110
+ }
111
+
112
+ // Already current
113
+ if (currentVersion === CURRENT_SCHEMA_VERSION) {
114
+ return { migrated: false, version: currentVersion };
115
+ }
116
+
117
+ // Determine migrations to apply
118
+ const migrations = getMigrationPath(currentVersion, CURRENT_SCHEMA_VERSION);
119
+ if (migrations.length === 0) {
120
+ return { migrated: false, version: currentVersion };
121
+ }
122
+
123
+ // Clone config for mutation
124
+ const updatedConfig = JSON.parse(JSON.stringify(config));
125
+
126
+ // Apply each migration in sequence
127
+ const applied = [];
128
+ for (const m of migrations) {
129
+ m.migrate(updatedConfig);
130
+ applied.push(m.description);
131
+ }
132
+
133
+ if (dryRun) {
134
+ return {
135
+ migrated: true,
136
+ fromVersion: currentVersion,
137
+ toVersion: CURRENT_SCHEMA_VERSION,
138
+ applied,
139
+ dryRun: true
140
+ };
141
+ }
142
+
143
+ // Create backup
144
+ const backupDir = path.join(planningDir, '.migration-backup');
145
+ const backupPath = path.join(backupDir, 'config.json.bak');
146
+ if (!fs.existsSync(backupDir)) {
147
+ fs.mkdirSync(backupDir, { recursive: true });
148
+ }
149
+ fs.copyFileSync(configPath, backupPath);
150
+
151
+ // Write updated config atomically
152
+ atomicWrite(configPath, JSON.stringify(updatedConfig, null, 2));
153
+
154
+ return {
155
+ migrated: true,
156
+ fromVersion: currentVersion,
157
+ toVersion: CURRENT_SCHEMA_VERSION,
158
+ applied,
159
+ backupPath
160
+ };
161
+ }
162
+
163
+ module.exports = {
164
+ CURRENT_SCHEMA_VERSION,
165
+ MIGRATIONS,
166
+ detectSchemaVersion,
167
+ getMigrationPath,
168
+ applyMigrations
169
+ };
@@ -0,0 +1,364 @@
1
+ /**
2
+ * lib/phase.js — Phase operations for Plan-Build-Run tools.
3
+ *
4
+ * Handles phase directory management (add/remove/list), plan indexing,
5
+ * must-haves collection, and comprehensive phase info.
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const {
11
+ parseYamlFrontmatter,
12
+ findFiles,
13
+ countMustHaves,
14
+ determinePhaseStatus
15
+ } = require('./core');
16
+
17
+ /**
18
+ * Parse a markdown file's YAML frontmatter and return as JSON.
19
+ * Wraps parseYamlFrontmatter().
20
+ *
21
+ * @param {string} filePath - Path to markdown file
22
+ * @returns {object} Parsed frontmatter or error
23
+ */
24
+ function frontmatter(filePath) {
25
+ const resolved = path.resolve(filePath);
26
+ if (!fs.existsSync(resolved)) {
27
+ return { error: `File not found: ${resolved}` };
28
+ }
29
+ const content = fs.readFileSync(resolved, 'utf8');
30
+ return parseYamlFrontmatter(content);
31
+ }
32
+
33
+ /**
34
+ * Plan inventory for a phase, grouped by wave.
35
+ *
36
+ * @param {string} phaseNum - Phase number
37
+ * @param {string} [planningDir] - Path to .planning directory
38
+ * @returns {object} Plan index
39
+ */
40
+ function planIndex(phaseNum, planningDir) {
41
+ const dir = planningDir || path.join(process.env.PBR_PROJECT_ROOT || process.cwd(), '.planning');
42
+ const phasesDir = path.join(dir, 'phases');
43
+ if (!fs.existsSync(phasesDir)) {
44
+ return { error: 'No phases directory found' };
45
+ }
46
+
47
+ // Find phase directory matching the number
48
+ const entries = fs.readdirSync(phasesDir, { withFileTypes: true })
49
+ .filter(e => e.isDirectory());
50
+
51
+ const phaseDir = entries.find(e => e.name.startsWith(phaseNum.padStart(2, '0') + '-'));
52
+ if (!phaseDir) {
53
+ return { error: `No phase directory found matching phase ${phaseNum}` };
54
+ }
55
+
56
+ const fullDir = path.join(phasesDir, phaseDir.name);
57
+ const planFiles = findFiles(fullDir, /-PLAN\.md$/);
58
+
59
+ const plans = [];
60
+ const waves = {};
61
+
62
+ for (const file of planFiles) {
63
+ const content = fs.readFileSync(path.join(fullDir, file), 'utf8');
64
+ const fm = parseYamlFrontmatter(content);
65
+
66
+ const plan = {
67
+ file,
68
+ plan_id: fm.plan || file.replace(/-PLAN\.md$/, ''),
69
+ wave: parseInt(fm.wave, 10) || 1,
70
+ type: fm.type || 'unknown',
71
+ autonomous: fm.autonomous !== false,
72
+ depends_on: fm.depends_on || [],
73
+ gap_closure: fm.gap_closure || false,
74
+ has_summary: fs.existsSync(path.join(fullDir, `SUMMARY-${fm.plan || ''}.md`)),
75
+ must_haves_count: countMustHaves(fm.must_haves)
76
+ };
77
+
78
+ plans.push(plan);
79
+
80
+ const waveKey = `wave_${plan.wave}`;
81
+ if (!waves[waveKey]) waves[waveKey] = [];
82
+ waves[waveKey].push(plan.plan_id);
83
+ }
84
+
85
+ return {
86
+ phase: phaseDir.name,
87
+ total_plans: plans.length,
88
+ plans,
89
+ waves
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Collect all must-haves from all PLAN.md files in a phase.
95
+ * Returns per-plan grouping + flat deduplicated list + total count.
96
+ *
97
+ * @param {string} phaseNum - Phase number
98
+ * @param {string} [planningDir] - Path to .planning directory
99
+ */
100
+ function mustHavesCollect(phaseNum, planningDir) {
101
+ const dir = planningDir || path.join(process.env.PBR_PROJECT_ROOT || process.cwd(), '.planning');
102
+ const phasesDir = path.join(dir, 'phases');
103
+ if (!fs.existsSync(phasesDir)) {
104
+ return { error: 'No phases directory found' };
105
+ }
106
+
107
+ const entries = fs.readdirSync(phasesDir, { withFileTypes: true })
108
+ .filter(e => e.isDirectory());
109
+ const phaseDir = entries.find(e => e.name.startsWith(phaseNum.padStart(2, '0') + '-'));
110
+ if (!phaseDir) {
111
+ return { error: `No phase directory found matching phase ${phaseNum}` };
112
+ }
113
+
114
+ const fullDir = path.join(phasesDir, phaseDir.name);
115
+ const planFiles = findFiles(fullDir, /-PLAN\.md$/);
116
+
117
+ const perPlan = {};
118
+ const allTruths = new Set();
119
+ const allArtifacts = new Set();
120
+ const allKeyLinks = new Set();
121
+
122
+ for (const file of planFiles) {
123
+ const content = fs.readFileSync(path.join(fullDir, file), 'utf8');
124
+ const fm = parseYamlFrontmatter(content);
125
+ const planId = fm.plan || file.replace(/-PLAN\.md$/, '');
126
+ const mh = fm.must_haves || { truths: [], artifacts: [], key_links: [] };
127
+
128
+ perPlan[planId] = mh;
129
+ (mh.truths || []).forEach(t => allTruths.add(t));
130
+ (mh.artifacts || []).forEach(a => allArtifacts.add(a));
131
+ (mh.key_links || []).forEach(k => allKeyLinks.add(k));
132
+ }
133
+
134
+ const all = {
135
+ truths: [...allTruths],
136
+ artifacts: [...allArtifacts],
137
+ key_links: [...allKeyLinks]
138
+ };
139
+
140
+ return {
141
+ phase: phaseDir.name,
142
+ plans: perPlan,
143
+ all,
144
+ total: all.truths.length + all.artifacts.length + all.key_links.length
145
+ };
146
+ }
147
+
148
+ /**
149
+ * Comprehensive single-phase status combining roadmap, filesystem, and plan data.
150
+ *
151
+ * @param {string} phaseNum - Phase number
152
+ * @param {string} [planningDir] - Path to .planning directory
153
+ */
154
+ function phaseInfo(phaseNum, planningDir) {
155
+ const dir = planningDir || path.join(process.env.PBR_PROJECT_ROOT || process.cwd(), '.planning');
156
+ const { parseRoadmapMd } = require('./roadmap');
157
+ const phasesDir = path.join(dir, 'phases');
158
+ if (!fs.existsSync(phasesDir)) {
159
+ return { error: 'No phases directory found' };
160
+ }
161
+
162
+ const entries = fs.readdirSync(phasesDir, { withFileTypes: true })
163
+ .filter(e => e.isDirectory());
164
+ const phaseDir = entries.find(e => e.name.startsWith(phaseNum.padStart(2, '0') + '-'));
165
+ if (!phaseDir) {
166
+ return { error: `No phase directory found matching phase ${phaseNum}` };
167
+ }
168
+
169
+ const fullDir = path.join(phasesDir, phaseDir.name);
170
+
171
+ // Get roadmap info
172
+ let roadmapInfo = null;
173
+ const roadmapPath = path.join(dir, 'ROADMAP.md');
174
+ if (fs.existsSync(roadmapPath)) {
175
+ const roadmapContent = fs.readFileSync(roadmapPath, 'utf8');
176
+ const roadmap = parseRoadmapMd(roadmapContent);
177
+ roadmapInfo = roadmap.phases.find(p => p.number === phaseNum.padStart(2, '0')) || null;
178
+ }
179
+
180
+ // Get plan index
181
+ const plans = planIndex(phaseNum, dir);
182
+
183
+ // Check for verification
184
+ const verificationPath = path.join(fullDir, 'VERIFICATION.md');
185
+ let verification = null;
186
+ if (fs.existsSync(verificationPath)) {
187
+ const vContent = fs.readFileSync(verificationPath, 'utf8');
188
+ verification = parseYamlFrontmatter(vContent);
189
+ }
190
+
191
+ // Check summaries
192
+ const summaryFiles = findFiles(fullDir, /^SUMMARY-.*\.md$/);
193
+ const summaries = summaryFiles.map(f => {
194
+ const content = fs.readFileSync(path.join(fullDir, f), 'utf8');
195
+ const fm = parseYamlFrontmatter(content);
196
+ return { file: f, plan: fm.plan || f.replace(/^SUMMARY-|\.md$/g, ''), status: fm.status || 'unknown' };
197
+ });
198
+
199
+ // Determine filesystem status
200
+ const planCount = plans.total_plans || 0;
201
+ const completedCount = summaries.filter(s => s.status === 'complete').length;
202
+ const hasVerification = fs.existsSync(verificationPath);
203
+ const fsStatus = determinePhaseStatus(planCount, completedCount, summaryFiles.length, hasVerification, fullDir);
204
+
205
+ return {
206
+ phase: phaseDir.name,
207
+ name: roadmapInfo ? roadmapInfo.name : phaseDir.name.replace(/^\d+-/, ''),
208
+ goal: roadmapInfo ? roadmapInfo.goal : null,
209
+ roadmap_status: roadmapInfo ? roadmapInfo.status : null,
210
+ filesystem_status: fsStatus,
211
+ plans: plans.plans || [],
212
+ plan_count: planCount,
213
+ summaries,
214
+ completed: completedCount,
215
+ verification,
216
+ has_context: fs.existsSync(path.join(fullDir, 'CONTEXT.md'))
217
+ };
218
+ }
219
+
220
+ /**
221
+ * Add a new phase directory (with renumbering).
222
+ *
223
+ * @param {string} slug - Phase slug
224
+ * @param {string|null} afterPhase - Insert after this phase number
225
+ * @param {string} [planningDir] - Path to .planning directory
226
+ */
227
+ function phaseAdd(slug, afterPhase, planningDir) {
228
+ const dir = planningDir || path.join(process.env.PBR_PROJECT_ROOT || process.cwd(), '.planning');
229
+ const phasesDir = path.join(dir, 'phases');
230
+ if (!fs.existsSync(phasesDir)) {
231
+ fs.mkdirSync(phasesDir, { recursive: true });
232
+ }
233
+
234
+ // Determine next phase number
235
+ const existing = fs.readdirSync(phasesDir)
236
+ .filter(d => /^\d+-/.test(d))
237
+ .map(d => parseInt(d.split('-')[0], 10))
238
+ .sort((a, b) => a - b);
239
+
240
+ let newNum;
241
+ if (afterPhase) {
242
+ const after = parseInt(afterPhase, 10);
243
+ // Find the next number after the specified phase
244
+ const higher = existing.filter(n => n > after);
245
+ if (higher.length > 0) {
246
+ // Need to renumber: insert between after and next
247
+ newNum = after + 1;
248
+ // Renumber all phases >= newNum
249
+ for (const dirName of fs.readdirSync(phasesDir).sort().reverse()) {
250
+ const num = parseInt(dirName.split('-')[0], 10);
251
+ if (num >= newNum) {
252
+ const newName = dirName.replace(/^\d+/, String(num + 1).padStart(2, '0'));
253
+ fs.renameSync(path.join(phasesDir, dirName), path.join(phasesDir, newName));
254
+ }
255
+ }
256
+ } else {
257
+ newNum = after + 1;
258
+ }
259
+ } else {
260
+ newNum = existing.length > 0 ? Math.max(...existing) + 1 : 1;
261
+ }
262
+
263
+ const dirName = `${String(newNum).padStart(2, '0')}-${slug}`;
264
+ const fullPath = path.join(phasesDir, dirName);
265
+ fs.mkdirSync(fullPath, { recursive: true });
266
+
267
+ return {
268
+ phase: newNum,
269
+ slug,
270
+ directory: dirName,
271
+ path: fullPath,
272
+ renumbered: afterPhase ? true : false
273
+ };
274
+ }
275
+
276
+ /**
277
+ * Remove an empty phase directory (with renumbering).
278
+ *
279
+ * @param {string} phaseNum - Phase number to remove
280
+ * @param {string} [planningDir] - Path to .planning directory
281
+ */
282
+ function phaseRemove(phaseNum, planningDir) {
283
+ const dir = planningDir || path.join(process.env.PBR_PROJECT_ROOT || process.cwd(), '.planning');
284
+ const phasesDir = path.join(dir, 'phases');
285
+ const padded = String(phaseNum).padStart(2, '0');
286
+ const dirs = fs.readdirSync(phasesDir).filter(d => d.startsWith(padded + '-'));
287
+
288
+ if (dirs.length === 0) {
289
+ return { removed: false, error: `Phase ${phaseNum} not found` };
290
+ }
291
+
292
+ const dirName = dirs[0];
293
+ const fullPath = path.join(phasesDir, dirName);
294
+
295
+ // Check if phase has artifacts
296
+ const contents = fs.readdirSync(fullPath);
297
+ if (contents.length > 0) {
298
+ return {
299
+ removed: false,
300
+ error: `Phase ${phaseNum} (${dirName}) has ${contents.length} files. Remove contents first or use --force.`,
301
+ files: contents
302
+ };
303
+ }
304
+
305
+ fs.rmdirSync(fullPath);
306
+
307
+ // Renumber subsequent phases
308
+ const allDirs = fs.readdirSync(phasesDir)
309
+ .filter(d => /^\d+-/.test(d))
310
+ .sort();
311
+
312
+ for (const d of allDirs) {
313
+ const num = parseInt(d.split('-')[0], 10);
314
+ if (num > parseInt(phaseNum, 10)) {
315
+ const newName = d.replace(/^\d+/, String(num - 1).padStart(2, '0'));
316
+ fs.renameSync(path.join(phasesDir, d), path.join(phasesDir, newName));
317
+ }
318
+ }
319
+
320
+ return {
321
+ removed: true,
322
+ directory: dirName,
323
+ renumbered: true
324
+ };
325
+ }
326
+
327
+ /**
328
+ * List all phase directories with status.
329
+ *
330
+ * @param {string} [planningDir] - Path to .planning directory
331
+ */
332
+ function phaseList(planningDir) {
333
+ const dir = planningDir || path.join(process.env.PBR_PROJECT_ROOT || process.cwd(), '.planning');
334
+ const phasesDir = path.join(dir, 'phases');
335
+ if (!fs.existsSync(phasesDir)) {
336
+ return { phases: [] };
337
+ }
338
+
339
+ const phases = fs.readdirSync(phasesDir)
340
+ .filter(d => /^\d+-/.test(d))
341
+ .sort()
342
+ .map(d => {
343
+ const num = parseInt(d.split('-')[0], 10);
344
+ const slug = d.replace(/^\d+-/, '');
345
+ const fullPath = path.join(phasesDir, d);
346
+ const files = fs.readdirSync(fullPath);
347
+ const hasPlan = files.some(f => /^PLAN/i.test(f));
348
+ const hasSummary = files.some(f => /^SUMMARY/i.test(f));
349
+ const hasVerification = files.some(f => /^VERIFICATION/i.test(f));
350
+ return { num, slug, directory: d, files: files.length, hasPlan, hasSummary, hasVerification };
351
+ });
352
+
353
+ return { phases };
354
+ }
355
+
356
+ module.exports = {
357
+ frontmatter,
358
+ planIndex,
359
+ mustHavesCollect,
360
+ phaseInfo,
361
+ phaseAdd,
362
+ phaseRemove,
363
+ phaseList
364
+ };