diff --git a/src/roadmap-parser.cts b/src/roadmap-parser.cts index b0236d065..33845d957 100644 --- a/src/roadmap-parser.cts +++ b/src/roadmap-parser.cts @@ -275,6 +275,19 @@ interface MilestoneInfo { name: string; } +/** + * Strip a leading delimiter run (whitespace, em/en-dash, colon, hyphen) from a + * milestone-name capture. Markdown headings commonly take the shape + * `## vX.Y — Name` or `## vX.Y: Name`; the raw capture includes the delimiter + * because `.trim()` only removes whitespace, not punctuation. A name beginning + * with punctuation is a delimiter-led fragment, not the curated name (#2135). + * NOTE: do not strip `#` — a name beginning with `#` is a heading-parse failure + * that should stay loud rather than be silently cleaned. + */ +function stripLeadingDelimiter(s: string): string { + return s.replace(/^[\s—–:-]+/, '').trim(); +} + function getMilestoneInfo(cwd: string): MilestoneInfo { try { const roadmap = platformReadSync(path.join(planningDir(cwd), 'ROADMAP.md')); @@ -294,22 +307,34 @@ function getMilestoneInfo(cwd: string): MilestoneInfo { if (stateVersion) { const escapedVer = escapeRegex(stateVersion); - const headingMatch = roadmap.match( - new RegExp(`##[^\\n]*${escapedVer}[:\\s]+([^\\n(]+)`, 'i') + + // #2135: consult the 🚧 name-bearing marker FIRST. It is the only construct + // guaranteed to carry the milestone's curated name adjacent to its version + // (the active-milestone bullet). A `##` heading is often nameless + // ("## vX.Y — Active Milestone") and, when unanchored, was matched + // spuriously on a copy quoted inside backticks in this very bullet. + const listMatch = roadmap.match( + new RegExp(`🚧\\s*\\*?\\*?${escapedVer}\\s+([^*\\n]+)`, 'i') ); - if (headingMatch) { - if (!headingMatch[0].includes('āœ…')) { - return { version: stateVersion, name: headingMatch[1].trim() }; - } - } else { - const listMatch = roadmap.match( - new RegExp(`🚧\\s*\\*?\\*?${escapedVer}\\s+([^*\\n]+)`, 'i') - ); - if (listMatch) { - return { version: stateVersion, name: listMatch[1].trim() }; - } - return { version: stateVersion, name: 'milestone' }; + if (listMatch) { + const name = stripLeadingDelimiter(listMatch[1]); + if (name) return { version: stateVersion, name }; } + + // Fall back to the `##` heading — ANCHORED to line start (`^` + `m` flag) + // so a heading quoted inside backticks or prose mid-line can no longer + // match. Skip shipped (āœ…) headings. + const headingMatch = roadmap.match( + new RegExp(`^##[^\\n]*${escapedVer}[:\\s]+([^\\n(]+)`, 'im') + ); + if (headingMatch && !headingMatch[0].includes('āœ…')) { + // Strip a leading delimiter — `.trim()` removes whitespace, not the + // em-dash/colon that conventionally separates version from name. + const name = stripLeadingDelimiter(headingMatch[1]); + if (name) return { version: stateVersion, name }; + } + + return { version: stateVersion, name: 'milestone' }; } const inProgressMatch = roadmap.match(/🚧\s*\*\*v(\d+(?:\.\d+)+)\s+([^*]+)\*\*/); diff --git a/src/state.cts b/src/state.cts index 06b41be4f..b28faeb40 100644 --- a/src/state.cts +++ b/src/state.cts @@ -1573,8 +1573,18 @@ function syncStateFrontmatter(content: string, cwd: string | undefined): string // existing frontmatter already holds; only an empty derived value falls through // to this guard (the primary #905 preserve path below handles that). const MILESTONE_NAME_PLACEHOLDER = 'milestone'; + // #2135: widen the preserve guard. A bad derive is not always the literal + // placeholder — getMilestoneInfo can return a delimiter-led fragment + // ("— Active Milestone") when the roadmap regex mis-binds. Preserve the + // existing curated name unless the derived value actually looks like a name: + // non-empty, not the placeholder, and not punctuation-led. + const derivedName = derivedFm['milestone_name']; + const derivedLooksLikeName = typeof derivedName === 'string' + && derivedName.length > 0 + && derivedName !== MILESTONE_NAME_PLACEHOLDER + && !/^[\s—–:-]/.test(derivedName); if ( - derivedFm['milestone_name'] === MILESTONE_NAME_PLACEHOLDER && + !derivedLooksLikeName && existingFm['milestone_name'] && existingFm['milestone_name'] !== MILESTONE_NAME_PLACEHOLDER ) {