pan-wizard 3.8.0 → 3.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +4 -1
  2. package/agents/pan-conductor.md +1 -2
  3. package/agents/pan-counterfactual.md +1 -2
  4. package/agents/pan-debugger.md +1 -2
  5. package/agents/pan-distiller.md +1 -2
  6. package/agents/pan-document_code.md +1 -0
  7. package/agents/pan-executor.md +1 -0
  8. package/agents/pan-experiment-runner.md +1 -2
  9. package/agents/pan-hardener.md +1 -2
  10. package/agents/pan-integration-checker.md +1 -2
  11. package/agents/pan-knowledge.md +1 -2
  12. package/agents/pan-meta-reviewer.md +1 -2
  13. package/agents/pan-optimizer.md +1 -0
  14. package/agents/pan-phase-researcher.md +1 -0
  15. package/agents/pan-plan-checker.md +1 -2
  16. package/agents/pan-planner.md +1 -0
  17. package/agents/pan-previewer.md +1 -2
  18. package/agents/pan-project-researcher.md +6 -0
  19. package/agents/pan-research-synthesizer.md +7 -0
  20. package/agents/pan-reviewer.md +2 -3
  21. package/agents/pan-roadmapper.md +1 -0
  22. package/agents/pan-verifier.md +1 -2
  23. package/bin/install-lib.cjs +661 -46
  24. package/bin/install.js +722 -116
  25. package/commands/pan/experiment.md +2 -0
  26. package/commands/pan/profile.md +2 -0
  27. package/hooks/dist/pan-cost-logger.js +22 -7
  28. package/package.json +5 -4
  29. package/pan-wizard-core/bin/lib/commands-learnings.cjs +544 -0
  30. package/pan-wizard-core/bin/lib/commands.cjs +12 -523
  31. package/pan-wizard-core/bin/lib/core.cjs +69 -0
  32. package/pan-wizard-core/bin/lib/cost.cjs +62 -8
  33. package/pan-wizard-core/bin/lib/git.cjs +6 -1
  34. package/pan-wizard-core/bin/lib/lock.cjs +108 -0
  35. package/pan-wizard-core/bin/lib/milestone.cjs +3 -2
  36. package/pan-wizard-core/bin/lib/phase-remove.cjs +392 -0
  37. package/pan-wizard-core/bin/lib/phase.cjs +4 -369
  38. package/pan-wizard-core/bin/lib/runner.cjs +5 -0
  39. package/pan-wizard-core/bin/lib/state.cjs +10 -1
  40. package/pan-wizard-core/bin/lib/verify-deploy.cjs +181 -0
  41. package/pan-wizard-core/bin/lib/verify-drift.cjs +255 -0
  42. package/pan-wizard-core/bin/lib/verify-preflight.cjs +261 -0
  43. package/pan-wizard-core/bin/lib/verify-retro.cjs +177 -0
  44. package/pan-wizard-core/bin/lib/verify.cjs +10 -797
  45. package/pan-wizard-core/bin/pan-tools.cjs +10 -0
  46. package/pan-wizard-core/workflows/plan-phase.md +11 -0
  47. package/scripts/build-plugin.js +105 -0
  48. package/scripts/install-git-hooks.js +64 -0
  49. package/scripts/release-check.js +13 -2
@@ -30,7 +30,7 @@
30
30
  * - hit rate: cache_read / (cache_read + input - cache_write) if any cache activity
31
31
  *
32
32
  * Rate table is approximate — real pricing comes from the provider's API.
33
- * Rates are US dollars per million tokens, indicative as of 2026-04. Users
33
+ * Rates are US dollars per million tokens, indicative as of 2026-06. Users
34
34
  * can override with `.planning/config.json` → `cost.rates`.
35
35
  */
36
36
 
@@ -48,21 +48,32 @@ const TOKENS_FILE = 'tokens.jsonl';
48
48
  * Override per-model in config.json → cost.rates.
49
49
  */
