Files
msd-core/sdk/src/query/roadmap-update-plan-progress.ts
2026-05-10 14:43:53 -05:00

160 lines
5.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 <N> 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,
},
};
};