/** * roadmap.update-plan-progress — sync ROADMAP.md progress table + plan checkboxes * from on-disk PLAN/SUMMARY counts for a phase. * * Port of `cmdRoadmapUpdatePlanProgress` from get-shit-done/bin/lib/roadmap.cjs * (lines 257–354). Uses `findPhase` for disk stats and `readModifyWriteRoadmapMd` * for atomic writes (same pattern as `phase.complete`). */ import { findPhase } from './phase.js'; import { readModifyWriteRoadmapMd, replaceInCurrentMilestone } from './phase-roadmap-mutation.js'; import { existsSync } from 'node:fs'; import { escapeRegex, planningPaths } from './helpers.js'; import { GSDError, ErrorClassification } from '../errors.js'; import type { QueryHandler } from './utils.js'; function phaseMarkdownRegexSource(phaseNum: string): string { const stripped = String(phaseNum).replace(/^[A-Z]{1,6}-(?=\d)/i, ''); const match = stripped.match(/^0*(\d+)([A-Z])?((?:\.\d+)*)$/i); if (!match) return escapeRegex(phaseNum); const integer = match[1]!.replace(/^0+/, '') || '0'; const letter = match[2] ? escapeRegex(match[2]) : ''; const decimal = match[3] ? escapeRegex(match[3]) : ''; return `0*${escapeRegex(integer)}${letter}${decimal}`; } export const roadmapUpdatePlanProgress: QueryHandler = async (args, projectDir, workstream) => { // Support --phase flag form in addition to positional (fixes #2796). // execute-phase.md:228 passes --phase so positional-only parsing silently // took the literal string "--phase" as the phase value. const phaseIdx = args.indexOf('--phase'); let phaseNum: string; const phaseFlagValue = phaseIdx !== -1 ? args[phaseIdx + 1] : undefined; if ( phaseIdx !== -1 && phaseFlagValue !== undefined && !String(phaseFlagValue).startsWith('--') ) { phaseNum = String(phaseFlagValue); } else { // Positional: skip any leading flag tokens in case of mixed invocations. const positional = args.filter((a) => !String(a).startsWith('--')); phaseNum = positional[0] ? String(positional[0]) : ''; } if (!phaseNum) { throw new GSDError('phase number required for roadmap update-plan-progress', ErrorClassification.Validation); } const phaseResult = await findPhase([phaseNum], projectDir, workstream); const info = phaseResult.data as { found: boolean; plans: string[]; summaries: string[]; }; if (!info.found) { throw new GSDError(`Phase ${phaseNum} not found`, ErrorClassification.Validation); } const planCount = info.plans.length; const summaryCount = info.summaries.length; if (planCount === 0) { return { data: { updated: false, reason: 'No plans found', plan_count: 0, summary_count: 0, }, }; } const isComplete = summaryCount >= planCount; const status = isComplete ? 'Complete' : summaryCount > 0 ? 'In Progress' : 'Planned'; const today = new Date().toISOString().split('T')[0]!; const roadmapPath = planningPaths(projectDir, workstream).roadmap; if (!existsSync(roadmapPath)) { return { data: { updated: false, reason: 'ROADMAP.md not found', plan_count: planCount, summary_count: summaryCount, }, }; } await readModifyWriteRoadmapMd(projectDir, (roadmapContent) => { const phasePattern = phaseMarkdownRegexSource(phaseNum); const tableRowPattern = new RegExp( `^(\\|\\s*${phasePattern}\\.?\\s[^|]*(?:\\|[^\\n]*))$`, 'im', ); const dateField = isComplete ? ` ${today} ` : ' '; roadmapContent = roadmapContent.replace(tableRowPattern, (fullRow) => { const cells = fullRow.split('|').slice(1, -1); if (cells.length === 5) { cells[2] = ` ${summaryCount}/${planCount} `; cells[3] = ` ${status.padEnd(11)}`; cells[4] = dateField; } else if (cells.length === 4) { cells[1] = ` ${summaryCount}/${planCount} `; cells[2] = ` ${status.padEnd(11)}`; cells[3] = dateField; } return '|' + cells.join('|') + '|'; }); const planCountPattern = new RegExp( `(#{2,4}\\s*Phase\\s+${phasePattern}(?=[:\\s])(?:(?!\\n#{2,4})[\\s\\S])*?\\*\\*Plans:\\*\\*[ \\t]*)[^\\n]+`, 'i', ); const planCountText = isComplete ? `${summaryCount}/${planCount} plans complete` : `${summaryCount}/${planCount} plans executed`; roadmapContent = replaceInCurrentMilestone(roadmapContent, planCountPattern, `$1${planCountText}`); if (isComplete) { const checkboxPattern = new RegExp( `(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${phasePattern}[:\\s][^\\n]*)`, 'i', ); roadmapContent = replaceInCurrentMilestone( roadmapContent, checkboxPattern, `$1x$2 (completed ${today})`, ); } const summaries = info.summaries; for (const summaryFile of summaries) { const planId = summaryFile.replace('-SUMMARY.md', '').replace('SUMMARY.md', ''); if (!planId) continue; const planEscaped = escapeRegex(planId); const planCheckboxPattern = new RegExp( `(-\\s*\\[) (\\]\\s*(?:\\*\\*)?${planEscaped}(?:\\*\\*)?)`, 'i', ); roadmapContent = roadmapContent.replace(planCheckboxPattern, '$1x$2'); } return roadmapContent; }, workstream); return { data: { updated: true, phase: phaseNum, plan_count: planCount, summary_count: summaryCount, status, complete: isComplete, }, }; };