50
50
  const DEFAULT_RATES = {
51
- // Anthropic
52
- 'claude-opus-4-7': { input: 15.0, output: 75.0, cache_read: 1.5, cache_write: 18.75 },
53
- 'claude-opus-4-6': { input: 15.0, output: 75.0, cache_read: 1.5, cache_write: 18.75 },
51
+ // Anthropic — verified against platform pricing 2026-06. Opus 4.6+ is $5/$25
52
+ // (the old $15/$75 Opus pricing ended with the 4.5 generation). Cache rates
53
+ // follow Anthropic's convention: read 0.1× input, write 1.25× input.
54
+ 'claude-fable-5': { input: 10.0, output: 50.0, cache_read: 1.0, cache_write: 12.5 },
55
+ 'claude-opus-4-8': { input: 5.0, output: 25.0, cache_read: 0.5, cache_write: 6.25 },
56
+ 'claude-opus-4-7': { input: 5.0, output: 25.0, cache_read: 0.5, cache_write: 6.25 },
57
+ 'claude-opus-4-6': { input: 5.0, output: 25.0, cache_read: 0.5, cache_write: 6.25 },
54
58
  'claude-sonnet-4-6': { input: 3.0, output: 15.0, cache_read: 0.3, cache_write: 3.75 },
55
59
  'claude-haiku-4-5': { input: 1.0, output: 5.0, cache_read: 0.1, cache_write: 1.25 },
56
60
 
61
+ // OpenAI — verified against published pricing 2026-06 ($5/$30 standard tier).
62
+ // Prompt caching is a 90% input discount with no separate write charge, so
63
+ // cache_write bills at the plain input rate.
64
+ 'gpt-5.5': { input: 5.0, output: 30.0, cache_read: 0.5, cache_write: 5.0 },
65
+
57
66
  // Google Gemini — published rates (per million tokens, approximate; users can override via config.json → cost.rates).
58
- // 2.5 tier uses the <=200K-context tier; long-context calls may be billed at ~2x. Cache rates are Google's context-cache pricing (~25% of input rate).
67
+ // Pro tiers use the <=200K-context tier; long-context calls may be billed at ~2x. Cache rates are Google's context-cache pricing (~25% of input rate).
68
+ // (gemini-1.5-pro removed 2026-06: retired model; records for it fall back to tier rates.)
69
+ 'gemini-3.1-pro': { input: 2.00, output: 12.0, cache_read: 0.50, cache_write: 2.00 },
70
+ 'gemini-3.1-pro-preview': { input: 2.00, output: 12.0, cache_read: 0.50, cache_write: 2.00 },
59
71
  'gemini-2.5-pro': { input: 1.25, output: 10.0, cache_read: 0.3125, cache_write: 1.25 },
60
72
  'gemini-2.5-flash': { input: 0.30, output: 2.50, cache_read: 0.075, cache_write: 0.30 },
61
73
  'gemini-2.5-flash-lite': { input: 0.10, output: 0.40, cache_read: 0.025, cache_write: 0.10 },
62
- 'gemini-1.5-pro': { input: 1.25, output: 5.00, cache_read: 0.3125, cache_write: 1.25 },
63
74
 
64
- // Tier fallbacks when model id is unknown
65
- 'reasoning': { input: 15.0, output: 75.0, cache_read: 1.5, cache_write: 18.75 },
75
+ // Tier fallbacks when model id is unknown (reasoning tracks current Opus pricing)
76
+ 'reasoning': { input: 5.0, output: 25.0, cache_read: 0.5, cache_write: 6.25 },
66
77
  'mid': { input: 3.0, output: 15.0, cache_read: 0.3, cache_write: 3.75 },
67
78
  'fast': { input: 1.0, output: 5.0, cache_read: 0.1, cache_write: 1.25 },
68
79
  };
@@ -81,6 +92,15 @@ function resolveRate(model, tier, configRates) {
81
92
  if (tier && configRates[tier]) return configRates[tier];
82
93
  }
83
94
  if (model && DEFAULT_RATES[model]) return DEFAULT_RATES[model];
