diff --git a/sdk/src/query/phase-lifecycle.test.ts b/sdk/src/query/phase-lifecycle.test.ts index b623cf329..ab1b77c39 100644 --- a/sdk/src/query/phase-lifecycle.test.ts +++ b/sdk/src/query/phase-lifecycle.test.ts @@ -150,6 +150,82 @@ describe('replaceInCurrentMilestone', () => { expect(before).toContain('3 plans'); // old milestone untouched expect(after).toContain('4 plans'); // current milestone updated }); + + it('replaces only in current milestone when older milestones are wrapped in
', async () => { + const { replaceInCurrentMilestone } = await import('./phase-lifecycle.js'); + const content = [ + '# Roadmap', + '', + '
', + '✅ v1.18 (shipped)', + '', + '### Phase 1: Old Phase', + '', + '- [ ] Phase 1: Old Phase', + '', + '
', + '', + '
', + '✅ v1.19 (shipped)', + '', + '### Phase 2: Another Old Phase', + '', + '
', + '', + '## Current Milestone: v1.20', + '', + '- [ ] Phase 3: Current work', + '', + '### Phase 3: Current work', + '', + '**Plans:** 0/2 plans', + '', + ].join('\n'); + + const pattern = /\*\*Plans:\*\* [^\n]+/; + const result = replaceInCurrentMilestone(content, pattern, '**Plans:** 2/2 plans complete'); + + // Should update Phase 3's Plans line (current milestone) + expect(result).toContain('**Plans:** 2/2 plans complete'); + // Should NOT touch v1.18 or v1.19 sections + expect(result).toContain('✅ v1.18'); + expect(result).toContain('✅ v1.19'); + }); + + it('replaces inside active milestone when it is wrapped in a
block', async () => { + const { replaceInCurrentMilestone } = await import('./phase-lifecycle.js'); + // Scenario: active milestone is collapsed in
(e.g. user collapsed it) + const content = [ + '# Roadmap', + '', + '
', + '✅ v1.18 (shipped)', + '', + '### Phase 1: Old Phase', + '', + '**Plans:** 1/1 plans', + '', + '
', + '', + '
', + '🚧 v1.19 in-progress', + '', + '### Phase 2: Current Work', + '', + '**Plans:** 1/2 plans', + '', + '
', + '', + ].join('\n'); + + const pattern = /\*\*Plans:\*\* [^\n]+/g; + const result = replaceInCurrentMilestone(content, pattern, '**Plans:** 2/2 plans complete'); + + // The replacement should happen somewhere in the content (not silently dropped) + expect(result).toContain('**Plans:** 2/2 plans complete'); + // v1.18 old plans line should remain untouched + expect(result).toContain('**Plans:** 1/1 plans'); + }); }); // ─── readModifyWriteRoadmapMd ─────────────────────────────────────────── diff --git a/sdk/src/query/phase-lifecycle.ts b/sdk/src/query/phase-lifecycle.ts index a44e74b32..c42ebe864 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 ───────────────────────────────────────────