refactor: extract shared utilities to reduce duplication across codebase

- core.cjs: add filterPlanFiles, filterSummaryFiles, getPhaseFileStats,
  readSubdirectories helpers; use them in searchPhaseInDir and getArchivedPhaseDirs
- gsd-tools.cjs: add parseNamedArgs() helper; replace ~50 repetitive indexOf/ternary
  patterns in state record-metric, add-decision, add-blocker, record-session,
  begin-phase, signal-waiting, template fill, and frontmatter subcommands
- phase.cjs: decompose 250-line cmdPhaseRemove into renameDecimalPhases(),
  renameIntegerPhases(), and updateRoadmapAfterPhaseRemoval(); import readSubdirectories
- workstream.cjs: import stateExtractField from state.cjs and shared helpers from
  core.cjs; replace all inline regex state parsing and readdirSync+filter+map patterns

All 1062 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
SalesTeamToolbox
2026-03-20 23:00:40 -07:00
parent e3a427252d
commit 5f95fea4d7
4 changed files with 281 additions and 371 deletions

View File

@@ -151,6 +151,30 @@ const profilePipeline = require('./lib/profile-pipeline.cjs');
const profileOutput = require('./lib/profile-output.cjs');
const workstream = require('./lib/workstream.cjs');
// ─── Arg parsing helpers ──────────────────────────────────────────────────────
/**
* Extract named --flag <value> pairs from an args array.
* Returns an object mapping flag names to their values (null if absent).
* Flags listed in `booleanFlags` are treated as boolean (no value consumed).
*
* parseNamedArgs(args, 'phase', 'plan') → { phase: '3', plan: '1' }
* parseNamedArgs(args, [], ['amend', 'force']) → { amend: true, force: false }
*/
function parseNamedArgs(args, valueFlags = [], booleanFlags = []) {
const result = {};
for (const flag of valueFlags) {
const idx = args.indexOf(`--${flag}`);
result[flag] = idx !== -1 && args[idx + 1] !== undefined && !args[idx + 1].startsWith('--')
? args[idx + 1]
: null;
}
for (const flag of booleanFlags) {
result[flag] = args.includes(`--${flag}`);
}
return result;
}
// ─── CLI Router ───────────────────────────────────────────────────────────────
async function main() {
@@ -329,74 +353,27 @@ async function runCommand(command, args, cwd, raw) {
} else if (subcommand === 'advance-plan') {
state.cmdStateAdvancePlan(cwd, raw);
} else if (subcommand === 'record-metric') {
const phaseIdx = args.indexOf('--phase');
const planIdx = args.indexOf('--plan');
const durationIdx = args.indexOf('--duration');
const tasksIdx = args.indexOf('--tasks');
const filesIdx = args.indexOf('--files');
state.cmdStateRecordMetric(cwd, {
phase: phaseIdx !== -1 ? args[phaseIdx + 1] : null,
plan: planIdx !== -1 ? args[planIdx + 1] : null,
duration: durationIdx !== -1 ? args[durationIdx + 1] : null,
tasks: tasksIdx !== -1 ? args[tasksIdx + 1] : null,
files: filesIdx !== -1 ? args[filesIdx + 1] : null,
}, raw);
const { phase: p, plan, duration, tasks, files } = parseNamedArgs(args, ['phase', 'plan', 'duration', 'tasks', 'files']);
state.cmdStateRecordMetric(cwd, { phase: p, plan, duration, tasks, files }, raw);
} else if (subcommand === 'update-progress') {
state.cmdStateUpdateProgress(cwd, raw);
} else if (subcommand === 'add-decision') {
const phaseIdx = args.indexOf('--phase');
const summaryIdx = args.indexOf('--summary');
const summaryFileIdx = args.indexOf('--summary-file');
const rationaleIdx = args.indexOf('--rationale');
const rationaleFileIdx = args.indexOf('--rationale-file');
state.cmdStateAddDecision(cwd, {
phase: phaseIdx !== -1 ? args[phaseIdx + 1] : null,
summary: summaryIdx !== -1 ? args[summaryIdx + 1] : null,
summary_file: summaryFileIdx !== -1 ? args[summaryFileIdx + 1] : null,
rationale: rationaleIdx !== -1 ? args[rationaleIdx + 1] : '',
rationale_file: rationaleFileIdx !== -1 ? args[rationaleFileIdx + 1] : null,
}, raw);
const { phase: p, summary, 'summary-file': summary_file, rationale, 'rationale-file': rationale_file } = parseNamedArgs(args, ['phase', 'summary', 'summary-file', 'rationale', 'rationale-file']);
state.cmdStateAddDecision(cwd, { phase: p, summary, summary_file, rationale: rationale || '', rationale_file }, raw);
} else if (subcommand === 'add-blocker') {
const textIdx = args.indexOf('--text');
const textFileIdx = args.indexOf('--text-file');
state.cmdStateAddBlocker(cwd, {
text: textIdx !== -1 ? args[textIdx + 1] : null,
text_file: textFileIdx !== -1 ? args[textFileIdx + 1] : null,
}, raw);
const { text, 'text-file': text_file } = parseNamedArgs(args, ['text', 'text-file']);
state.cmdStateAddBlocker(cwd, { text, text_file }, raw);
} else if (subcommand === 'resolve-blocker') {
const textIdx = args.indexOf('--text');
state.cmdStateResolveBlocker(cwd, textIdx !== -1 ? args[textIdx + 1] : null, raw);
state.cmdStateResolveBlocker(cwd, parseNamedArgs(args, ['text']).text, raw);
} else if (subcommand === 'record-session') {
const stoppedIdx = args.indexOf('--stopped-at');
const resumeIdx = args.indexOf('--resume-file');
state.cmdStateRecordSession(cwd, {
stopped_at: stoppedIdx !== -1 ? args[stoppedIdx + 1] : null,
resume_file: resumeIdx !== -1 ? args[resumeIdx + 1] : 'None',
}, raw);
const { 'stopped-at': stopped_at, 'resume-file': resume_file } = parseNamedArgs(args, ['stopped-at', 'resume-file']);
state.cmdStateRecordSession(cwd, { stopped_at, resume_file: resume_file || 'None' }, raw);
} else if (subcommand === 'begin-phase') {
const phaseIdx = args.indexOf('--phase');
const nameIdx = args.indexOf('--name');
const plansIdx = args.indexOf('--plans');
state.cmdStateBeginPhase(
cwd,
phaseIdx !== -1 ? args[phaseIdx + 1] : null,
nameIdx !== -1 ? args[nameIdx + 1] : null,
plansIdx !== -1 ? parseInt(args[plansIdx + 1], 10) : null,
raw
);
const { phase: p, name, plans } = parseNamedArgs(args, ['phase', 'name', 'plans']);
state.cmdStateBeginPhase(cwd, p, name, plans !== null ? parseInt(plans, 10) : null, raw);
} else if (subcommand === 'signal-waiting') {
const typeIdx = args.indexOf('--type');
const qIdx = args.indexOf('--question');
const optIdx = args.indexOf('--options');
const phaseIdx = args.indexOf('--phase');
state.cmdSignalWaiting(
cwd,
typeIdx !== -1 ? args[typeIdx + 1] : null,
qIdx !== -1 ? args[qIdx + 1] : null,
optIdx !== -1 ? args[optIdx + 1] : null,
phaseIdx !== -1 ? args[phaseIdx + 1] : null,
raw
);
const { type, question, options, phase: p } = parseNamedArgs(args, ['type', 'question', 'options', 'phase']);
state.cmdSignalWaiting(cwd, type, question, options, p, raw);
} else if (subcommand === 'signal-resume') {
state.cmdSignalResume(cwd, raw);
} else {
@@ -452,24 +429,18 @@ async function runCommand(command, args, cwd, raw) {
template.cmdTemplateSelect(cwd, args[2], raw);
} else if (subcommand === 'fill') {
const templateType = args[2];
const phaseIdx = args.indexOf('--phase');
const planIdx = args.indexOf('--plan');
const nameIdx = args.indexOf('--name');
const typeIdx = args.indexOf('--type');
const waveIdx = args.indexOf('--wave');
const fieldsIdx = args.indexOf('--fields');
const { phase, plan, name, type, wave, fields: fieldsRaw } = parseNamedArgs(args, ['phase', 'plan', 'name', 'type', 'wave', 'fields']);
let fields = {};
if (fieldsRaw) {
const { safeJsonParse } = require('./lib/security.cjs');
const result = safeJsonParse(fieldsRaw, { label: '--fields' });
if (!result.ok) error(result.error);
fields = result.value;
}
template.cmdTemplateFill(cwd, templateType, {
phase: phaseIdx !== -1 ? args[phaseIdx + 1] : null,
plan: planIdx !== -1 ? args[planIdx + 1] : null,
name: nameIdx !== -1 ? args[nameIdx + 1] : null,
type: typeIdx !== -1 ? args[typeIdx + 1] : 'execute',
wave: waveIdx !== -1 ? args[waveIdx + 1] : '1',
fields: fieldsIdx !== -1 ? (() => {
const { safeJsonParse } = require('./lib/security.cjs');
const result = safeJsonParse(args[fieldsIdx + 1], { label: '--fields' });
if (!result.ok) error(result.error);
return result.value;
})() : {},
phase, plan, name, fields,
type: type || 'execute',
wave: wave || '1',
}, raw);
} else {
error('Unknown template subcommand. Available: select, fill');
@@ -481,18 +452,14 @@ async function runCommand(command, args, cwd, raw) {
const subcommand = args[1];
const file = args[2];
if (subcommand === 'get') {
const fieldIdx = args.indexOf('--field');
frontmatter.cmdFrontmatterGet(cwd, file, fieldIdx !== -1 ? args[fieldIdx + 1] : null, raw);
frontmatter.cmdFrontmatterGet(cwd, file, parseNamedArgs(args, ['field']).field, raw);
} else if (subcommand === 'set') {
const fieldIdx = args.indexOf('--field');
const valueIdx = args.indexOf('--value');
frontmatter.cmdFrontmatterSet(cwd, file, fieldIdx !== -1 ? args[fieldIdx + 1] : null, valueIdx !== -1 ? args[valueIdx + 1] : undefined, raw);
const { field, value } = parseNamedArgs(args, ['field', 'value']);
frontmatter.cmdFrontmatterSet(cwd, file, field, value !== null ? value : undefined, raw);
} else if (subcommand === 'merge') {
const dataIdx = args.indexOf('--data');
frontmatter.cmdFrontmatterMerge(cwd, file, dataIdx !== -1 ? args[dataIdx + 1] : null, raw);
frontmatter.cmdFrontmatterMerge(cwd, file, parseNamedArgs(args, ['data']).data, raw);
} else if (subcommand === 'validate') {
const schemaIdx = args.indexOf('--schema');
frontmatter.cmdFrontmatterValidate(cwd, file, schemaIdx !== -1 ? args[schemaIdx + 1] : null, raw);
frontmatter.cmdFrontmatterValidate(cwd, file, parseNamedArgs(args, ['schema']).schema, raw);
} else {
error('Unknown frontmatter subcommand. Available: get, set, merge, validate');
}

View File

@@ -643,8 +643,7 @@ function comparePhaseNum(a, b) {
function searchPhaseInDir(baseDir, relBase, normalized) {
try {
const entries = fs.readdirSync(baseDir, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b));
const dirs = readSubdirectories(baseDir, true);
// Match: starts with normalized (numeric) OR contains normalized as prefix segment (custom ID)
const match = dirs.find(d => {
if (d.startsWith(normalized)) return true;
@@ -661,14 +660,9 @@ function searchPhaseInDir(baseDir, relBase, normalized) {
const phaseNumber = dirMatch ? dirMatch[1] : normalized;
const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null;
const phaseDir = path.join(baseDir, match);
const phaseFiles = fs.readdirSync(phaseDir);
const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').sort();
const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').sort();
const hasResearch = phaseFiles.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md');
const hasContext = phaseFiles.some(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md');
const hasVerification = phaseFiles.some(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md');
const hasReviews = phaseFiles.some(f => f.endsWith('-REVIEWS.md') || f === 'REVIEWS.md');
const { plans: unsortedPlans, summaries: unsortedSummaries, hasResearch, hasContext, hasVerification, hasReviews } = getPhaseFileStats(phaseDir);
const plans = unsortedPlans.sort();
const summaries = unsortedSummaries.sort();
const completedPlanIds = new Set(
summaries.map(s => s.replace('-SUMMARY.md', '').replace('SUMMARY.md', ''))
@@ -753,8 +747,7 @@ function getArchivedPhaseDirs(cwd) {
for (const archiveName of phaseDirs) {
const version = archiveName.match(/^(v[\d.]+)-phases$/)[1];
const archivePath = path.join(milestonesDir, archiveName);
const entries = fs.readdirSync(archivePath, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b));
const dirs = readSubdirectories(archivePath, true);
for (const dir of dirs) {
results.push({
@@ -1077,6 +1070,50 @@ function getMilestonePhaseFilter(cwd) {
return isDirInMilestone;
}
// ─── Phase file helpers ──────────────────────────────────────────────────────
/** Filter a file list to just PLAN.md / *-PLAN.md entries. */
function filterPlanFiles(files) {
return files.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md');
}
/** Filter a file list to just SUMMARY.md / *-SUMMARY.md entries. */
function filterSummaryFiles(files) {
return files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
}
/**
* Read a phase directory and return counts/flags for common file types.
* Returns an object with plans[], summaries[], and boolean flags for
* research/context/verification files.
*/
function getPhaseFileStats(phaseDir) {
const files = fs.readdirSync(phaseDir);
return {
plans: filterPlanFiles(files),
summaries: filterSummaryFiles(files),
hasResearch: files.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'),
hasContext: files.some(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md'),
hasVerification: files.some(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md'),
hasReviews: files.some(f => f.endsWith('-REVIEWS.md') || f === 'REVIEWS.md'),
};
}
/**
* Read immediate child directories from a path.
* Returns [] if the path doesn't exist or can't be read.
* Pass sort=true to apply comparePhaseNum ordering.
*/
function readSubdirectories(dirPath, sort = false) {
try {
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
return sort ? dirs.sort((a, b) => comparePhaseNum(a, b)) : dirs;
} catch {
return [];
}
}
module.exports = {
output,
error,
@@ -1113,4 +1150,8 @@ module.exports = {
planningPaths,
getActiveWorkstream,
setActiveWorkstream,
filterPlanFiles,
filterSummaryFiles,
getPhaseFileStats,
readSubdirectories,
};

View File

@@ -4,7 +4,7 @@
const fs = require('fs');
const path = require('path');
const { escapeRegex, loadConfig, normalizePhaseName, comparePhaseNum, findPhaseInternal, getArchivedPhaseDirs, generateSlugInternal, getMilestonePhaseFilter, stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone, toPosixPath, planningDir, output, error } = require('./core.cjs');
const { escapeRegex, loadConfig, normalizePhaseName, comparePhaseNum, findPhaseInternal, getArchivedPhaseDirs, generateSlugInternal, getMilestonePhaseFilter, stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone, toPosixPath, planningDir, output, error, readSubdirectories } = require('./core.cjs');
const { extractFrontmatter } = require('./frontmatter.cjs');
const { writeStateMd, stateExtractField, stateReplaceField, stateReplaceFieldWithFallback } = require('./state.cjs');
@@ -461,256 +461,169 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
output(result, raw, decimalPhase);
}
function cmdPhaseRemove(cwd, targetPhase, options, raw) {
if (!targetPhase) {
error('phase number required for phase remove');
/**
* Renumber sibling decimal phases after a decimal phase is removed.
* e.g. removing 06.2 → 06.3 becomes 06.2, 06.4 becomes 06.3, etc.
* Returns { renamedDirs, renamedFiles }.
*/
function renameDecimalPhases(phasesDir, baseInt, removedDecimal) {
const renamedDirs = [], renamedFiles = [];
const decPattern = new RegExp(`^${baseInt}\\.(\\d+)-(.+)$`);
const dirs = readSubdirectories(phasesDir, true);
const toRename = dirs
.map(dir => { const m = dir.match(decPattern); return m ? { dir, oldDecimal: parseInt(m[1], 10), slug: m[2] } : null; })
.filter(item => item && item.oldDecimal > removedDecimal)
.sort((a, b) => b.oldDecimal - a.oldDecimal); // descending to avoid conflicts
for (const item of toRename) {
const newDecimal = item.oldDecimal - 1;
const oldPhaseId = `${baseInt}.${item.oldDecimal}`;
const newPhaseId = `${baseInt}.${newDecimal}`;
const newDirName = `${baseInt}.${newDecimal}-${item.slug}`;
fs.renameSync(path.join(phasesDir, item.dir), path.join(phasesDir, newDirName));
renamedDirs.push({ from: item.dir, to: newDirName });
for (const f of fs.readdirSync(path.join(phasesDir, newDirName))) {
if (f.includes(oldPhaseId)) {
const newFileName = f.replace(oldPhaseId, newPhaseId);
fs.renameSync(path.join(phasesDir, newDirName, f), path.join(phasesDir, newDirName, newFileName));
renamedFiles.push({ from: f, to: newFileName });
}
}
}
return { renamedDirs, renamedFiles };
}
/**
* Renumber all integer phases after removedInt.
* e.g. removing phase 5 → phase 6 becomes 5, phase 7 becomes 6, etc.
* Returns { renamedDirs, renamedFiles }.
*/
function renameIntegerPhases(phasesDir, removedInt) {
const renamedDirs = [], renamedFiles = [];
const dirs = readSubdirectories(phasesDir, true);
const toRename = dirs
.map(dir => {
const m = dir.match(/^(\d+)([A-Z])?(?:\.(\d+))?-(.+)$/i);
if (!m) return null;
const dirInt = parseInt(m[1], 10);
return dirInt > removedInt ? { dir, oldInt: dirInt, letter: m[2] ? m[2].toUpperCase() : '', decimal: m[3] ? parseInt(m[3], 10) : null, slug: m[4] } : null;
})
.filter(Boolean)
.sort((a, b) => a.oldInt !== b.oldInt ? b.oldInt - a.oldInt : (b.decimal || 0) - (a.decimal || 0));
for (const item of toRename) {
const newInt = item.oldInt - 1;
const newPadded = String(newInt).padStart(2, '0');
const oldPadded = String(item.oldInt).padStart(2, '0');
const letterSuffix = item.letter || '';
const decimalSuffix = item.decimal !== null ? `.${item.decimal}` : '';
const oldPrefix = `${oldPadded}${letterSuffix}${decimalSuffix}`;
const newPrefix = `${newPadded}${letterSuffix}${decimalSuffix}`;
const newDirName = `${newPrefix}-${item.slug}`;
fs.renameSync(path.join(phasesDir, item.dir), path.join(phasesDir, newDirName));
renamedDirs.push({ from: item.dir, to: newDirName });
for (const f of fs.readdirSync(path.join(phasesDir, newDirName))) {
if (f.startsWith(oldPrefix)) {
const newFileName = newPrefix + f.slice(oldPrefix.length);
fs.renameSync(path.join(phasesDir, newDirName, f), path.join(phasesDir, newDirName, newFileName));
renamedFiles.push({ from: f, to: newFileName });
}
}
}
return { renamedDirs, renamedFiles };
}
/**
* Remove a phase section from ROADMAP.md and renumber all subsequent integer phases.
*/
function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, removedInt) {
let content = fs.readFileSync(roadmapPath, 'utf-8');
const escaped = escapeRegex(targetPhase);
content = content.replace(new RegExp(`\\n?#{2,4}\\s*Phase\\s+${escaped}\\s*:[\\s\\S]*?(?=\\n#{2,4}\\s+Phase\\s+\\d|$)`, 'i'), '');
content = content.replace(new RegExp(`\\n?-\\s*\\[[ x]\\]\\s*.*Phase\\s+${escaped}[:\\s][^\\n]*`, 'gi'), '');
content = content.replace(new RegExp(`\\n?\\|\\s*${escaped}\\.?\\s[^|]*\\|[^\\n]*`, 'gi'), '');
if (!isDecimal) {
const MAX_PHASE = 99;
for (let oldNum = MAX_PHASE; oldNum > removedInt; oldNum--) {
const newNum = oldNum - 1;
const oldStr = String(oldNum), newStr = String(newNum);
const oldPad = oldStr.padStart(2, '0'), newPad = newStr.padStart(2, '0');
content = content.replace(new RegExp(`(#{2,4}\\s*Phase\\s+)${oldStr}(\\s*:)`, 'gi'), `$1${newStr}$2`);
content = content.replace(new RegExp(`(Phase\\s+)${oldStr}([:\\s])`, 'g'), `$1${newStr}$2`);
content = content.replace(new RegExp(`${oldPad}-(\\d{2})`, 'g'), `${newPad}-$1`);
content = content.replace(new RegExp(`(\\|\\s*)${oldStr}\\.\\s`, 'g'), `$1${newStr}. `);
content = content.replace(new RegExp(`(Depends on:\\*\\*\\s*Phase\\s+)${oldStr}\\b`, 'gi'), `$1${newStr}`);
}
}
fs.writeFileSync(roadmapPath, content, 'utf-8');
}
function cmdPhaseRemove(cwd, targetPhase, options, raw) {
if (!targetPhase) error('phase number required for phase remove');
const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
const phasesDir = path.join(planningDir(cwd), 'phases');
const force = options.force || false;
if (!fs.existsSync(roadmapPath)) {
error('ROADMAP.md not found');
}
if (!fs.existsSync(roadmapPath)) error('ROADMAP.md not found');
// Normalize the target
const normalized = normalizePhaseName(targetPhase);
const isDecimal = targetPhase.includes('.');
const force = options.force || false;
// Find and validate target directory
let targetDir = null;
try {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b));
targetDir = dirs.find(d => d.startsWith(normalized + '-') || d === normalized);
} catch { /* intentionally empty */ }
// Find target directory
const targetDir = readSubdirectories(phasesDir, true)
.find(d => d.startsWith(normalized + '-') || d === normalized) || null;
// Check for executed work (SUMMARY.md files)
// Guard against removing executed work
if (targetDir && !force) {
const targetPath = path.join(phasesDir, targetDir);
const files = fs.readdirSync(targetPath);
const files = fs.readdirSync(path.join(phasesDir, targetDir));
const summaries = files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
if (summaries.length > 0) {
error(`Phase ${targetPhase} has ${summaries.length} executed plan(s). Use --force to remove anyway.`);
}
}
// Delete target directory
if (targetDir) {
fs.rmSync(path.join(phasesDir, targetDir), { recursive: true, force: true });
}
if (targetDir) fs.rmSync(path.join(phasesDir, targetDir), { recursive: true, force: true });
// Renumber subsequent phases
const renamedDirs = [];
const renamedFiles = [];
if (isDecimal) {
// Decimal removal: renumber sibling decimals (e.g., removing 06.2 → 06.3 becomes 06.2)
const baseParts = normalized.split('.');
const baseInt = baseParts[0];
const removedDecimal = parseInt(baseParts[1], 10);
try {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b));
// Find sibling decimals with higher numbers
const decPattern = new RegExp(`^${baseInt}\\.(\\d+)-(.+)$`);
const toRename = [];
for (const dir of dirs) {
const dm = dir.match(decPattern);
if (dm && parseInt(dm[1], 10) > removedDecimal) {
toRename.push({ dir, oldDecimal: parseInt(dm[1], 10), slug: dm[2] });
}
}
// Sort descending to avoid conflicts
toRename.sort((a, b) => b.oldDecimal - a.oldDecimal);
for (const item of toRename) {
const newDecimal = item.oldDecimal - 1;
const oldPhaseId = `${baseInt}.${item.oldDecimal}`;
const newPhaseId = `${baseInt}.${newDecimal}`;
const newDirName = `${baseInt}.${newDecimal}-${item.slug}`;
// Rename directory
fs.renameSync(path.join(phasesDir, item.dir), path.join(phasesDir, newDirName));
renamedDirs.push({ from: item.dir, to: newDirName });
// Rename files inside
const dirFiles = fs.readdirSync(path.join(phasesDir, newDirName));
for (const f of dirFiles) {
// Files may have phase prefix like "06.2-01-PLAN.md"
if (f.includes(oldPhaseId)) {
const newFileName = f.replace(oldPhaseId, newPhaseId);
fs.renameSync(
path.join(phasesDir, newDirName, f),
path.join(phasesDir, newDirName, newFileName)
);
renamedFiles.push({ from: f, to: newFileName });
}
}
}
} catch { /* intentionally empty */ }
} else {
// Integer removal: renumber all subsequent integer phases
const removedInt = parseInt(normalized, 10);
try {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b));
// Collect directories that need renumbering (integer phases > removed, and their decimals/letters)
const toRename = [];
for (const dir of dirs) {
const dm = dir.match(/^(\d+)([A-Z])?(?:\.(\d+))?-(.+)$/i);
if (!dm) continue;
const dirInt = parseInt(dm[1], 10);
if (dirInt > removedInt) {
toRename.push({
dir,
oldInt: dirInt,
letter: dm[2] ? dm[2].toUpperCase() : '',
decimal: dm[3] ? parseInt(dm[3], 10) : null,
slug: dm[4],
});
}
}
// Sort descending to avoid conflicts
toRename.sort((a, b) => {
if (a.oldInt !== b.oldInt) return b.oldInt - a.oldInt;
return (b.decimal || 0) - (a.decimal || 0);
});
for (const item of toRename) {
const newInt = item.oldInt - 1;
const newPadded = String(newInt).padStart(2, '0');
const oldPadded = String(item.oldInt).padStart(2, '0');
const letterSuffix = item.letter || '';
const decimalSuffix = item.decimal !== null ? `.${item.decimal}` : '';
const oldPrefix = `${oldPadded}${letterSuffix}${decimalSuffix}`;
const newPrefix = `${newPadded}${letterSuffix}${decimalSuffix}`;
const newDirName = `${newPrefix}-${item.slug}`;
// Rename directory
fs.renameSync(path.join(phasesDir, item.dir), path.join(phasesDir, newDirName));
renamedDirs.push({ from: item.dir, to: newDirName });
// Rename files inside
const dirFiles = fs.readdirSync(path.join(phasesDir, newDirName));
for (const f of dirFiles) {
if (f.startsWith(oldPrefix)) {
const newFileName = newPrefix + f.slice(oldPrefix.length);
fs.renameSync(
path.join(phasesDir, newDirName, f),
path.join(phasesDir, newDirName, newFileName)
);
renamedFiles.push({ from: f, to: newFileName });
}
}
}
} catch { /* intentionally empty */ }
}
// Renumber subsequent phases on disk
let renamedDirs = [], renamedFiles = [];
try {
const renamed = isDecimal
? renameDecimalPhases(phasesDir, normalized.split('.')[0], parseInt(normalized.split('.')[1], 10))
: renameIntegerPhases(phasesDir, parseInt(normalized, 10));
renamedDirs = renamed.renamedDirs;
renamedFiles = renamed.renamedFiles;
} catch { /* intentionally empty */ }
// Update ROADMAP.md
let roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
// Remove the target phase section
const targetEscaped = escapeRegex(targetPhase);
const sectionPattern = new RegExp(
`\\n?#{2,4}\\s*Phase\\s+${targetEscaped}\\s*:[\\s\\S]*?(?=\\n#{2,4}\\s+Phase\\s+\\d|$)`,
'i'
);
roadmapContent = roadmapContent.replace(sectionPattern, '');
// Remove from phase list (checkbox)
const checkboxPattern = new RegExp(`\\n?-\\s*\\[[ x]\\]\\s*.*Phase\\s+${targetEscaped}[:\\s][^\\n]*`, 'gi');
roadmapContent = roadmapContent.replace(checkboxPattern, '');
// Remove from progress table
const tableRowPattern = new RegExp(`\\n?\\|\\s*${targetEscaped}\\.?\\s[^|]*\\|[^\\n]*`, 'gi');
roadmapContent = roadmapContent.replace(tableRowPattern, '');
// Renumber references in ROADMAP for subsequent phases
if (!isDecimal) {
const removedInt = parseInt(normalized, 10);
// Collect all integer phases > removedInt
const maxPhase = 99; // reasonable upper bound
for (let oldNum = maxPhase; oldNum > removedInt; oldNum--) {
const newNum = oldNum - 1;
const oldStr = String(oldNum);
const newStr = String(newNum);
const oldPad = oldStr.padStart(2, '0');
const newPad = newStr.padStart(2, '0');
// Phase headings: ## Phase 18: or ### Phase 18: → ## Phase 17: or ### Phase 17:
roadmapContent = roadmapContent.replace(
new RegExp(`(#{2,4}\\s*Phase\\s+)${oldStr}(\\s*:)`, 'gi'),
`$1${newStr}$2`
);
// Checkbox items: - [ ] **Phase 18:** → - [ ] **Phase 17:**
roadmapContent = roadmapContent.replace(
new RegExp(`(Phase\\s+)${oldStr}([:\\s])`, 'g'),
`$1${newStr}$2`
);
// Plan references: 18-01 → 17-01
roadmapContent = roadmapContent.replace(
new RegExp(`${oldPad}-(\\d{2})`, 'g'),
`${newPad}-$1`
);
// Table rows: | 18. → | 17.
roadmapContent = roadmapContent.replace(
new RegExp(`(\\|\\s*)${oldStr}\\.\\s`, 'g'),
`$1${newStr}. `
);
// Depends on references
roadmapContent = roadmapContent.replace(
new RegExp(`(Depends on:\\*\\*\\s*Phase\\s+)${oldStr}\\b`, 'gi'),
`$1${newStr}`
);
}
}
fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8');
updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, parseInt(normalized, 10));
// Update STATE.md phase count
const statePath = path.join(planningDir(cwd), 'STATE.md');
if (fs.existsSync(statePath)) {
let stateContent = fs.readFileSync(statePath, 'utf-8');
// Update "Total Phases" field — supports both bold and plain formats
const totalRaw = stateExtractField(stateContent, 'Total Phases');
if (totalRaw) {
const oldTotal = parseInt(totalRaw, 10);
stateContent = stateReplaceField(stateContent, 'Total Phases', String(oldTotal - 1)) || stateContent;
stateContent = stateReplaceField(stateContent, 'Total Phases', String(parseInt(totalRaw, 10) - 1)) || stateContent;
}
// Update "Phase: X of Y" pattern
const ofPattern = /(\bof\s+)(\d+)(\s*(?:\(|phases?))/i;
const ofMatch = stateContent.match(ofPattern);
const ofMatch = stateContent.match(/(\bof\s+)(\d+)(\s*(?:\(|phases?))/i);
if (ofMatch) {
const oldTotal = parseInt(ofMatch[2], 10);
stateContent = stateContent.replace(ofPattern, `$1${oldTotal - 1}$3`);
stateContent = stateContent.replace(/(\bof\s+)(\d+)(\s*(?:\(|phases?))/i, `$1${parseInt(ofMatch[2], 10) - 1}$3`);
}
writeStateMd(statePath, stateContent, cwd);
}
const result = {
output({
removed: targetPhase,
directory_deleted: targetDir || null,
directory_deleted: targetDir,
renamed_directories: renamedDirs,
renamed_files: renamedFiles,
roadmap_updated: true,
state_updated: fs.existsSync(statePath),
};
output(result, raw);
}, raw);
}
function cmdPhaseComplete(cwd, phaseNum, raw) {

View File

@@ -10,7 +10,8 @@
const fs = require('fs');
const path = require('path');
const { output, error, planningPaths, planningRoot, toPosixPath, getMilestoneInfo, generateSlugInternal, setActiveWorkstream, getActiveWorkstream } = require('./core.cjs');
const { output, error, planningPaths, planningRoot, toPosixPath, getMilestoneInfo, generateSlugInternal, setActiveWorkstream, getActiveWorkstream, filterPlanFiles, filterSummaryFiles, readSubdirectories } = require('./core.cjs');
const { stateExtractField } = require('./state.cjs');
// ─── Migration ──────────────────────────────────────────────────────────────
@@ -184,25 +185,23 @@ function cmdWorkstreamList(cwd, raw) {
const wsDir = path.join(wsRoot, entry.name);
const phasesDir = path.join(wsDir, 'phases');
let phaseCount = 0, completedCount = 0;
try {
const dirs = fs.readdirSync(phasesDir, { withFileTypes: true }).filter(e => e.isDirectory());
phaseCount = dirs.length;
for (const d of dirs) {
const files = fs.readdirSync(path.join(phasesDir, d.name));
const plans = files.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md');
const summaries = files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
const phaseDirs = readSubdirectories(phasesDir);
const phaseCount = phaseDirs.length;
let completedCount = 0;
for (const d of phaseDirs) {
try {
const phaseFiles = fs.readdirSync(path.join(phasesDir, d));
const plans = filterPlanFiles(phaseFiles);
const summaries = filterSummaryFiles(phaseFiles);
if (plans.length > 0 && summaries.length >= plans.length) completedCount++;
}
} catch {}
} catch {}
}
let status = 'unknown', currentPhase = null;
try {
const stateContent = fs.readFileSync(path.join(wsDir, 'STATE.md'), 'utf-8');
const statusMatch = stateContent.match(/\*\*Status:\*\*\s*(.+)/);
if (statusMatch) status = statusMatch[1].trim();
const phaseMatch = stateContent.match(/\*\*Current Phase:\*\*\s*(.+)/);
if (phaseMatch) currentPhase = phaseMatch[1].trim();
status = stateExtractField(stateContent, 'Status') || 'unknown';
currentPhase = stateExtractField(stateContent, 'Current Phase');
} catch {}
workstreams.push({
@@ -240,14 +239,11 @@ function cmdWorkstreamStatus(cwd, name, raw) {
};
const phases = [];
try {
const dirs = fs.readdirSync(p.phases, { withFileTypes: true })
.filter(e => e.isDirectory()).map(e => e.name).sort();
for (const dir of dirs) {
const phasePath = path.join(p.phases, dir);
const phaseFiles = fs.readdirSync(phasePath);
const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md');
const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
for (const dir of readSubdirectories(p.phases).sort()) {
try {
const phaseFiles = fs.readdirSync(path.join(p.phases, dir));
const plans = filterPlanFiles(phaseFiles);
const summaries = filterSummaryFiles(phaseFiles);
phases.push({
directory: dir,
status: summaries.length >= plans.length && plans.length > 0 ? 'complete' :
@@ -255,19 +251,16 @@ function cmdWorkstreamStatus(cwd, name, raw) {
plan_count: plans.length,
summary_count: summaries.length,
});
}
} catch {}
} catch {}
}
let stateInfo = {};
try {
const stateContent = fs.readFileSync(p.state, 'utf-8');
const statusMatch = stateContent.match(/\*\*Status:\*\*\s*(.+)/);
const phaseMatch = stateContent.match(/\*\*Current Phase:\*\*\s*(.+)/);
const activityMatch = stateContent.match(/\*\*Last Activity:\*\*\s*(.+)/);
stateInfo = {
status: statusMatch ? statusMatch[1].trim() : 'unknown',
current_phase: phaseMatch ? phaseMatch[1].trim() : null,
last_activity: activityMatch ? activityMatch[1].trim() : null,
status: stateExtractField(stateContent, 'Status') || 'unknown',
current_phase: stateExtractField(stateContent, 'Current Phase'),
last_activity: stateExtractField(stateContent, 'Last Activity'),
};
} catch {}
@@ -392,19 +385,19 @@ function cmdWorkstreamProgress(cwd, raw) {
const wsDir = path.join(wsRoot, entry.name);
const phasesDir = path.join(wsDir, 'phases');
let phaseCount = 0, completedCount = 0, totalPlans = 0, completedPlans = 0;
try {
const dirs = fs.readdirSync(phasesDir, { withFileTypes: true }).filter(e => e.isDirectory());
phaseCount = dirs.length;
for (const d of dirs) {
const files = fs.readdirSync(path.join(phasesDir, d.name));
const plans = files.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md');
const summaries = files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
const phaseDirsProgress = readSubdirectories(phasesDir);
const phaseCount = phaseDirsProgress.length;
let completedCount = 0, totalPlans = 0, completedPlans = 0;
for (const d of phaseDirsProgress) {
try {
const phaseFiles = fs.readdirSync(path.join(phasesDir, d));
const plans = filterPlanFiles(phaseFiles);
const summaries = filterSummaryFiles(phaseFiles);
totalPlans += plans.length;
completedPlans += Math.min(summaries.length, plans.length);
if (plans.length > 0 && summaries.length >= plans.length) completedCount++;
}
} catch {}
} catch {}
}
let roadmapPhaseCount = phaseCount;
try {
@@ -416,10 +409,8 @@ function cmdWorkstreamProgress(cwd, raw) {
let status = 'unknown', currentPhase = null;
try {
const stateContent = fs.readFileSync(path.join(wsDir, 'STATE.md'), 'utf-8');
const statusMatch = stateContent.match(/\*\*Status:\*\*\s*(.+)/);
if (statusMatch) status = statusMatch[1].trim();
const phaseMatch = stateContent.match(/\*\*Current Phase:\*\*\s*(.+)/);
if (phaseMatch) currentPhase = phaseMatch[1].trim();
status = stateExtractField(stateContent, 'Status') || 'unknown';
currentPhase = stateExtractField(stateContent, 'Current Phase');
} catch {}
workstreams.push({
@@ -459,10 +450,8 @@ function getOtherActiveWorkstreams(cwd, excludeWs) {
let status = 'unknown', currentPhase = null;
try {
const content = fs.readFileSync(statePath, 'utf-8');
const statusMatch = content.match(/\*\*Status:\*\*\s*(.+)/);
if (statusMatch) status = statusMatch[1].trim();
const phaseMatch = content.match(/\*\*Current Phase:\*\*\s*(.+)/);
if (phaseMatch) currentPhase = phaseMatch[1].trim();
status = stateExtractField(content, 'Status') || 'unknown';
currentPhase = stateExtractField(content, 'Current Phase');
} catch {}
if (status.toLowerCase().includes('milestone complete') ||
@@ -470,18 +459,18 @@ function getOtherActiveWorkstreams(cwd, excludeWs) {
continue;
}
let phaseCount = 0, completedCount = 0;
try {
const phasesDir = path.join(wsDir, 'phases');
const dirs = fs.readdirSync(phasesDir, { withFileTypes: true }).filter(e => e.isDirectory());
phaseCount = dirs.length;
for (const d of dirs) {
const files = fs.readdirSync(path.join(phasesDir, d.name));
const plans = files.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md');
const summaries = files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
const phasesDir = path.join(wsDir, 'phases');
const phaseDirsOther = readSubdirectories(phasesDir);
const phaseCount = phaseDirsOther.length;
let completedCount = 0;
for (const d of phaseDirsOther) {
try {
const phaseFiles = fs.readdirSync(path.join(phasesDir, d));
const plans = filterPlanFiles(phaseFiles);
const summaries = filterSummaryFiles(phaseFiles);
if (plans.length > 0 && summaries.length >= plans.length) completedCount++;
}
} catch {}
} catch {}
}
others.push({ name: entry.name, status, current_phase: currentPhase, phases: `${completedCount}/${phaseCount}` });
}