From fde6af9f3879b7d00b302e793ec8ba8c3d7f9787 Mon Sep 17 00:00:00 2001 From: wangwangbobo <40018333+wangwangbobo@users.noreply.github.com> Date: Sat, 21 Mar 2026 22:48:13 +0800 Subject: [PATCH] fix: extract milestone title from CONTEXT.md when ROADMAP is missing (#1729) Fixes #1725 Added extractContextTitle() helper to parse the H1 heading from CONTEXT.md or CONTEXT-DRAFT.md files. When a milestone has no ROADMAP.md or SUMMARY.md, the title is now extracted from the context file's heading (e.g. '# M005: Platform Foundation') instead of falling back to the bare milestone ID. This affects the 'no roadmap, no summary' branch in _deriveStateImpl() where milestone titles were previously hardcoded to the milestone ID. --- src/resources/extensions/gsd/state.ts | 38 ++++++++++++++++++++------- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/src/resources/extensions/gsd/state.ts b/src/resources/extensions/gsd/state.ts index 58451ca1a..3655281a7 100644 --- a/src/resources/extensions/gsd/state.ts +++ b/src/resources/extensions/gsd/state.ts @@ -163,6 +163,18 @@ export async function deriveState(basePath: string): Promise { return result; } +/** + * Extract milestone title from CONTEXT.md or CONTEXT-DRAFT.md heading. + * Falls back to the provided fallback (usually the milestone ID). + */ +function extractContextTitle(content: string | null, fallback: string): string { + if (!content) return fallback; + const h1 = content.split('\n').find(line => line.startsWith('# ')); + if (!h1) return fallback; + // Extract title from "# M005: Platform Foundation & Separation" format + return h1.slice(2).trim().replace(/^M\d+(?:-[a-z0-9]{6})?[^:]*:\s*/, '') || fallback; +} + async function _deriveStateImpl(basePath: string): Promise { const milestoneIds = findMilestoneIds(basePath); @@ -311,27 +323,35 @@ async function _deriveStateImpl(basePath: string): Promise { // Check for CONTEXT-DRAFT.md to distinguish draft-seeded from blank milestones. // A draft seed means the milestone has discussion material but no full context yet. const contextFile = resolveMilestoneFile(basePath, mid, "CONTEXT"); - if (!contextFile) { - const draftFile = resolveMilestoneFile(basePath, mid, "CONTEXT-DRAFT"); - if (draftFile) activeMilestoneHasDraft = true; - } + const draftFile = resolveMilestoneFile(basePath, mid, "CONTEXT-DRAFT"); + if (!contextFile && draftFile) activeMilestoneHasDraft = true; + + // Extract title from CONTEXT.md or CONTEXT-DRAFT.md heading before falling back to mid. + const contextContent = contextFile ? await cachedLoadFile(contextFile) : null; + const draftContent = draftFile && !contextContent ? await cachedLoadFile(draftFile) : null; + const title = extractContextTitle(contextContent || draftContent, mid); // Check milestone-level dependencies before promoting to active. // Without this, a queued milestone with depends_on in its CONTEXT // frontmatter would be promoted to active even when its deps are unmet // (the dep check only existed in the has-roadmap path previously). - const contextContent = contextFile ? await cachedLoadFile(contextFile) : null; const deps = parseContextDependsOn(contextContent); const depsUnmet = deps.some(dep => !completeMilestoneIds.has(dep)); if (depsUnmet) { - registry.push({ id: mid, title: mid, status: 'pending', dependsOn: deps }); + registry.push({ id: mid, title, status: 'pending', dependsOn: deps }); } else { - activeMilestone = { id: mid, title: mid }; + activeMilestone = { id: mid, title }; activeMilestoneFound = true; - registry.push({ id: mid, title: mid, status: 'active', ...(deps.length > 0 ? { dependsOn: deps } : {}) }); + registry.push({ id: mid, title, status: 'active', ...(deps.length > 0 ? { dependsOn: deps } : {}) }); } } else { - registry.push({ id: mid, title: mid, status: 'pending' }); + // For milestones after the active one, also try to extract title from context files. + const contextFile = resolveMilestoneFile(basePath, mid, "CONTEXT"); + const draftFile = resolveMilestoneFile(basePath, mid, "CONTEXT-DRAFT"); + const contextContent = contextFile ? await cachedLoadFile(contextFile) : null; + const draftContent = draftFile && !contextContent ? await cachedLoadFile(draftFile) : null; + const title = extractContextTitle(contextContent || draftContent, mid); + registry.push({ id: mid, title, status: 'pending' }); } continue; }