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
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Lock — advisory file locking + atomic writes for .planning/ concurrency
3
+ * (ADR-0030). Zero dependencies, synchronous (matches the CJS codebase),
4
+ * cross-platform: exclusive-create (`wx`) is atomic on NTFS and POSIX alike.
5
+ *
6
+ * v1 semantics are BEST-EFFORT serialization: when the lock cannot be
7
+ * acquired within the retry budget, callers fall back to the unlocked write
8
+ * (today's behavior) rather than failing — concurrent fleets get lost-update
9
+ * protection in the common case without introducing a new failure mode for
10
+ * single-agent users. Strict mode (fail on timeout) is the documented
11
+ * escalation path in ADR-0030.
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ const fs = require('fs');
17
+
18
+ const DEFAULT_RETRIES = 40;
19
+ const DEFAULT_INTERVAL_MS = 25;
20
+ const DEFAULT_STALE_MS = 10_000;
21
+
22
+ // Synchronous sleep without busy-waiting: Atomics.wait on a throwaway buffer.
23
+ function sleepSync(ms) {
24
+ const sab = new SharedArrayBuffer(4);
25
+ Atomics.wait(new Int32Array(sab), 0, 0, ms);
26
+ }
27
+
28
+ /**
29
+ * Try to acquire <filePath>.lock. Stale locks (older than staleMs — a crashed
30
+ * holder) are stolen.
31
+ * @returns {{ acquired: boolean, lockPath: string }}
32
+ */
33
+ function acquireLock(filePath, opts = {}) {
34
+ const retries = opts.retries ?? DEFAULT_RETRIES;
35
+ const intervalMs = opts.intervalMs ?? DEFAULT_INTERVAL_MS;
36
+ const staleMs = opts.staleMs ?? DEFAULT_STALE_MS;
37
+ const lockPath = filePath + '.lock';
38
+
39
+ for (let attempt = 0; attempt <= retries; attempt++) {
40
+ try {
41
+ fs.writeFileSync(lockPath, String(process.pid), { flag: 'wx' });
42
+ return { acquired: true, lockPath };
43
+ } catch (err) {
44
+ if (err.code !== 'EEXIST') {
45
+ // Lock dir unwritable etc. — treat as unacquirable, don't spin.
46
+ return { acquired: false, lockPath };
47
+ }
48
+ try {
49
+ const age = Date.now() - fs.statSync(lockPath).mtimeMs;
50
+ if (age > staleMs) {
51
+ // Holder likely crashed — steal and retry immediately.
52
+ try { fs.unlinkSync(lockPath); } catch { /* racing steal — loop retries */ }
53
+ continue;
54
+ }
55
+ } catch { /* lock vanished between EEXIST and stat — loop retries */ }
56
+ if (attempt < retries) sleepSync(intervalMs);
57
+ }
58
+ }
59
+ return { acquired: false, lockPath };
60
+ }
61
+
62
+ /** Release a lock acquired by acquireLock. Best-effort. */
63
+ function releaseLock(lockPath) {
64
+ try { fs.unlinkSync(lockPath); } catch { /* already gone */ }
65
+ }
66
+
67
+ /**
68
+ * Run fn while holding <filePath>.lock.
69
+ * @param {string} filePath - The file the lock guards (lock is filePath + '.lock')
70
+ * @param {Function} fn - Critical section
71
+ * @param {object} [opts] - {retries, intervalMs, staleMs}
72
+ * @returns {{ locked: boolean, result: any }} locked=false means fn ran
73
+ * WITHOUT the lock (best-effort fallback — see module header).
74
+ */
75
+ function withFileLock(filePath, fn, opts = {}) {
76
+ const { acquired, lockPath } = acquireLock(filePath, opts);
77
+ try {
78
+ return { locked: acquired, result: fn() };
79
+ } finally {
80
+ if (acquired) releaseLock(lockPath);
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Atomic file write: temp file in the same directory + rename. Readers never
86
+ * observe a torn/partial file. The temp name embeds pid to avoid collisions
87
+ * between concurrent writers.
88
+ */
89
+ function writeFileAtomic(filePath, content) {
90
+ const tmpPath = `${filePath}.${process.pid}.tmp`;
91
+ fs.writeFileSync(tmpPath, content, 'utf-8');
92
+ try {
93
+ fs.renameSync(tmpPath, filePath);
94
+ } catch (err) {
95
+ // Windows can refuse rename-over-open-file; fall back to direct write so
96
+ // the content still lands, then clean the temp.
97
+ fs.writeFileSync(filePath, content, 'utf-8');
98
+ try { fs.unlinkSync(tmpPath); } catch { /* best-effort */ }
99
+ }
100
+ }
101
+
102
+ module.exports = {
103
+ acquireLock,
104
+ releaseLock,
105
+ withFileLock,
106
+ writeFileAtomic,
107
+ sleepSync,
108
+ };
@@ -260,9 +260,10 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
260
260
  if (commitResult.exitCode === 0) {
261
261
  const hashResult = execGit(cwd, ['rev-parse', '--short', 'HEAD']);
262
262
  result.commit_hash = hashResult.exitCode === 0 ? hashResult.stdout : null;
263
- // Create tag
263
+ // Create tag. Signing is explicitly disabled: tag.gpgsign=true in user
264
+ // config turns plain `git tag` into sign-or-fail in non-interactive runs.
264
265
  const tagName = `milestone-${version}`;
265
- const tagResult = execGit(cwd, ['tag', tagName]);
266
+ const tagResult = execGit(cwd, ['-c', 'tag.gpgsign=false', 'tag', tagName]);
266
267
  result.tag = tagResult.exitCode === 0 ? tagName : null;
267
268
  }
268
269
  }
@@ -0,0 +1,392 @@
1
+ /**
2
+ * Phase / Remove — phase removal with renumbering cascade (directories, files,
3
+ * roadmap references, state counts).
4
+ * Extracted from phase.cjs (IMPROVEMENT-TODO P2 module decomposition);
5
+ * phase.cjs re-exports the public pieces, so consumers are unaffected.
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const { escapeRegex, normalizePhaseName, comparePhaseNum, output, error } = require('./core.cjs');
11
+ const { writeStateMd, readStateSafe } = require('./state.cjs');
12
+ const { ROADMAP_FILE, STATE_FILE, isSummaryFile } = require('./constants.cjs');
13
+ const { planningPath, phasesPath, fileAccessible } = require('./utils.cjs');
14
+
15
+ /**
16
+ * Delete a phase directory from disk.
17
+ * @param {string} phaseDir - Absolute path to the phase directory to remove
18
+ */
19
+ function removePhaseFromDisk(phaseDir) {
20
+ try {
21
+ fs.rmSync(phaseDir, { recursive: true, force: true });
22
+ } catch (e) {
23
+ return { removed: false, error: e.message };
24
+ }
25
+ return { removed: true };
26
+ }
27
+
28
+ /**
29
+ * Renumber sibling decimal phases after one is removed.
30
+ *
31
+ * Algorithm: When a decimal phase like 06.2 is removed, all higher-numbered
32
+ * siblings under the same base integer (06.3, 06.4, ...) must be decremented
33
+ * by 1 to fill the gap. We process in descending order to avoid directory
34
+ * name collisions during rename (e.g. rename 06.4 -> 06.3 before 06.3 -> 06.2).
35
+ *
36
+ * @param {string} phasesDir - Absolute path to the phases directory
37
+ * @param {string} baseInt - The integer portion of the removed phase (e.g. "06")
38
+ * @param {number} removedDecimal - The decimal portion that was removed (e.g. 2)
39
+ * @returns {{ renamedDirs: Array, renamedFiles: Array }}
40
+ */
41
+ function renumberDecimalPhases(phasesDir, baseInt, removedDecimal) {
42
+ const renamedDirs = [];
43
+ const renamedFiles = [];
44
+
45
+ try {
46
+ const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
47
+ const dirs = entries.filter(entry => entry.isDirectory()).map(entry => entry.name).sort((left, right) => comparePhaseNum(left, right));
48
+
49
+ // Find sibling decimals with higher numbers than the removed one
50
+ const decPattern = new RegExp(`^${baseInt}\\.(\\d+)-(.+)$`);
51
+ const toRename = [];
52
+ for (const dir of dirs) {
53
+ const decMatch = dir.match(decPattern);
54
+ if (decMatch && parseInt(decMatch[1], 10) > removedDecimal) {
55
+ toRename.push({ dir, oldDecimal: parseInt(decMatch[1], 10), slug: decMatch[2] });
56
+ }
57
+ }
58
+
59
+ // Sort descending so higher-numbered dirs are renamed first,
60
+ // preventing collisions (e.g. 06.4 -> 06.3 before 06.3 -> 06.2)
61
+ toRename.sort((left, right) => right.oldDecimal - left.oldDecimal);
62
+
63
+ for (const item of toRename) {
64
+ const newDecimal = item.oldDecimal - 1;
65
+ const oldPhaseId = `${baseInt}.${item.oldDecimal}`;
66
+ const newPhaseId = `${baseInt}.${newDecimal}`;
67
+ const newDirName = `${baseInt}.${newDecimal}-${item.slug}`;
68
+
69
+ // Rename the directory itself (e.g. 06.3-foo -> 06.2-foo)
70
+ fs.renameSync(path.join(phasesDir, item.dir), path.join(phasesDir, newDirName));
71
+ renamedDirs.push({ from: item.dir, to: newDirName });
72
+
73
+ // Rename files inside that contain the old phase ID prefix
74
+ const dirFiles = fs.readdirSync(path.join(phasesDir, newDirName));
75
+ for (const file of dirFiles) {
76
+ // Files may have phase prefix like "06.2-01-plan.md"
77
+ if (file.includes(oldPhaseId)) {
78
+ const newFileName = file.replace(oldPhaseId, newPhaseId);
79
+ fs.renameSync(
80
+ path.join(phasesDir, newDirName, file),
81
+ path.join(phasesDir, newDirName, newFileName)
82
+ );
83
+ renamedFiles.push({ from: file, to: newFileName });
84
+ }
85
+ }
86
+ }
87
+ } catch (e) {
88
+ return { renamedDirs, renamedFiles, error: `Partial rename: ${e.message}` };
89
+ }
90
+
91
+ return { renamedDirs, renamedFiles };
92
+ }
93
+
94
+ /**
95
+ * Collect phase directories that need renumbering (integer > removedInt).
96
+ * @param {string[]} dirs - Sorted directory names
97
+ * @param {number} removedInt - Removed phase integer
98
+ * @returns {Array} Items to rename, sorted descending to avoid collisions
99
+ */
100
+ function collectDirsToRenumber(dirs, removedInt) {
101
+ const toRename = [];
102
+ for (const dir of dirs) {
103
+ const dirMatch = dir.match(/^(\d+)([A-Z])?(?:\.(\d+))?-(.+)$/i);
104
+ if (!dirMatch) continue;
105
+ const dirInt = parseInt(dirMatch[1], 10);
106
+ if (dirInt > removedInt) {
107
+ toRename.push({
108
+ dir, oldInt: dirInt,
109
+ letter: dirMatch[2] ? dirMatch[2].toUpperCase() : '',
110
+ decimal: dirMatch[3] ? parseInt(dirMatch[3], 10) : null,
111
+ slug: dirMatch[4],
112
+ });
113
+ }
114
+ }
115
+ toRename.sort((left, right) => {
116
+ if (left.oldInt !== right.oldInt) return right.oldInt - left.oldInt;
117
+ return (right.decimal || 0) - (left.decimal || 0);
118
+ });
119
+ return toRename;
120
+ }
121
+
122
+ /**
123
+ * Rename a single phase directory and its internal files.
124
+ * @param {string} phasesDir - Phases directory path
125
+ * @param {Object} item - Rename item from collectDirsToRenumber
126
+ * @param {Array} renamedDirs - Accumulator for renamed dirs
127
+ * @param {Array} renamedFiles - Accumulator for renamed files
128
+ */
129
+ function renamePhaseDir(phasesDir, item, renamedDirs, renamedFiles) {
130
+ const newPadded = String(item.oldInt - 1).padStart(2, '0');
131
+ const oldPadded = String(item.oldInt).padStart(2, '0');
132
+ const suffix = (item.letter || '') + (item.decimal !== null ? `.${item.decimal}` : '');
133
+ const oldPrefix = oldPadded + suffix;
134
+ const newPrefix = newPadded + suffix;
135
+ const newDirName = `${newPrefix}-${item.slug}`;
136
+
137
+ fs.renameSync(path.join(phasesDir, item.dir), path.join(phasesDir, newDirName));
138
+ renamedDirs.push({ from: item.dir, to: newDirName });
139
+
140
+ for (const file of fs.readdirSync(path.join(phasesDir, newDirName))) {
141
+ if (file.startsWith(oldPrefix)) {
142
+ const newFileName = newPrefix + file.slice(oldPrefix.length);
143
+ fs.renameSync(path.join(phasesDir, newDirName, file), path.join(phasesDir, newDirName, newFileName));
144
+ renamedFiles.push({ from: file, to: newFileName });
145
+ }
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Renumber integer phases (and their decimal/letter children) after one is removed.
151
+ *
152
+ * Algorithm: When an integer phase like 05 is removed, all phases with a higher
153
+ * integer base (06, 06.1, 06A, 07, ...) must be decremented by 1. We process
154
+ * in descending order to avoid directory name collisions during the rename
155
+ * cascade (e.g. rename 07 -> 06 before 06 -> 05). Each directory and its
156
+ * contained files with the old phase prefix are renamed to reflect the new number.
157
+ *
158
+ * @param {string} phasesDir - Absolute path to the phases directory
159
+ * @param {number} removedInt - The integer phase number that was removed
160
+ * @returns {{ renamedDirs: Array, renamedFiles: Array }}
161
+ */
162
+ function renumberIntegerPhases(phasesDir, removedInt) {
163
+ const renamedDirs = [];
164
+ const renamedFiles = [];
165
+ try {
166
+ const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
167
+ const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b));
168
+ const toRename = collectDirsToRenumber(dirs, removedInt);
169
+ for (const item of toRename) renamePhaseDir(phasesDir, item, renamedDirs, renamedFiles);
170
+ } catch (e) {
171
+ return { renamedDirs, renamedFiles, error: `Partial rename: ${e.message}` };
172
+ }
173
+ return { renamedDirs, renamedFiles };
174
+ }
175
+
176
+ /**
177
+ * Rewrite roadmap.md after a phase is removed: delete the target section,
178
+ * remove checkbox/table references, and renumber subsequent phase references.
179
+ *
180
+ * @param {string} cwd - Working directory path
181
+ * @param {string} phaseNum - The phase number that was removed (as originally specified)
182
+ * @param {boolean} isDecimal - Whether the removed phase was a decimal phase
183
+ * @param {string} normalized - The zero-padded normalized phase number
184
+ */
185
+ function updateRoadmapAfterRemoval(cwd, phaseNum, isDecimal, normalized) {
186
+ const roadmapPath = path.join(planningPath(cwd), ROADMAP_FILE);
187
+ let roadmapContent;
188
+ try {
189
+ roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
190
+ } catch { return; }
191
+
192
+ // Remove the target phase section from roadmap.md.
193
+ // Matches from the phase heading to the next phase heading (or end of file).
194
+ const targetEscaped = escapeRegex(phaseNum);
195
+ const sectionPattern = new RegExp(
196
+ `\\n?#{2,4}\\s*Phase\\s+${targetEscaped}\\s*:[\\s\\S]*?(?=\\n#{2,4}\\s+Phase\\s+\\d|$)`,
197
+ 'i'
198
+ );
199
+ roadmapContent = roadmapContent.replace(sectionPattern, '');
200
+
201
+ // Remove checkbox list items referencing this phase
202
+ const checkboxPattern = new RegExp(`\\n?-\\s*\\[[ x]\\]\\s*.*Phase\\s+${targetEscaped}[:\\s][^\\n]*`, 'gi');
203
+ roadmapContent = roadmapContent.replace(checkboxPattern, '');
204
+
205
+ // Remove progress table rows referencing this phase
206
+ const tableRowPattern = new RegExp(`\\n?\\|\\s*${targetEscaped}\\.?\\s[^|]*\\|[^\\n]*`, 'gi');
207
+ roadmapContent = roadmapContent.replace(tableRowPattern, '');
208
+
209
+ // For integer phase removal, renumber all references to subsequent phases.
210
+ // Walk from highest phase number down to removedInt+1, decrementing each by 1.
211
+ // This avoids double-renaming (e.g. 8->7 then 7->6 would break if done ascending).
212
+ if (!isDecimal) {
213
+ const removedInt = parseInt(normalized, 10);
214
+
215
+ // Reasonable upper bound for phase numbers
216
+ const maxPhase = 99;
217
+ for (let oldNum = maxPhase; oldNum > removedInt; oldNum--) {
218
+ const newNum = oldNum - 1;
219
+ const oldStr = String(oldNum);
220
+ const newStr = String(newNum);
221
+ const oldPad = oldStr.padStart(2, '0');
222
+ const newPad = newStr.padStart(2, '0');
223
+
224
+ // Phase headings: ## Phase 18: or ### Phase 18: -> ## Phase 17:
225
+ roadmapContent = roadmapContent.replace(
226
+ new RegExp(`(#{2,4}\\s*Phase\\s+)${oldStr}(\\s*:)`, 'gi'),
227
+ `$1${newStr}$2`
228
+ );
229
+
230
+ // Inline phase references: "Phase 18:" or "Phase 18 " -> "Phase 17:"
231
+ roadmapContent = roadmapContent.replace(
232
+ new RegExp(`(Phase\\s+)${oldStr}([:\\s])`, 'g'),
233
+ `$1${newStr}$2`
234
+ );
235
+
236
+ // Plan references in padded form: 18-01 -> 17-01
237
+ roadmapContent = roadmapContent.replace(
238
+ new RegExp(`${oldPad}-(\\d{2})`, 'g'),
239
+ `${newPad}-$1`
240
+ );
241
+
242
+ // Progress table row numbers: | 18. -> | 17.
243
+ roadmapContent = roadmapContent.replace(
244
+ new RegExp(`(\\|\\s*)${oldStr}\\.\\s`, 'g'),
245
+ `$1${newStr}. `
246
+ );
247
+
248
+ // Depends-on references: "Depends on:** Phase 18" -> "Phase 17"
249
+ roadmapContent = roadmapContent.replace(
250
+ new RegExp(`(Depends on:\\*\\*\\s*Phase\\s+)${oldStr}\\b`, 'gi'),
251
+ `$1${newStr}`
252
+ );
253
+ }
254
+ }
255
+
256
+ try { fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8'); } catch { /* best-effort */ }
257
+ }
258
+
259
+ /**
260
+ * Remove a phase directory, renumber subsequent phases, and update ROADMAP/STATE.
261
+ * @param {string} cwd - Working directory path
262
+ * @param {string} targetPhase - Phase number to remove
263
+ * @param {Object} options - Options (force: skip executed-work check)
264
+ * @param {boolean} raw - If true, output raw value instead of JSON
265
+ * @returns {void}
266
+ */
267
+ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
268
+ if (!targetPhase) {
269
+ error('phase number required for phase remove');
270
+ }
271
+
272
+ const roadmapPath = path.join(planningPath(cwd), ROADMAP_FILE);
273
+ const phasesDir = phasesPath(cwd);
274
+ const force = options.force || false;
275
+
276
+ if (!fileAccessible(roadmapPath)) {
277
+ error('roadmap.md not found');
278
+ }
279
+
280
+ // Normalize the target
281
+ const normalized = normalizePhaseName(targetPhase);
282
+ const isDecimal = targetPhase.includes('.');
283
+
284
+ // Find and validate target directory
285
+ let targetDir = null;
286
+ try {
287
+ const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
288
+ const dirs = entries.filter(entry => entry.isDirectory()).map(entry => entry.name).sort((left, right) => comparePhaseNum(left, right));
289
+ targetDir = dirs.find(dir => dir.startsWith(normalized + '-') || dir === normalized);
290
+ } catch {
291
+ // Phases directory does not exist; targetDir remains null
292
+ }
293
+
294
+ // Check for executed work (summary.md files)
295
+ if (targetDir && !force) {
296
+ const targetPath = path.join(phasesDir, targetDir);
297
+ const files = fs.readdirSync(targetPath);
298
+ const summaries = files.filter(isSummaryFile);
299
+ if (summaries.length > 0) {
300
+ error(`Phase ${targetPhase} has ${summaries.length} executed plan(s). Use --force to remove anyway.`);
301
+ }
302
+ }
303
+
304
+ // Delete target directory
305
+ let removeWarning = null;
306
+ if (targetDir) {
307
+ const removeResult = removePhaseFromDisk(path.join(phasesDir, targetDir));
308
+ if (!removeResult.removed) {
309
+ removeWarning = removeResult.error;
310
+ }
311
+ }
312
+
313
+ // Renumber subsequent phases using the appropriate strategy
314
+ let renamedDirs = [];
315
+ let renamedFiles = [];
316
+
317
+ let renameError = null;
318
+ if (isDecimal) {
319
+ // Decimal removal: renumber sibling decimals (e.g., removing 06.2 -> 06.3 becomes 06.2)
320
+ const baseParts = normalized.split('.');
321
+ const baseInt = baseParts[0];
322
+ const removedDecimal = parseInt(baseParts[1], 10);
323
+ const result = renumberDecimalPhases(phasesDir, baseInt, removedDecimal);
324
+ renamedDirs = result.renamedDirs;
325
+ renamedFiles = result.renamedFiles;
326
+ renameError = result.error || null;
327
+ } else {
328
+ // Integer removal: renumber all subsequent integer phases
329
+ const removedInt = parseInt(normalized, 10);
330
+ const result = renumberIntegerPhases(phasesDir, removedInt);
331
+ renamedDirs = result.renamedDirs;
332
+ renamedFiles = result.renamedFiles;
333
+ renameError = result.error || null;
334
+ }
335
+
336
+ // Update roadmap.md: remove section and renumber references
337
+ updateRoadmapAfterRemoval(cwd, targetPhase, isDecimal, normalized);
338
+
339
+ // Update state.md phase count
340
+ const stateUpdated = updateStateAfterPhaseRemoval(cwd);
341
+
342
+ const result = {
343
+ removed: targetPhase,
344
+ directory_deleted: targetDir || null,
345
+ renamed_directories: renamedDirs,
346
+ renamed_files: renamedFiles,
347
+ roadmap_updated: true,
348
+ state_updated: stateUpdated,
349
+ };
350
+ if (renameError) result.rename_warning = renameError;
351
+ if (removeWarning) result.remove_warning = removeWarning;
352
+
353
+ output(result, raw);
354
+ }
355
+
356
+ /**
357
+ * Decrement phase count in state.md after a phase is removed.
358
+ * @param {string} cwd - Working directory path
359
+ * @returns {boolean} true if state.md was updated
360
+ */
361
+ function updateStateAfterPhaseRemoval(cwd) {
362
+ const statePath = path.join(planningPath(cwd), STATE_FILE);
363
+ const content = readStateSafe(statePath);
364
+ if (content === null) return false;
365
+
366
+ let updated = content;
367
+ // Decrement "Total Phases" field
368
+ const totalPattern = /(\*\*Total Phases:\*\*\s*)(\d+)/;
369
+ const totalMatch = updated.match(totalPattern);
370
+ if (totalMatch) {
371
+ updated = updated.replace(totalPattern, `$1${parseInt(totalMatch[2], 10) - 1}`);
372
+ }
373
+ // Decrement "Phase: X of Y" pattern
374
+ const ofPattern = /(\bof\s+)(\d+)(\s*(?:\(|phases?))/i;
375
+ const ofMatch = updated.match(ofPattern);
376
+ if (ofMatch) {
377
+ updated = updated.replace(ofPattern, `$1${parseInt(ofMatch[2], 10) - 1}$3`);
378
+ }
379
+ writeStateMd(statePath, updated, cwd);
380
+ return true;
381
+ }
382
+
383
+ module.exports = {
384
+ removePhaseFromDisk,
385
+ renumberDecimalPhases,
386
+ collectDirsToRenumber,
387
+ renamePhaseDir,
388
+ renumberIntegerPhases,
389
+ updateRoadmapAfterRemoval,
390
+ cmdPhaseRemove,
391
+ updateStateAfterPhaseRemoval,
392
+ };