Includes: CLAUDE.md, settings.json, agents, commands, rules, skills, hooks, contexts, evals, get-shit-done, plugin configs (installed list and marketplace sources). Excludes credentials, runtime caches, telemetry, session data, and plugin binary cache.
940 lines
31 KiB
JavaScript
940 lines
31 KiB
JavaScript
/**
|
|
* Phase — Phase CRUD, query, and lifecycle operations
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { escapeRegex, normalizePhaseName, comparePhaseNum, findPhaseInternal, getArchivedPhaseDirs, generateSlugInternal, getMilestonePhaseFilter, stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone, toPosixPath, output, error } = require('./core.cjs');
|
|
const { extractFrontmatter } = require('./frontmatter.cjs');
|
|
const { writeStateMd } = require('./state.cjs');
|
|
|
|
function cmdPhasesList(cwd, options, raw) {
|
|
const phasesDir = path.join(cwd, '.planning', 'phases');
|
|
const { type, phase, includeArchived } = options;
|
|
|
|
// If no phases directory, return empty
|
|
if (!fs.existsSync(phasesDir)) {
|
|
if (type) {
|
|
output({ files: [], count: 0 }, raw, '');
|
|
} else {
|
|
output({ directories: [], count: 0 }, raw, '');
|
|
}
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Get all phase directories
|
|
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
|
let dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
|
|
|
|
// Include archived phases if requested
|
|
if (includeArchived) {
|
|
const archived = getArchivedPhaseDirs(cwd);
|
|
for (const a of archived) {
|
|
dirs.push(`${a.name} [${a.milestone}]`);
|
|
}
|
|
}
|
|
|
|
// Sort numerically (handles integers, decimals, letter-suffix, hybrids)
|
|
dirs.sort((a, b) => comparePhaseNum(a, b));
|
|
|
|
// If filtering by phase number
|
|
if (phase) {
|
|
const normalized = normalizePhaseName(phase);
|
|
const match = dirs.find(d => d.startsWith(normalized));
|
|
if (!match) {
|
|
output({ files: [], count: 0, phase_dir: null, error: 'Phase not found' }, raw, '');
|
|
return;
|
|
}
|
|
dirs = [match];
|
|
}
|
|
|
|
// If listing files of a specific type
|
|
if (type) {
|
|
const files = [];
|
|
for (const dir of dirs) {
|
|
const dirPath = path.join(phasesDir, dir);
|
|
const dirFiles = fs.readdirSync(dirPath);
|
|
|
|
let filtered;
|
|
if (type === 'plans') {
|
|
filtered = dirFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md');
|
|
} else if (type === 'summaries') {
|
|
filtered = dirFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
|
|
} else {
|
|
filtered = dirFiles;
|
|
}
|
|
|
|
files.push(...filtered.sort());
|
|
}
|
|
|
|
const result = {
|
|
files,
|
|
count: files.length,
|
|
phase_dir: phase ? dirs[0].replace(/^\d+(?:\.\d+)*-?/, '') : null,
|
|
};
|
|
output(result, raw, files.join('\n'));
|
|
return;
|
|
}
|
|
|
|
// Default: list directories
|
|
output({ directories: dirs, count: dirs.length }, raw, dirs.join('\n'));
|
|
} catch (e) {
|
|
error('Failed to list phases: ' + e.message);
|
|
}
|
|
}
|
|
|
|
function cmdPhaseNextDecimal(cwd, basePhase, raw) {
|
|
const phasesDir = path.join(cwd, '.planning', 'phases');
|
|
const normalized = normalizePhaseName(basePhase);
|
|
|
|
// Check if phases directory exists
|
|
if (!fs.existsSync(phasesDir)) {
|
|
output(
|
|
{
|
|
found: false,
|
|
base_phase: normalized,
|
|
next: `${normalized}.1`,
|
|
existing: [],
|
|
},
|
|
raw,
|
|
`${normalized}.1`
|
|
);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
|
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
|
|
|
|
// Check if base phase exists
|
|
const baseExists = dirs.some(d => d.startsWith(normalized + '-') || d === normalized);
|
|
|
|
// Find existing decimal phases for this base
|
|
const decimalPattern = new RegExp(`^${normalized}\\.(\\d+)`);
|
|
const existingDecimals = [];
|
|
|
|
for (const dir of dirs) {
|
|
const match = dir.match(decimalPattern);
|
|
if (match) {
|
|
existingDecimals.push(`${normalized}.${match[1]}`);
|
|
}
|
|
}
|
|
|
|
// Sort numerically
|
|
existingDecimals.sort((a, b) => comparePhaseNum(a, b));
|
|
|
|
// Calculate next decimal
|
|
let nextDecimal;
|
|
if (existingDecimals.length === 0) {
|
|
nextDecimal = `${normalized}.1`;
|
|
} else {
|
|
const lastDecimal = existingDecimals[existingDecimals.length - 1];
|
|
const lastNum = parseInt(lastDecimal.split('.')[1], 10);
|
|
nextDecimal = `${normalized}.${lastNum + 1}`;
|
|
}
|
|
|
|
output(
|
|
{
|
|
found: baseExists,
|
|
base_phase: normalized,
|
|
next: nextDecimal,
|
|
existing: existingDecimals,
|
|
},
|
|
raw,
|
|
nextDecimal
|
|
);
|
|
} catch (e) {
|
|
error('Failed to calculate next decimal phase: ' + e.message);
|
|
}
|
|
}
|
|
|
|
function cmdFindPhase(cwd, phase, raw) {
|
|
if (!phase) {
|
|
error('phase identifier required');
|
|
}
|
|
|
|
const phasesDir = path.join(cwd, '.planning', 'phases');
|
|
const normalized = normalizePhaseName(phase);
|
|
|
|
const notFound = { found: false, directory: null, phase_number: null, phase_name: null, plans: [], summaries: [] };
|
|
|
|
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));
|
|
|
|
const match = dirs.find(d => d.startsWith(normalized));
|
|
if (!match) {
|
|
output(notFound, raw, '');
|
|
return;
|
|
}
|
|
|
|
const dirMatch = match.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i);
|
|
const phaseNumber = dirMatch ? dirMatch[1] : normalized;
|
|
const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null;
|
|
|
|
const phaseDir = path.join(phasesDir, 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 result = {
|
|
found: true,
|
|
directory: toPosixPath(path.join('.planning', 'phases', match)),
|
|
phase_number: phaseNumber,
|
|
phase_name: phaseName,
|
|
plans,
|
|
summaries,
|
|
};
|
|
|
|
output(result, raw, result.directory);
|
|
} catch {
|
|
output(notFound, raw, '');
|
|
}
|
|
}
|
|
|
|
function extractObjective(content) {
|
|
const m = content.match(/<objective>\s*\n?\s*(.+)/);
|
|
return m ? m[1].trim() : null;
|
|
}
|
|
|
|
function cmdPhasePlanIndex(cwd, phase, raw) {
|
|
if (!phase) {
|
|
error('phase required for phase-plan-index');
|
|
}
|
|
|
|
const phasesDir = path.join(cwd, '.planning', 'phases');
|
|
const normalized = normalizePhaseName(phase);
|
|
|
|
// Find phase directory
|
|
let phaseDir = null;
|
|
let phaseDirName = 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));
|
|
const match = dirs.find(d => d.startsWith(normalized));
|
|
if (match) {
|
|
phaseDir = path.join(phasesDir, match);
|
|
phaseDirName = match;
|
|
}
|
|
} catch {
|
|
// phases dir doesn't exist
|
|
}
|
|
|
|
if (!phaseDir) {
|
|
output({ phase: normalized, error: 'Phase not found', plans: [], waves: {}, incomplete: [], has_checkpoints: false }, raw);
|
|
return;
|
|
}
|
|
|
|
// Get all files in phase directory
|
|
const phaseFiles = fs.readdirSync(phaseDir);
|
|
const planFiles = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').sort();
|
|
const summaryFiles = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
|
|
|
|
// Build set of plan IDs with summaries
|
|
const completedPlanIds = new Set(
|
|
summaryFiles.map(s => s.replace('-SUMMARY.md', '').replace('SUMMARY.md', ''))
|
|
);
|
|
|
|
const plans = [];
|
|
const waves = {};
|
|
const incomplete = [];
|
|
let hasCheckpoints = false;
|
|
|
|
for (const planFile of planFiles) {
|
|
const planId = planFile.replace('-PLAN.md', '').replace('PLAN.md', '');
|
|
const planPath = path.join(phaseDir, planFile);
|
|
const content = fs.readFileSync(planPath, 'utf-8');
|
|
const fm = extractFrontmatter(content);
|
|
|
|
// Count tasks: XML <task> tags (canonical) or ## Task N markdown (legacy)
|
|
const xmlTasks = content.match(/<task[\s>]/gi) || [];
|
|
const mdTasks = content.match(/##\s*Task\s*\d+/gi) || [];
|
|
const taskCount = xmlTasks.length || mdTasks.length;
|
|
|
|
// Parse wave as integer
|
|
const wave = parseInt(fm.wave, 10) || 1;
|
|
|
|
// Parse autonomous (default true if not specified)
|
|
let autonomous = true;
|
|
if (fm.autonomous !== undefined) {
|
|
autonomous = fm.autonomous === 'true' || fm.autonomous === true;
|
|
}
|
|
|
|
if (!autonomous) {
|
|
hasCheckpoints = true;
|
|
}
|
|
|
|
// Parse files_modified (underscore is canonical; also accept hyphenated for compat)
|
|
let filesModified = [];
|
|
const fmFiles = fm['files_modified'] || fm['files-modified'];
|
|
if (fmFiles) {
|
|
filesModified = Array.isArray(fmFiles) ? fmFiles : [fmFiles];
|
|
}
|
|
|
|
const hasSummary = completedPlanIds.has(planId);
|
|
if (!hasSummary) {
|
|
incomplete.push(planId);
|
|
}
|
|
|
|
const plan = {
|
|
id: planId,
|
|
wave,
|
|
autonomous,
|
|
objective: extractObjective(content) || fm.objective || null,
|
|
files_modified: filesModified,
|
|
task_count: taskCount,
|
|
has_summary: hasSummary,
|
|
};
|
|
|
|
plans.push(plan);
|
|
|
|
// Group by wave
|
|
const waveKey = String(wave);
|
|
if (!waves[waveKey]) {
|
|
waves[waveKey] = [];
|
|
}
|
|
waves[waveKey].push(planId);
|
|
}
|
|
|
|
const result = {
|
|
phase: normalized,
|
|
plans,
|
|
waves,
|
|
incomplete,
|
|
has_checkpoints: hasCheckpoints,
|
|
};
|
|
|
|
output(result, raw);
|
|
}
|
|
|
|
function cmdPhaseAdd(cwd, description, raw) {
|
|
if (!description) {
|
|
error('description required for phase add');
|
|
}
|
|
|
|
const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md');
|
|
if (!fs.existsSync(roadmapPath)) {
|
|
error('ROADMAP.md not found');
|
|
}
|
|
|
|
const rawContent = fs.readFileSync(roadmapPath, 'utf-8');
|
|
const content = extractCurrentMilestone(rawContent, cwd);
|
|
const slug = generateSlugInternal(description);
|
|
|
|
// Find highest integer phase number (in current milestone only)
|
|
const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*:/gi;
|
|
let maxPhase = 0;
|
|
let m;
|
|
while ((m = phasePattern.exec(content)) !== null) {
|
|
const num = parseInt(m[1], 10);
|
|
if (num > maxPhase) maxPhase = num;
|
|
}
|
|
|
|
const newPhaseNum = maxPhase + 1;
|
|
const paddedNum = String(newPhaseNum).padStart(2, '0');
|
|
const dirName = `${paddedNum}-${slug}`;
|
|
const dirPath = path.join(cwd, '.planning', 'phases', dirName);
|
|
|
|
// Create directory with .gitkeep so git tracks empty folders
|
|
fs.mkdirSync(dirPath, { recursive: true });
|
|
fs.writeFileSync(path.join(dirPath, '.gitkeep'), '');
|
|
|
|
// Build phase entry
|
|
const phaseEntry = `\n### Phase ${newPhaseNum}: ${description}\n\n**Goal:** [To be planned]\n**Requirements**: TBD\n**Depends on:** Phase ${maxPhase}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /gsd:plan-phase ${newPhaseNum} to break down)\n`;
|
|
|
|
// Find insertion point: before last "---" or at end
|
|
let updatedContent;
|
|
const lastSeparator = rawContent.lastIndexOf('\n---');
|
|
if (lastSeparator > 0) {
|
|
updatedContent = rawContent.slice(0, lastSeparator) + phaseEntry + rawContent.slice(lastSeparator);
|
|
} else {
|
|
updatedContent = rawContent + phaseEntry;
|
|
}
|
|
|
|
fs.writeFileSync(roadmapPath, updatedContent, 'utf-8');
|
|
|
|
const result = {
|
|
phase_number: newPhaseNum,
|
|
padded: paddedNum,
|
|
name: description,
|
|
slug,
|
|
directory: `.planning/phases/${dirName}`,
|
|
};
|
|
|
|
output(result, raw, paddedNum);
|
|
}
|
|
|
|
function cmdPhaseInsert(cwd, afterPhase, description, raw) {
|
|
if (!afterPhase || !description) {
|
|
error('after-phase and description required for phase insert');
|
|
}
|
|
|
|
const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md');
|
|
if (!fs.existsSync(roadmapPath)) {
|
|
error('ROADMAP.md not found');
|
|
}
|
|
|
|
const rawContent = fs.readFileSync(roadmapPath, 'utf-8');
|
|
const content = extractCurrentMilestone(rawContent, cwd);
|
|
const slug = generateSlugInternal(description);
|
|
|
|
// Normalize input then strip leading zeros for flexible matching
|
|
const normalizedAfter = normalizePhaseName(afterPhase);
|
|
const unpadded = normalizedAfter.replace(/^0+/, '');
|
|
const afterPhaseEscaped = unpadded.replace(/\./g, '\\.');
|
|
const targetPattern = new RegExp(`#{2,4}\\s*Phase\\s+0*${afterPhaseEscaped}:`, 'i');
|
|
if (!targetPattern.test(content)) {
|
|
error(`Phase ${afterPhase} not found in ROADMAP.md`);
|
|
}
|
|
|
|
// Calculate next decimal using existing logic
|
|
const phasesDir = path.join(cwd, '.planning', 'phases');
|
|
const normalizedBase = normalizePhaseName(afterPhase);
|
|
let existingDecimals = [];
|
|
|
|
try {
|
|
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
|
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
|
|
const decimalPattern = new RegExp(`^${normalizedBase}\\.(\\d+)`);
|
|
for (const dir of dirs) {
|
|
const dm = dir.match(decimalPattern);
|
|
if (dm) existingDecimals.push(parseInt(dm[1], 10));
|
|
}
|
|
} catch {}
|
|
|
|
const nextDecimal = existingDecimals.length === 0 ? 1 : Math.max(...existingDecimals) + 1;
|
|
const decimalPhase = `${normalizedBase}.${nextDecimal}`;
|
|
const dirName = `${decimalPhase}-${slug}`;
|
|
const dirPath = path.join(cwd, '.planning', 'phases', dirName);
|
|
|
|
// Create directory with .gitkeep so git tracks empty folders
|
|
fs.mkdirSync(dirPath, { recursive: true });
|
|
fs.writeFileSync(path.join(dirPath, '.gitkeep'), '');
|
|
|
|
// Build phase entry
|
|
const phaseEntry = `\n### Phase ${decimalPhase}: ${description} (INSERTED)\n\n**Goal:** [Urgent work - to be planned]\n**Requirements**: TBD\n**Depends on:** Phase ${afterPhase}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /gsd:plan-phase ${decimalPhase} to break down)\n`;
|
|
|
|
// Insert after the target phase section
|
|
const headerPattern = new RegExp(`(#{2,4}\\s*Phase\\s+0*${afterPhaseEscaped}:[^\\n]*\\n)`, 'i');
|
|
const headerMatch = rawContent.match(headerPattern);
|
|
if (!headerMatch) {
|
|
error(`Could not find Phase ${afterPhase} header`);
|
|
}
|
|
|
|
const headerIdx = rawContent.indexOf(headerMatch[0]);
|
|
const afterHeader = rawContent.slice(headerIdx + headerMatch[0].length);
|
|
const nextPhaseMatch = afterHeader.match(/\n#{2,4}\s+Phase\s+\d/i);
|
|
|
|
let insertIdx;
|
|
if (nextPhaseMatch) {
|
|
insertIdx = headerIdx + headerMatch[0].length + nextPhaseMatch.index;
|
|
} else {
|
|
insertIdx = rawContent.length;
|
|
}
|
|
|
|
const updatedContent = rawContent.slice(0, insertIdx) + phaseEntry + rawContent.slice(insertIdx);
|
|
fs.writeFileSync(roadmapPath, updatedContent, 'utf-8');
|
|
|
|
const result = {
|
|
phase_number: decimalPhase,
|
|
after_phase: afterPhase,
|
|
name: description,
|
|
slug,
|
|
directory: `.planning/phases/${dirName}`,
|
|
};
|
|
|
|
output(result, raw, decimalPhase);
|
|
}
|
|
|
|
function cmdPhaseRemove(cwd, targetPhase, options, raw) {
|
|
if (!targetPhase) {
|
|
error('phase number required for phase remove');
|
|
}
|
|
|
|
const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md');
|
|
const phasesDir = path.join(cwd, '.planning', 'phases');
|
|
const force = options.force || false;
|
|
|
|
if (!fs.existsSync(roadmapPath)) {
|
|
error('ROADMAP.md not found');
|
|
}
|
|
|
|
// Normalize the target
|
|
const normalized = normalizePhaseName(targetPhase);
|
|
const isDecimal = targetPhase.includes('.');
|
|
|
|
// 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 {}
|
|
|
|
// Check for executed work (SUMMARY.md files)
|
|
if (targetDir && !force) {
|
|
const targetPath = path.join(phasesDir, targetDir);
|
|
const files = fs.readdirSync(targetPath);
|
|
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 });
|
|
}
|
|
|
|
// 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 {}
|
|
|
|
} 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 {}
|
|
}
|
|
|
|
// 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');
|
|
|
|
// Update STATE.md phase count
|
|
const statePath = path.join(cwd, '.planning', 'STATE.md');
|
|
if (fs.existsSync(statePath)) {
|
|
let stateContent = fs.readFileSync(statePath, 'utf-8');
|
|
// Update "Total Phases" field
|
|
const totalPattern = /(\*\*Total Phases:\*\*\s*)(\d+)/;
|
|
const totalMatch = stateContent.match(totalPattern);
|
|
if (totalMatch) {
|
|
const oldTotal = parseInt(totalMatch[2], 10);
|
|
stateContent = stateContent.replace(totalPattern, `$1${oldTotal - 1}`);
|
|
}
|
|
// Update "Phase: X of Y" pattern
|
|
const ofPattern = /(\bof\s+)(\d+)(\s*(?:\(|phases?))/i;
|
|
const ofMatch = stateContent.match(ofPattern);
|
|
if (ofMatch) {
|
|
const oldTotal = parseInt(ofMatch[2], 10);
|
|
stateContent = stateContent.replace(ofPattern, `$1${oldTotal - 1}$3`);
|
|
}
|
|
writeStateMd(statePath, stateContent, cwd);
|
|
}
|
|
|
|
const result = {
|
|
removed: targetPhase,
|
|
directory_deleted: targetDir || null,
|
|
renamed_directories: renamedDirs,
|
|
renamed_files: renamedFiles,
|
|
roadmap_updated: true,
|
|
state_updated: fs.existsSync(statePath),
|
|
};
|
|
|
|
output(result, raw);
|
|
}
|
|
|
|
function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
if (!phaseNum) {
|
|
error('phase number required for phase complete');
|
|
}
|
|
|
|
const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md');
|
|
const statePath = path.join(cwd, '.planning', 'STATE.md');
|
|
const phasesDir = path.join(cwd, '.planning', 'phases');
|
|
const normalized = normalizePhaseName(phaseNum);
|
|
const today = new Date().toISOString().split('T')[0];
|
|
|
|
// Verify phase info
|
|
const phaseInfo = findPhaseInternal(cwd, phaseNum);
|
|
if (!phaseInfo) {
|
|
error(`Phase ${phaseNum} not found`);
|
|
}
|
|
|
|
const planCount = phaseInfo.plans.length;
|
|
const summaryCount = phaseInfo.summaries.length;
|
|
let requirementsUpdated = false;
|
|
|
|
// Update ROADMAP.md: mark phase complete
|
|
if (fs.existsSync(roadmapPath)) {
|
|
let roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
|
|
|
|
// Checkbox: - [ ] Phase N: → - [x] Phase N: (...completed DATE)
|
|
const checkboxPattern = new RegExp(
|
|
`(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${escapeRegex(phaseNum)}[:\\s][^\\n]*)`,
|
|
'i'
|
|
);
|
|
roadmapContent = replaceInCurrentMilestone(roadmapContent, checkboxPattern, `$1x$2 (completed ${today})`);
|
|
|
|
// Progress table: update Status to Complete, add date
|
|
const phaseEscaped = escapeRegex(phaseNum);
|
|
const tablePattern = new RegExp(
|
|
`(\\|\\s*${phaseEscaped}\\.?\\s[^|]*\\|[^|]*\\|)\\s*[^|]*(\\|)\\s*[^|]*(\\|)`,
|
|
'i'
|
|
);
|
|
roadmapContent = replaceInCurrentMilestone(
|
|
roadmapContent, tablePattern,
|
|
`$1 Complete $2 ${today} $3`
|
|
);
|
|
|
|
// Update plan count in phase section
|
|
const planCountPattern = new RegExp(
|
|
`(#{2,4}\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`,
|
|
'i'
|
|
);
|
|
roadmapContent = replaceInCurrentMilestone(
|
|
roadmapContent, planCountPattern,
|
|
`$1${summaryCount}/${planCount} plans complete`
|
|
);
|
|
|
|
fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8');
|
|
|
|
// Update REQUIREMENTS.md traceability for this phase's requirements
|
|
const reqPath = path.join(cwd, '.planning', 'REQUIREMENTS.md');
|
|
if (fs.existsSync(reqPath)) {
|
|
// Extract the current phase section from roadmap (scoped to avoid cross-phase matching)
|
|
const phaseEsc = escapeRegex(phaseNum);
|
|
const currentMilestoneRoadmap = extractCurrentMilestone(roadmapContent, cwd);
|
|
const phaseSectionMatch = currentMilestoneRoadmap.match(
|
|
new RegExp(`(#{2,4}\\s*Phase\\s+${phaseEsc}[:\\s][\\s\\S]*?)(?=#{2,4}\\s*Phase\\s+|$)`, 'i')
|
|
);
|
|
|
|
const sectionText = phaseSectionMatch ? phaseSectionMatch[1] : '';
|
|
const reqMatch = sectionText.match(/\*\*Requirements:\*\*\s*([^\n]+)/i);
|
|
|
|
if (reqMatch) {
|
|
const reqIds = reqMatch[1].replace(/[\[\]]/g, '').split(/[,\s]+/).map(r => r.trim()).filter(Boolean);
|
|
let reqContent = fs.readFileSync(reqPath, 'utf-8');
|
|
|
|
for (const reqId of reqIds) {
|
|
const reqEscaped = escapeRegex(reqId);
|
|
// Update checkbox: - [ ] **REQ-ID** → - [x] **REQ-ID**
|
|
reqContent = reqContent.replace(
|
|
new RegExp(`(-\\s*\\[)[ ](\\]\\s*\\*\\*${reqEscaped}\\*\\*)`, 'gi'),
|
|
'$1x$2'
|
|
);
|
|
// Update traceability table: | REQ-ID | Phase N | Pending/In Progress | → | REQ-ID | Phase N | Complete |
|
|
reqContent = reqContent.replace(
|
|
new RegExp(`(\\|\\s*${reqEscaped}\\s*\\|[^|]+\\|)\\s*(?:Pending|In Progress)\\s*(\\|)`, 'gi'),
|
|
'$1 Complete $2'
|
|
);
|
|
}
|
|
|
|
fs.writeFileSync(reqPath, reqContent, 'utf-8');
|
|
requirementsUpdated = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Find next phase — check both filesystem AND roadmap
|
|
// Phases may be defined in ROADMAP.md but not yet scaffolded to disk,
|
|
// so a filesystem-only scan would incorrectly report is_last_phase:true
|
|
let nextPhaseNum = null;
|
|
let nextPhaseName = null;
|
|
let isLastPhase = true;
|
|
|
|
try {
|
|
const isDirInMilestone = getMilestonePhaseFilter(cwd);
|
|
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
|
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name)
|
|
.filter(isDirInMilestone)
|
|
.sort((a, b) => comparePhaseNum(a, b));
|
|
|
|
// Find the next phase directory after current
|
|
for (const dir of dirs) {
|
|
const dm = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i);
|
|
if (dm) {
|
|
if (comparePhaseNum(dm[1], phaseNum) > 0) {
|
|
nextPhaseNum = dm[1];
|
|
nextPhaseName = dm[2] || null;
|
|
isLastPhase = false;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
} catch {}
|
|
|
|
// Fallback: if filesystem found no next phase, check ROADMAP.md
|
|
// for phases that are defined but not yet planned (no directory on disk)
|
|
if (isLastPhase && fs.existsSync(roadmapPath)) {
|
|
try {
|
|
const roadmapForPhases = extractCurrentMilestone(fs.readFileSync(roadmapPath, 'utf-8'), cwd);
|
|
const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
|
|
let pm;
|
|
while ((pm = phasePattern.exec(roadmapForPhases)) !== null) {
|
|
if (comparePhaseNum(pm[1], phaseNum) > 0) {
|
|
nextPhaseNum = pm[1];
|
|
nextPhaseName = pm[2].replace(/\(INSERTED\)/i, '').trim().toLowerCase().replace(/\s+/g, '-');
|
|
isLastPhase = false;
|
|
break;
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
// Update STATE.md
|
|
if (fs.existsSync(statePath)) {
|
|
let stateContent = fs.readFileSync(statePath, 'utf-8');
|
|
|
|
// Update Current Phase
|
|
stateContent = stateContent.replace(
|
|
/(\*\*Current Phase:\*\*\s*).*/,
|
|
`$1${nextPhaseNum || phaseNum}`
|
|
);
|
|
|
|
// Update Current Phase Name
|
|
if (nextPhaseName) {
|
|
stateContent = stateContent.replace(
|
|
/(\*\*Current Phase Name:\*\*\s*).*/,
|
|
`$1${nextPhaseName.replace(/-/g, ' ')}`
|
|
);
|
|
}
|
|
|
|
// Update Status
|
|
stateContent = stateContent.replace(
|
|
/(\*\*Status:\*\*\s*).*/,
|
|
`$1${isLastPhase ? 'Milestone complete' : 'Ready to plan'}`
|
|
);
|
|
|
|
// Update Current Plan
|
|
stateContent = stateContent.replace(
|
|
/(\*\*Current Plan:\*\*\s*).*/,
|
|
`$1Not started`
|
|
);
|
|
|
|
// Update Last Activity
|
|
stateContent = stateContent.replace(
|
|
/(\*\*Last Activity:\*\*\s*).*/,
|
|
`$1${today}`
|
|
);
|
|
|
|
// Update Last Activity Description
|
|
stateContent = stateContent.replace(
|
|
/(\*\*Last Activity Description:\*\*\s*).*/,
|
|
`$1Phase ${phaseNum} complete${nextPhaseNum ? `, transitioned to Phase ${nextPhaseNum}` : ''}`
|
|
);
|
|
|
|
// Increment Completed Phases counter (#956)
|
|
const completedMatch = stateContent.match(/\*\*Completed Phases:\*\*\s*(\d+)/);
|
|
if (completedMatch) {
|
|
const newCompleted = parseInt(completedMatch[1], 10) + 1;
|
|
stateContent = stateContent.replace(
|
|
/(\*\*Completed Phases:\*\*\s*)\d+/,
|
|
`$1${newCompleted}`
|
|
);
|
|
|
|
// Recalculate percent based on completed / total (#956)
|
|
const totalMatch = stateContent.match(/\*\*Total Phases:\*\*\s*(\d+)/);
|
|
if (totalMatch) {
|
|
const totalPhases = parseInt(totalMatch[1], 10);
|
|
if (totalPhases > 0) {
|
|
const newPercent = Math.round((newCompleted / totalPhases) * 100);
|
|
stateContent = stateContent.replace(
|
|
/(\*\*Progress:\*\*\s*)\d+%/,
|
|
`$1${newPercent}%`
|
|
);
|
|
// Also update percent field if it exists separately
|
|
stateContent = stateContent.replace(
|
|
/(percent:\s*)\d+/,
|
|
`$1${newPercent}`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
writeStateMd(statePath, stateContent, cwd);
|
|
}
|
|
|
|
const result = {
|
|
completed_phase: phaseNum,
|
|
phase_name: phaseInfo.phase_name,
|
|
plans_executed: `${summaryCount}/${planCount}`,
|
|
next_phase: nextPhaseNum,
|
|
next_phase_name: nextPhaseName,
|
|
is_last_phase: isLastPhase,
|
|
date: today,
|
|
roadmap_updated: fs.existsSync(roadmapPath),
|
|
state_updated: fs.existsSync(statePath),
|
|
requirements_updated: requirementsUpdated,
|
|
};
|
|
|
|
output(result, raw);
|
|
}
|
|
|
|
module.exports = {
|
|
cmdPhasesList,
|
|
cmdPhaseNextDecimal,
|
|
cmdFindPhase,
|
|
cmdPhasePlanIndex,
|
|
cmdPhaseAdd,
|
|
cmdPhaseInsert,
|
|
cmdPhaseRemove,
|
|
cmdPhaseComplete,
|
|
};
|