/** * 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(/\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 tags (canonical) or ## Task N markdown (legacy) const xmlTasks = content.match(/]/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, };