From 0f17cfc71d116d7e421fa3321271f16d8fcc8f55 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 26 Apr 2026 10:28:48 -0400 Subject: [PATCH] fix(phase-lifecycle): skip
-wrapped sections in milestone detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit replaceInCurrentMilestone's lastIndexOf('
') heuristic fails when the active milestone itself is wrapped in a
block — the after-slice is empty so the replacement is silently dropped. Fix detects this case (after.trim().length === 0) and falls back to locating the last complete
…
span and applying the replacement only inside it, leaving all earlier archived-milestone blocks untouched. Closes #2641 --- sdk/src/query/phase-lifecycle.ts | 41 +++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/sdk/src/query/phase-lifecycle.ts b/sdk/src/query/phase-lifecycle.ts index 5211c46a6..48620dbd1 100644 --- a/sdk/src/query/phase-lifecycle.ts +++ b/sdk/src/query/phase-lifecycle.ts @@ -85,6 +85,12 @@ function generateSlugInternal(text: string): string { * If no `
` blocks exist, replaces in the entire content. * Otherwise, only replaces in content after the last `` close tag. * + * Edge case: when the active milestone is itself wrapped in a `
` block + * (e.g. collapsed before it is fully shipped), the last `
` belongs to + * the active milestone and the `after` slice is empty. In that case the function + * falls back to searching the full content with all complete `
` blocks + * stripped, so archived milestones are never touched. + * * @param content - Full ROADMAP.md content * @param pattern - Regex or string pattern to match * @param replacement - Replacement string @@ -102,7 +108,40 @@ export function replaceInCurrentMilestone( const offset = lastDetailsClose + '
'.length; const before = content.slice(0, offset); const after = content.slice(offset); - return before + after.replace(pattern, replacement); + + // Fast path: the current milestone is not inside a
block — the + // pattern lives in the plain text after the last
. + if (after.trim().length > 0) { + return before + after.replace(pattern, replacement); + } + + // Slow path: the active milestone is inside the last
block. + // Strip every complete
…
block except the last one, then + // apply the replacement inside that last block while leaving the stripped + // (archived) blocks untouched. + // + // Strategy: + // 1. Collect all complete
…
spans. + // 2. Replace only inside the LAST span; leave earlier spans unchanged. + const detailsBlockRe = /
[\s\S]*?<\/details>/gi; + const spans: { start: number; end: number; text: string }[] = []; + let m: RegExpExecArray | null; + while ((m = detailsBlockRe.exec(content)) !== null) { + spans.push({ start: m.index, end: m.index + m[0].length, text: m[0] }); + } + + if (spans.length === 0) { + // No complete blocks found — fall back to full-content replace. + return content.replace(pattern, replacement); + } + + const lastSpan = spans[spans.length - 1]; + const updatedLastBlock = lastSpan.text.replace(pattern, replacement); + return ( + content.slice(0, lastSpan.start) + + updatedLastBlock + + content.slice(lastSpan.end) + ); } // ─── readModifyWriteRoadmapMd ───────────────────────────────────────────