95
+ // Transcript/hook-captured ids are versioned ("claude-opus-4-8-20260301",
96
+ // "claude-fable-5[1m]") while the table uses family keys — prefix-match,
97
+ // longest key first so the most specific family wins.
98
+ if (model) {
99
+ const families = Object.keys(DEFAULT_RATES)
100
+ .filter(k => model.startsWith(k))
101
+ .sort((a, b) => b.length - a.length);
102
+ if (families.length > 0) return DEFAULT_RATES[families[0]];
103
+ }
84
104
  if (tier && DEFAULT_RATES[tier]) return DEFAULT_RATES[tier];
85
105
  return null;
86
106
  }
@@ -342,6 +362,37 @@ function cmdCostClear(cwd, raw) {
342
362
  }
343
363
  }
344
364
 
365
+ // ─── Rate-table staleness ───────────────────────────────────────────────────
366
+
367
+ // Date DEFAULT_RATES was last verified against published provider pricing.
368
+ // Bump this whenever the table is re-verified; `models check` flags the table
369
+ // once it is older than RATES_STALE_AFTER_DAYS (provider prices move faster
370
+ // than PAN releases do).
371
+ const RATES_VERIFIED_AT = '2026-06-10';
372
+ const RATES_STALE_AFTER_DAYS = 180;
373
+ const RATE_TIERS = ['reasoning', 'mid', 'fast'];
374
+
375
+ function checkRatesStaleness(now = new Date()) {
376
+ const verified = new Date(RATES_VERIFIED_AT + 'T00:00:00Z');
377
+ const ageDays = Math.floor((now.getTime() - verified.getTime()) / 86400000);
378
+ return {
379
+ rates_verified_at: RATES_VERIFIED_AT,
380
+ age_days: ageDays,
381
+ stale_after_days: RATES_STALE_AFTER_DAYS,
382
+ stale: ageDays > RATES_STALE_AFTER_DAYS,
383
+ models: Object.keys(DEFAULT_RATES).filter(k => !RATE_TIERS.includes(k)),
384
+ tiers: RATE_TIERS,
385
+ };
386
+ }
387
+
388
+ function cmdModelsCheck(raw) {
389
+ const result = checkRatesStaleness();
390
+ const human = result.stale
391
+ ? `Rate table verified ${result.rates_verified_at} (${result.age_days} days ago) — STALE: re-verify provider pricing and bump RATES_VERIFIED_AT in cost.cjs`
392
+ : `Rate table verified ${result.rates_verified_at} (${result.age_days} days ago) — OK`;
393
+ output(result, raw, human);
394
+ }
395
+
345
396
  module.exports = {
346
397
  computeCost,
347
398
  appendRecord,
@@ -350,10 +401,13 @@ module.exports = {
350
401
  renderTable,
351
402
  renderChart,
352
403
  resolveRate,
404
+ checkRatesStaleness,
353
405
  cmdCostReport,
354
406
  cmdCostAppend,
355
407
  cmdCostClear,
408
+ cmdModelsCheck,
356
409
  METRICS_DIR,
357
410
  TOKENS_FILE,
358
411
  DEFAULT_RATES,
412
+ RATES_VERIFIED_AT,
359
413
  };
@@ -272,7 +272,12 @@ function cmdGitTag(cwd, sub, opts, raw) {
272
272
  }
273
273
  if (sub === 'create') {
274
274
  if (!name) { error('--name required for tag create'); }
275
- const args = message ? ['tag', '-m', message, name] : ['tag', name];
275
+ // tag.gpgsign=true in user config would force signing (and fail outright
276
+ // for lightweight tags) in non-interactive runs — PAN tags are automation
277
+ // markers, so signing is explicitly disabled.
278
+ const args = message
279
+ ? ['-c', 'tag.gpgsign=false', 'tag', '-m', message, name]
280
+ : ['-c', 'tag.gpgsign=false', 'tag', name];
276
281
  const r = execGit(cwd, args);
277
282
  if (r.exitCode !== 0) {
278
283
  output({ created: false, tag: name, detail: r.stderr }, raw, 'tag create failed');
@@ -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
+ };