fix: treat milestones with summary but no roadmap as complete (#13)

Milestones completed before the roadmap convention (e.g. M001-M003)
have SUMMARY files but no ROADMAP. Previously these were treated as
incomplete, causing the first one to become the active milestone with
a fallback title like 'M001: M001'.

Now getActiveMilestoneId(), the completeMilestoneIds pre-computation,
and the deriveState() registry loop all check for a SUMMARY file when
no roadmap exists and mark the milestone as complete, extracting the
title from the summary H1.
This commit is contained in:
jonathancostin 2026-03-11 03:01:18 -05:00 committed by GitHub
parent 729218e58e
commit 424f7edf72
2 changed files with 76 additions and 3 deletions

View file

@ -79,7 +79,12 @@ export async function getActiveMilestoneId(basePath: string): Promise<string | n
for (const mid of milestoneIds) {
const roadmapFile = resolveMilestoneFile(basePath, mid, "ROADMAP");
const content = roadmapFile ? await loadFile(roadmapFile) : null;
if (!content) return mid; // No roadmap yet — milestone is incomplete
if (!content) {
// No roadmap — but if a summary exists, the milestone is already complete
const summaryFile = resolveMilestoneFile(basePath, mid, "SUMMARY");
if (summaryFile) continue; // completed milestone, skip
return mid; // No roadmap and no summary — milestone is incomplete
}
const roadmap = parseRoadmap(content);
if (!isMilestoneComplete(roadmap)) return mid;
}
@ -117,7 +122,12 @@ export async function deriveState(basePath: string): Promise<GSDState> {
for (const mid of milestoneIds) {
const rf = resolveMilestoneFile(basePath, mid, "ROADMAP");
const rc = rf ? await loadFile(rf) : null;
if (!rc) continue;
if (!rc) {
// No roadmap — milestone is complete if it has a summary
const sf = resolveMilestoneFile(basePath, mid, "SUMMARY");
if (sf) completeMilestoneIds.add(mid);
continue;
}
const rmap = parseRoadmap(rc);
if (!isMilestoneComplete(rmap)) continue;
const sf = resolveMilestoneFile(basePath, mid, "SUMMARY");
@ -134,7 +144,18 @@ export async function deriveState(basePath: string): Promise<GSDState> {
const roadmapFile = resolveMilestoneFile(basePath, mid, "ROADMAP");
const content = roadmapFile ? await loadFile(roadmapFile) : null;
if (!content) {
// No roadmap yet — treat as incomplete/active
// No roadmap — check if a summary exists (completed milestone without roadmap)
const summaryFile = resolveMilestoneFile(basePath, mid, "SUMMARY");
if (summaryFile) {
const summaryContent = await loadFile(summaryFile);
const summaryTitle = summaryContent
? (parseSummary(summaryContent).title || mid)
: mid;
registry.push({ id: mid, title: summaryTitle, status: 'complete' });
completeMilestoneIds.add(mid);
continue;
}
// No roadmap and no summary — treat as incomplete/active
if (!activeMilestoneFound) {
activeMilestone = { id: mid, title: mid };
activeMilestoneFound = true;

View file

@ -618,6 +618,58 @@ Continue from step 2.
}
}
// ═══ Milestone with summary but no roadmap → complete ═══════════════════
{
console.log('\n=== milestone with summary and no roadmap → complete ===');
const base = createFixtureBase();
try {
// M001, M002: completed milestones with summaries but no roadmaps
const m1dir = join(base, '.gsd', 'milestones', 'M001');
mkdirSync(m1dir, { recursive: true });
writeFileSync(join(m1dir, 'M001-SUMMARY.md'), '---\nid: M001\n---\n# Bootstrap\nDone.');
const m2dir = join(base, '.gsd', 'milestones', 'M002');
mkdirSync(m2dir, { recursive: true });
writeFileSync(join(m2dir, 'M002-SUMMARY.md'), '---\nid: M002\n---\n# Core Features\nDone.');
// M003: active milestone with a roadmap
writeRoadmap(base, 'M003', '# M003: Polish\n## Slices\n- [ ] **S01: Cleanup**');
const state = await deriveState(base);
assertEq(state.phase, 'planning', 'summary-no-roadmap: phase is planning (active is M003)');
assertEq(state.activeMilestone?.id, 'M003', 'summary-no-roadmap: active milestone is M003');
assertEq(state.activeMilestone?.title, 'Polish', 'summary-no-roadmap: active title is Polish');
assertEq(state.registry.length, 3, 'summary-no-roadmap: registry has 3 entries');
assertEq(state.registry[0]?.status, 'complete', 'summary-no-roadmap: M001 is complete');
assertEq(state.registry[0]?.title, 'Bootstrap', 'summary-no-roadmap: M001 title from summary');
assertEq(state.registry[1]?.status, 'complete', 'summary-no-roadmap: M002 is complete');
assertEq(state.registry[1]?.title, 'Core Features', 'summary-no-roadmap: M002 title from summary');
assertEq(state.registry[2]?.status, 'active', 'summary-no-roadmap: M003 is active');
assertEq(state.progress?.milestones?.done, 2, 'summary-no-roadmap: milestones done = 2');
assertEq(state.progress?.milestones?.total, 3, 'summary-no-roadmap: milestones total = 3');
} finally {
cleanup(base);
}
}
// ═══ All milestones have summary but no roadmap → complete ═════════════
{
console.log('\n=== all milestones summary-only → complete ===');
const base = createFixtureBase();
try {
const m1dir = join(base, '.gsd', 'milestones', 'M001');
mkdirSync(m1dir, { recursive: true });
writeFileSync(join(m1dir, 'M001-SUMMARY.md'), '---\ntitle: Done\n---\nAll done.');
const state = await deriveState(base);
assertEq(state.phase, 'complete', 'all-summary-only: phase is complete');
assertEq(state.registry[0]?.status, 'complete', 'all-summary-only: M001 is complete');
} finally {
cleanup(base);
}
}
// ═════════════════════════════════════════════════════════════════════════
// Results
// ═════════════════════════════════════════════════════════════════════════