diff --git a/.changeset/3493-extract-milestone-generic-heading.md b/.changeset/3493-extract-milestone-generic-heading.md
new file mode 100644
index 000000000..d12aa4a99
--- /dev/null
+++ b/.changeset/3493-extract-milestone-generic-heading.md
@@ -0,0 +1,5 @@
+---
+type: Fixed
+pr: 3493
+---
+**`extractCurrentMilestone` preserves generic `## Phase Details` heading** — When a roadmap placed shared phase-detail bodies under a non-version-prefixed `## Phase Details` heading AFTER a `### 📋 vX.Y+ (Planned)` sibling, the entire Phase Details section fell outside the returned milestone slice. `gsd-sdk query phase.insert N` then reported "Phase N not found in ROADMAP.md" even though `### Phase N:` was plainly present. PR #2455 (closing #2422) handled the version-prefixed variant (`## v2.0 Phase Details`) via the same-version `continue` branch; this fix extends the same intent to the generic-label variant by appending the trailing `Phase Details` block (up to the next real milestone boundary or EOF) to the active-milestone slice. The intervening planned-milestone content is skipped so it does not leak in. (#3493)
diff --git a/sdk/src/query/roadmap.test.ts b/sdk/src/query/roadmap.test.ts
index 04e80e8bf..a6f9f14b3 100644
--- a/sdk/src/query/roadmap.test.ts
+++ b/sdk/src/query/roadmap.test.ts
@@ -809,6 +809,71 @@ Detail
expect(result).toContain('Phase 100');
expect(result).toContain('Phase Details');
});
+
+ // ─── Bug #3493: generic `## Phase Details` after planned-milestone sibling ──
+ it('bug-3493: preserves generic `## Phase Details` heading after `### 📋 vX.Y+ (Planned)` sibling', async () => {
+ // Minimal repro from issue #3493 verbatim: a generic (non-version-prefixed)
+ // `## Phase Details` heading sits AFTER a `### 📋 v2.1+ (Planned)` sibling
+ // in document order. The 📋-bearing sibling otherwise terminates the slice
+ // and the generic Phase Details body — including `### Phase 4: Next` — is
+ // dropped, even though it belongs to the active v2.0 milestone.
+ const roadmap = `# Roadmap: Example
+
+## Phases
+
+
+✅ v1.0 First Milestone — SHIPPED
+- [x] **Phase 1: First** (1/1 plans)
+
+
+### v2.0 Active Milestone (Phases 2–5)
+
+- [x] **Phase 2: Foundation** (5/5 plans) — completed
+- [x] **Phase 3: Pipeline** (8/8 plans) — completed
+- [ ] **Phase 4: Next** — pending
+- [ ] **Phase 5: Final** — pending
+
+### 📋 v2.1+ (Planned — Not Yet Scoped)
+
+Candidates pending milestone selection.
+
+## Phase Details
+
+### Phase 2: Foundation
+**Goal**: Foundation goal.
+
+### Phase 3: Pipeline
+**Goal**: Pipeline goal.
+
+### Phase 4: Next
+**Goal**: Next goal.
+
+### Phase 5: Final
+**Goal**: Final goal.
+`;
+ const state = `---\nmilestone: v2.0\n---\n# State\n`;
+ await writeFile(join(tmpDir, '.planning', 'STATE.md'), state);
+ await writeFile(join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
+
+ const slice = await extractCurrentMilestone(roadmap, tmpDir);
+
+ // The generic Phase Details heading and all four detail sections must
+ // survive — they belong to the active v2.0 milestone even though they
+ // sit after the planned-milestone sibling in document order.
+ expect(slice).toContain('## Phase Details');
+ expect(slice).toContain('### Phase 2: Foundation');
+ expect(slice).toContain('### Phase 3: Pipeline');
+ expect(slice).toContain('### Phase 4: Next');
+ expect(slice).toContain('### Phase 5: Final');
+
+ // And roadmapGetPhase (which calls extractCurrentMilestone internally)
+ // must locate Phase 4's detail section.
+ const result = await roadmapGetPhase(['4'], tmpDir);
+ const data = result.data as Record;
+ expect(data.found).toBe(true);
+ expect(data.phase_number).toBe('4');
+ expect(data.phase_name).toBe('Next');
+ });
});
// ─── roadmapGetPhase ──────────────────────────────────────────────────────
diff --git a/sdk/src/query/roadmap.ts b/sdk/src/query/roadmap.ts
index d3f00a120..6e3f14cad 100644
--- a/sdk/src/query/roadmap.ts
+++ b/sdk/src/query/roadmap.ts
@@ -313,9 +313,57 @@ export async function extractCurrentMilestone(content: string, projectDir: strin
break;
}
+ // Issue #3493: a generic, non-version-prefixed `## Phase Details` (or
+ // `### Phase Details`) heading sometimes sits AFTER a planned-milestone
+ // sibling (e.g. `### 📋 v2.1+ (Planned)`) in document order but holds the
+ // detail bodies (`### Phase N: ...`) for the *active* milestone. Issue #2422
+ // / PR #2455 handled the version-prefixed variant `## v2.0 Phase Details`
+ // via the same-version `continue` above; this branch handles the generic-
+ // label variant. When such a heading is found after the initial sectionEnd,
+ // append the Phase Details block to the returned slice (skipping the
+ // intervening planned-milestone content so it does not leak in), stopping
+ // at the next real milestone boundary (version-bearing or milestone-emoji-
+ // bearing heading) or EOF.
+ //
+ // Bounded to a single append so a malformed roadmap can't loop. Only
+ // matches the literal `Phase Details` label (canonical per GSD ROADMAP
+ // template); anything else continues to terminate the slice.
+ let phaseDetailsTail = '';
+ if (sectionEnd < content.length) {
+ const afterEnd = content.slice(sectionEnd);
+ const genericPhaseDetailsRegex = /^(#{1,3})\s+Phase\s+Details\b[^\n]*$/im;
+ const phaseDetailsMatch = afterEnd.match(genericPhaseDetailsRegex);
+ if (phaseDetailsMatch && phaseDetailsMatch.index !== undefined) {
+ const pdStart = sectionEnd + phaseDetailsMatch.index;
+ const pdHeadingLen = phaseDetailsMatch[0].length;
+ const pdLevel = phaseDetailsMatch[1].length;
+ // Scan from after the Phase Details heading for the next real milestone
+ // boundary (different version OR milestone emoji). Reuses the same
+ // (?!Phase\s+\S) phase-heading guard from #2619, plus a
+ // (?!Phase\s+Details\b) guard so a sibling Phase Details heading
+ // doesn't terminate the block prematurely.
+ const afterPdHeading = content.slice(pdStart + pdHeadingLen);
+ const nextRealMilestoneRegex = new RegExp(
+ `^#{1,${pdLevel}}\\s+(?!Phase\\s+\\S)(?!Phase\\s+Details\\b)(?:.*v(\\d+(?:\\.\\d+)+)[^\\n]*|.*(?:✅|📋|🚧|🟡))`,
+ 'gmi'
+ );
+ let pdEnd = content.length;
+ let mm: RegExpExecArray | null;
+ while ((mm = nextRealMilestoneRegex.exec(afterPdHeading)) !== null) {
+ const mv = mm[1];
+ if (mv && currentVersionStr && mv === currentVersionStr) continue;
+ pdEnd = pdStart + pdHeadingLen + mm.index;
+ break;
+ }
+ phaseDetailsTail = '\n' + content.slice(pdStart, pdEnd);
+ }
+ }
+
// Return only the current milestone section — never include the preamble, which
- // may contain ## Backlog and other non-current-milestone phases.
- return content.slice(sectionStart, sectionEnd);
+ // may contain ## Backlog and other non-current-milestone phases. Append any
+ // generic Phase Details block discovered above (#3493) so detail-section
+ // lookups for the active milestone succeed.
+ return content.slice(sectionStart, sectionEnd) + phaseDetailsTail;
}
// ─── Next-milestone helpers (issue #2497) ─────────────────────────────────