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.
- package/README.md +4 -1
- package/agents/pan-conductor.md +1 -2
- package/agents/pan-counterfactual.md +1 -2
- package/agents/pan-debugger.md +1 -2
- package/agents/pan-distiller.md +1 -2
- package/agents/pan-document_code.md +1 -0
- package/agents/pan-executor.md +1 -0
- package/agents/pan-experiment-runner.md +1 -2
- package/agents/pan-hardener.md +1 -2
- package/agents/pan-integration-checker.md +1 -2
- package/agents/pan-knowledge.md +1 -2
- package/agents/pan-meta-reviewer.md +1 -2
- package/agents/pan-optimizer.md +1 -0
- package/agents/pan-phase-researcher.md +1 -0
- package/agents/pan-plan-checker.md +1 -2
- package/agents/pan-planner.md +1 -0
- package/agents/pan-previewer.md +1 -2
- package/agents/pan-project-researcher.md +6 -0
- package/agents/pan-research-synthesizer.md +7 -0
- package/agents/pan-reviewer.md +2 -3
- package/agents/pan-roadmapper.md +1 -0
- package/agents/pan-verifier.md +1 -2
- package/bin/install-lib.cjs +661 -46
- package/bin/install.js +722 -116
- package/commands/pan/experiment.md +2 -0
- package/commands/pan/profile.md +2 -0
- package/hooks/dist/pan-cost-logger.js +22 -7
- package/package.json +5 -4
- package/pan-wizard-core/bin/lib/commands-learnings.cjs +544 -0
- package/pan-wizard-core/bin/lib/commands.cjs +12 -523
- package/pan-wizard-core/bin/lib/core.cjs +69 -0
- package/pan-wizard-core/bin/lib/cost.cjs +62 -8
- package/pan-wizard-core/bin/lib/git.cjs +6 -1
- package/pan-wizard-core/bin/lib/lock.cjs +108 -0
- package/pan-wizard-core/bin/lib/milestone.cjs +3 -2
- package/pan-wizard-core/bin/lib/phase-remove.cjs +392 -0
- package/pan-wizard-core/bin/lib/phase.cjs +4 -369
- package/pan-wizard-core/bin/lib/runner.cjs +5 -0
- package/pan-wizard-core/bin/lib/state.cjs +10 -1
- package/pan-wizard-core/bin/lib/verify-deploy.cjs +181 -0
- package/pan-wizard-core/bin/lib/verify-drift.cjs +255 -0
- package/pan-wizard-core/bin/lib/verify-preflight.cjs +261 -0
- package/pan-wizard-core/bin/lib/verify-retro.cjs +177 -0
- package/pan-wizard-core/bin/lib/verify.cjs +10 -797
- package/pan-wizard-core/bin/pan-tools.cjs +10 -0
- package/pan-wizard-core/workflows/plan-phase.md +11 -0
- package/scripts/build-plugin.js +105 -0
- package/scripts/install-git-hooks.js +64 -0
- 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-
|
|
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
|
-
|
|
53
|
-
'
|
|
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
|
-
//
|
|
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:
|
|
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
|
-
|
|
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
|
+
};
|