From 3eec3345334dacbbb0a13d65591f31b30ba7041f Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 22 May 2026 14:54:15 -0400 Subject: [PATCH] fix(3816): handle milestone-scoped phase dirs in roadmap parser, phase.add, init queries (#80) * fix(#3816): handle milestone-scoped phase dirs in roadmap parser, phase.add, findPhase - phase.ts: replace hardcoded /^v[\d.]+-phases$/ regex with sortedMilestoneArchiveDirs() helper that matches any *-phases suffix and sorts the current-milestone dir first - roadmap.ts: add fallback for plain-bullet phases (- [ ] Phase N:) in searchPhaseInContent and roadmapAnalyze; truncate content at ## Backlog boundary in roadmapAnalyze to prevent backlog phases from leaking into the active-milestone phase list - phase-lifecycle.ts: add resolveWritePhasesDir() and findPhaseInsertionPoint() helpers; wire both into phaseAdd and phaseAddBatch so new phase dirs land in the milestone-scoped path and roadmap entries are inserted before ## Backlog rather than after the last --- Co-Authored-By: Claude Sonnet 4.6 * fix(3816): sort milestone archives by version, not lexically `sortedMilestoneArchiveDirs` was sorting in descending order (b before a), causing v1.10-phases to be searched before v1.2-phases. Flip to ascending so the earliest archive is searched first, matching the deterministic sort the test at milestone-archive.test.cjs:383 asserts. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- .../3816-milestone-archive-version-sort.md | 5 + sdk/src/query/phase-lifecycle.ts | 116 +++++++++++++++--- sdk/src/query/phase.test.ts | 42 +++++++ sdk/src/query/phase.ts | 99 ++++++++++++--- sdk/src/query/roadmap.test.ts | 69 +++++++++++ sdk/src/query/roadmap.ts | 102 ++++++++++++++- 6 files changed, 395 insertions(+), 38 deletions(-) create mode 100644 .changeset/3816-milestone-archive-version-sort.md diff --git a/.changeset/3816-milestone-archive-version-sort.md b/.changeset/3816-milestone-archive-version-sort.md new file mode 100644 index 000000000..adfc48099 --- /dev/null +++ b/.changeset/3816-milestone-archive-version-sort.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 80 +--- +`find-phase` now searches milestone archive directories in ascending version order (`v1.2-phases` before `v1.10-phases`) rather than descending order, making archive traversal deterministic and consistent with numeric version semantics. (#3816) diff --git a/sdk/src/query/phase-lifecycle.ts b/sdk/src/query/phase-lifecycle.ts index b76548cef..5dfc9a959 100644 --- a/sdk/src/query/phase-lifecycle.ts +++ b/sdk/src/query/phase-lifecycle.ts @@ -69,6 +69,83 @@ import { export { readModifyWriteRoadmapMd, replaceInCurrentMilestone }; +// ─── Milestone-scoped directory helpers (#3816) ─────────────────────────── + +/** + * Read the current milestone identifier from STATE.md. + * Returns null on any error (absent, unreadable, or no `milestone:` field). + */ +async function readCurrentMilestoneFromState( + projectDir: string, + workstream?: string, +): Promise { + try { + const statePath = planningPaths(projectDir, workstream).state; + const content = await readFile(statePath, 'utf-8'); + const m = content.match(/^milestone:\s*(.+)$/m); + if (!m) return null; + return m[1].trim().replace(/^["']|["']$/g, ''); + } catch { + return null; + } +} + +/** + * Resolve the phases directory for a `phase.add` / `phase.add-batch` write. + * + * When the project uses a milestone-scoped layout (`.planning/milestones/ + * -phases/` already exists on disk), return that path so newly + * created phase directories land in the correct location alongside existing + * phases (#3816 fix for Symptom 3a). + * + * Falls back to the flat `.planning/phases/` path for all other projects. + */ +async function resolveWritePhasesDir( + projectDir: string, + workstream?: string, +): Promise { + const flatPhasesDir = planningPaths(projectDir, workstream).phases; + try { + const milestone = await readCurrentMilestoneFromState(projectDir, workstream); + if (!milestone) return flatPhasesDir; + const milestoneScopedDir = join(projectDir, '.planning', 'milestones', `${milestone}-phases`); + if (existsSync(milestoneScopedDir)) { + return milestoneScopedDir; + } + } catch { /* fall through to flat layout */ } + return flatPhasesDir; +} + +/** + * Find the insertion point for a new phase entry in the ROADMAP.md content. + * + * #3816 fix for Symptom 3b: the previous implementation used + * `lastIndexOf('\n---')` which would place a new phase AFTER the Backlog + * section when Backlog precedes the trailing `---`. The new implementation + * inserts at the end of the active milestone section (just before the next + * `## Backlog` / `## Planned` / etc. heading, or before the trailing `---`). + * + * Returns the index at which the new phase entry should be inserted. + */ +function findPhaseInsertionPoint(rawContent: string): number { + // Look for the next top-level (## or ###) section that marks the end of the + // active phases list: Backlog, Planned milestones, or similar. + // Matches lines starting with ## or ### that are NOT the trailing `---`. + // We insert BEFORE this heading. + const backlogBoundaryRe = /\n(#{2,3}\s+(?:Backlog|Planned|Shipped|Archived)\b[^\n]*)/i; + const boundaryMatch = rawContent.match(backlogBoundaryRe); + if (boundaryMatch && boundaryMatch.index !== undefined) { + return boundaryMatch.index; + } + + // Fallback: insert before the last `\n---` separator (original behaviour for + // ROADMAPs without a Backlog section). + const lastSeparator = rawContent.lastIndexOf('\n---'); + if (lastSeparator > 0) return lastSeparator; + + return rawContent.length; +} + // ─── phaseAdd handler ─────────────────────────────────────────────────── /** @@ -151,7 +228,8 @@ export const phaseAdd: QueryHandler = async (args, projectDir, workstream) => { // there is no race condition to guard against). const computePhaseFields = async (rawRoadmapContent: string) => { const milestoneContent = await extractCurrentMilestone(rawRoadmapContent, projectDir); - const phasesDir = planningPaths(projectDir, workstream).phases; + // #3816: use milestone-scoped directory if it exists, otherwise flat layout + const phasesDir = await resolveWritePhasesDir(projectDir, workstream); const dirNames = await listDirectories(phasesDir); const nextSequentialPhaseId = computeNextSequentialPhaseId(milestoneContent, dirNames); @@ -201,17 +279,16 @@ export const phaseAdd: QueryHandler = async (args, projectDir, workstream) => { dirName = resolvedDirName; computedPhaseEntry = resolvedEntry; - const dirPath = join(planningPaths(projectDir, workstream).phases, dirName); + // #3816: resolve to milestone-scoped dir if it exists + const phasesWriteDir = await resolveWritePhasesDir(projectDir, workstream); + const dirPath = join(phasesWriteDir, dirName); // Create directory with .gitkeep so git tracks empty folders await ensureDirectoryWithGitkeep(dirPath); - // Find insertion point: before last "---" or at end - const lastSeparator = roadmapRaw.lastIndexOf('\n---'); - if (lastSeparator > 0) { - return roadmapRaw.slice(0, lastSeparator) + computedPhaseEntry + roadmapRaw.slice(lastSeparator); - } - return roadmapRaw + computedPhaseEntry; + // #3816: insert before Backlog/Planned boundary, not after last `---` + const insertIdx = findPhaseInsertionPoint(roadmapRaw); + return roadmapRaw.slice(0, insertIdx) + computedPhaseEntry + roadmapRaw.slice(insertIdx); }, workstream); } @@ -220,7 +297,8 @@ export const phaseAdd: QueryHandler = async (args, projectDir, workstream) => { padded: typeof newPhaseId === 'number' ? String(newPhaseId).padStart(2, '0') : String(newPhaseId), name: description, slug, - directory: toPosixPath(relative(projectDir, join(planningPaths(projectDir, workstream).phases, dirName))), + // #3816: result.directory must reflect the actual write location + directory: toPosixPath(relative(projectDir, join(await resolveWritePhasesDir(projectDir, workstream), dirName))), naming_mode: config.phase_naming || 'sequential', }; @@ -307,9 +385,11 @@ export const phaseAddBatch: QueryHandler = async (args, projectDir, workstream) const content = await extractCurrentMilestone(rawContent, projectDir); let maxPhase = 0; + // #3816: resolve to milestone-scoped dir if it exists for the whole batch + const phasesWriteDir = await resolveWritePhasesDir(projectDir, workstream); + if (config.phase_naming !== 'custom') { - const phasesOnDisk = planningPaths(projectDir, workstream).phases; - const dirNames = await listDirectories(phasesOnDisk); + const dirNames = await listDirectories(phasesWriteDir); maxPhase = computeNextSequentialPhaseId(content, dirNames) - 1; } @@ -329,24 +409,24 @@ export const phaseAddBatch: QueryHandler = async (args, projectDir, workstream) } assertSafePhaseDirName(dirName); - const dirPath = join(planningPaths(projectDir, workstream).phases, dirName); + // #3816: use resolved milestone-scoped dir for directory creation + const dirPath = join(phasesWriteDir, dirName); await ensureDirectoryWithGitkeep(dirPath); const phaseEntry = buildPhaseRoadmapEntry(newPhaseId, description, config.phase_naming); - const lastSeparator = rawContent.lastIndexOf('\n---'); - rawContent = - lastSeparator > 0 - ? rawContent.slice(0, lastSeparator) + phaseEntry + rawContent.slice(lastSeparator) - : rawContent + phaseEntry; + // #3816: insert before Backlog/Planned boundary, not after last `---` + const insertIdx = findPhaseInsertionPoint(rawContent); + rawContent = rawContent.slice(0, insertIdx) + phaseEntry + rawContent.slice(insertIdx); added.push({ phase_number: typeof newPhaseId === 'number' ? newPhaseId : String(newPhaseId), padded: typeof newPhaseId === 'number' ? String(newPhaseId).padStart(2, '0') : String(newPhaseId), name: description, slug, - directory: toPosixPath(relative(projectDir, join(planningPaths(projectDir, workstream).phases, dirName))), + // #3816: result.directory reflects the actual write location + directory: toPosixPath(relative(projectDir, join(phasesWriteDir, dirName))), naming_mode: config.phase_naming || 'sequential', }); } diff --git a/sdk/src/query/phase.test.ts b/sdk/src/query/phase.test.ts index bb982f218..e3a87bc94 100644 --- a/sdk/src/query/phase.test.ts +++ b/sdk/src/query/phase.test.ts @@ -208,6 +208,48 @@ describe('findPhase', () => { expect(data.found).toBe(true); expect(data.archived).toBe('v1.0'); }); + + // ── #3816: prefixed-milestone archive dirs (aimpf-v1.1-phases) ───────── + + it('#3816: findPhase finds phase in prefixed-milestone archive dir (aimpf-v1.1-phases)', async () => { + // Non-standard milestone prefix: aimpf-v1.1-phases (not matching ^v[\d.]+-phases$) + const PREFIXED_MILESTONE_PHASES_DIR = '.planning/milestones/aimpf-v1.1-phases'; + const archiveDir = join(tmpDir, PREFIXED_MILESTONE_PHASES_DIR, '07-branch-rename'); + await mkdir(archiveDir, { recursive: true }); + await writeFile(join(archiveDir, '07-01-PLAN.md'), '---\nphase: 07\nplan: 01\n---\nPlan'); + + const result = await findPhase(['7'], tmpDir); + const data = result.data as Record; + + expect(data.found).toBe(true); + expect(data.directory).toBe(`${PREFIXED_MILESTONE_PHASES_DIR}/07-branch-rename`); + }); + + it('#3816: findPhase prefers current-milestone archive dir over stale archives with same phase number', async () => { + // Stale archive from a different milestone (v1.1-phases) has Phase 7 "dependency-audit" (Complete) + const staleArchiveDir = join(tmpDir, '.planning', 'milestones', 'v1.1-phases', '07-dependency-audit'); + await mkdir(staleArchiveDir, { recursive: true }); + await writeFile(join(staleArchiveDir, '07-01-PLAN.md'), '---\nphase: 07\nplan: 01\n---\nStale plan'); + await writeFile(join(staleArchiveDir, '07-01-SUMMARY.md'), 'Done'); + + // Active milestone (aimpf-v1.1-phases) has Phase 7 "branch-rename" (active) + const activeArchiveDir = join(tmpDir, '.planning', 'milestones', 'aimpf-v1.1-phases', '07-branch-rename'); + await mkdir(activeArchiveDir, { recursive: true }); + await writeFile(join(activeArchiveDir, '07-01-PLAN.md'), '---\nphase: 07\nplan: 01\n---\nActive plan'); + + // STATE.md identifies current milestone as aimpf-v1.1 + await writeFile( + join(tmpDir, '.planning', 'STATE.md'), + '---\nmilestone: aimpf-v1.1\nstatus: executing\n---\n\n# State\n', + ); + + const result = await findPhase(['7'], tmpDir); + const data = result.data as Record; + + expect(data.found).toBe(true); + // Must return the active milestone's Phase 7, not the stale one + expect(data.directory).toBe('.planning/milestones/aimpf-v1.1-phases/07-branch-rename'); + }); }); // ─── phasePlanIndex ──────────────────────────────────────────────────────── diff --git a/sdk/src/query/phase.ts b/sdk/src/query/phase.ts index 38290d3d4..6d5ad0ea8 100644 --- a/sdk/src/query/phase.ts +++ b/sdk/src/query/phase.ts @@ -32,6 +32,71 @@ import { import { relPlanningPath } from '../workstream-utils.js'; import type { QueryHandler } from './utils.js'; +// ─── Milestone-scoped directory helpers (#3816) ─────────────────────────── + +/** + * Pattern matching any milestone archive directory (ends in `-phases`). + * Accepts both the canonical `vX.Y-phases` form AND prefixed variants like + * `aimpf-v1.1-phases` that emerged from projects forking milestone names. + * + * Previous pattern `/^v[\d.]+-phases$/` silently skipped all prefixed dirs, + * causing `findPhase` to miss active phases and return stale archive matches. + */ +const MILESTONE_PHASES_DIR_RE = /-phases$/; + +/** + * Read the current milestone identifier from STATE.md so the archive scan + * can prefer the active-milestone archive over older archived dirs when two + * milestone archives both contain a phase with the same number. + * + * Returns null on any error (STATE.md absent, unreadable, or no `milestone:` + * front-matter field) — callers fall through to the standard sort order. + */ +async function readCurrentMilestone(projectDir: string, workstream?: string): Promise { + try { + const statePath = planningPaths(projectDir, workstream).state; + const stateContent = await readFile(statePath, 'utf-8'); + const m = stateContent.match(/^milestone:\s*(.+)$/m); + if (!m) return null; + return m[1].trim().replace(/^["']|["']$/g, ''); + } catch { + return null; + } +} + +/** + * Return the sorted list of milestone archive directory names from + * `.planning/milestones/`, placing the current-milestone dir first. + * + * Expansion of the previous `/^v[\d.]+-phases$/` filter: now accepts any + * directory ending in `-phases` so prefixed milestone names (e.g. + * `aimpf-v1.1-phases`) are included in the scan (#3816 fix). + */ +async function sortedMilestoneArchiveDirs( + milestonesDir: string, + currentMilestone: string | null, +): Promise { + const entries = await readdir(milestonesDir, { withFileTypes: true }); + const names = entries + .filter(e => e.isDirectory() && MILESTONE_PHASES_DIR_RE.test(e.name)) + .map(e => e.name); + + // Build expected name for the current-milestone archive dir so it sorts first. + const currentArchiveName = currentMilestone ? `${currentMilestone}-phases` : null; + + return names.sort((a, b) => { + // Current-milestone dir always first. + if (currentArchiveName) { + if (a === currentArchiveName) return -1; + if (b === currentArchiveName) return 1; + } + // Remaining dirs in ascending version order (oldest milestone first). + // v1.2-phases sorts before v1.10-phases so the earliest archive is + // searched first — deterministic and predictable for callers (#3816). + return a.localeCompare(b, undefined, { numeric: true }); + }); +} + // ─── Types ───────────────────────────────────────────────────────────────── interface PhaseInfo { @@ -241,16 +306,17 @@ export async function findPhaseByNumber( const milestonesDir = join(projectDir, '.planning', 'milestones'); try { - const milestoneEntries = await readdir(milestonesDir, { withFileTypes: true }); - const archiveDirs = milestoneEntries - .filter(e => e.isDirectory() && /^v[\d.]+-phases$/.test(e.name)) - .map(e => e.name) - .sort() - .reverse(); + // #3816: read current milestone so its archive dir is searched first, + // preventing a stale archive from shadowing the active phase when two + // milestone archives share the same phase number. + const currentMilestone = await readCurrentMilestone(projectDir, workstream); + const archiveDirs = await sortedMilestoneArchiveDirs(milestonesDir, currentMilestone); for (const archiveName of archiveDirs) { - const versionMatch = archiveName.match(/^(v[\d.]+)-phases$/); - const version = versionMatch ? versionMatch[1] : archiveName; + // Extract version: strip the trailing `-phases` suffix. + // Matches both canonical `v1.1-phases` → `v1.1` and prefixed + // `aimpf-v1.1-phases` → `aimpf-v1.1` (#3816). + const version = archiveName.replace(/-phases$/, ''); const archivePath = join(milestonesDir, archiveName); const relBase = '.planning/milestones/' + archiveName; const result = await searchPhaseInDir(archivePath, relBase, normalized); @@ -301,19 +367,18 @@ export const findPhase: QueryHandler = async (args, projectDir, workstream) => { const current = await searchPhaseInDir(phasesDir, relPhasesDir, normalized); if (current) return { data: current }; - // Search archived milestone phases (newest first) + // Search archived milestone phases — current-milestone dir first (#3816) const milestonesDir = join(projectDir, '.planning', 'milestones'); try { - const milestoneEntries = await readdir(milestonesDir, { withFileTypes: true }); - const archiveDirs = milestoneEntries - .filter(e => e.isDirectory() && /^v[\d.]+-phases$/.test(e.name)) - .map(e => e.name) - .sort() - .reverse(); + // #3816: include prefixed milestone dirs (e.g. aimpf-v1.1-phases) and + // sort the current-milestone archive to the front of the search order. + const currentMilestone = await readCurrentMilestone(projectDir, workstream); + const archiveDirs = await sortedMilestoneArchiveDirs(milestonesDir, currentMilestone); for (const archiveName of archiveDirs) { - const versionMatch = archiveName.match(/^(v[\d.]+)-phases$/); - const version = versionMatch ? versionMatch[1] : archiveName; + // Strip trailing `-phases` to derive the version label (#3816: works for + // both `v1.1-phases` and prefixed `aimpf-v1.1-phases`). + const version = archiveName.replace(/-phases$/, ''); const archivePath = join(milestonesDir, archiveName); const relBase = '.planning/milestones/' + archiveName; searchedDirectories.push(relBase); diff --git a/sdk/src/query/roadmap.test.ts b/sdk/src/query/roadmap.test.ts index a6f9f14b3..6326e7611 100644 --- a/sdk/src/query/roadmap.test.ts +++ b/sdk/src/query/roadmap.test.ts @@ -1082,6 +1082,75 @@ describe('roadmapAnalyze', () => { expect(phases.some(p => p.number === '1')).toBe(true); expect(phases.some(p => p.number === '3')).toBe(true); }); + + // ─── Bug #3816: bullet-list phases under prefixed-milestone heading ───── + + it('#3816: roadmapAnalyze finds phases listed as bullets under prefixed-milestone heading', async () => { + // Project uses "aimpf-v1.1" milestone (non-standard prefix). + // ROADMAP uses bullet-format phases (- [ ] Phase N:) under the active + // milestone heading, not heading-format (### Phase N:). + const roadmap = [ + '# Roadmap', + '', + '### 🚧 aimpf-v1.1 Branch Rename + V1.0 Debt Closure (active)', + '', + '- [x] Phase 7: Branch Rename — pending /gsd:spec-phase 7', + '', + '## Backlog', + '', + '### Phase 999.1: PycartaContext — backlog item', + '**Goal:** Parked idea.', + '', + '---', + '*Last updated: 2026-05-21*', + ].join('\n'); + const state = '---\nmilestone: aimpf-v1.1\nstatus: executing\n---\n\n# State\n'; + + await writeFile(join(tmpDir, '.planning', 'STATE.md'), state); + await writeFile(join(tmpDir, '.planning', 'ROADMAP.md'), roadmap); + + const result = await roadmapAnalyze([], tmpDir); + const data = result.data as Record; + const phases = data.phases as Array>; + + // Phase 7 must be discovered — it is in the active milestone section + expect(phases.some(p => p.number === '7')).toBe(true); + // Backlog phase 999.1 must NOT appear in the phases list + expect(phases.every(p => p.number !== '999.1')).toBe(true); + // phase_count must reflect active phases only + const activePhases = phases.filter(p => { + const num = parseFloat(String(p.number)); + return num < 999; + }); + expect(activePhases.length).toBeGreaterThan(0); + }); + + it('#3816: roadmapGetPhase finds phase listed as bullet under prefixed-milestone heading', async () => { + // Same ROADMAP structure as above, but using roadmapGetPhase + const roadmap = [ + '# Roadmap', + '', + '### 🚧 aimpf-v1.1 Branch Rename + V1.0 Debt Closure (active)', + '', + '- [ ] Phase 7: Branch Rename', + '', + '## Backlog', + '', + '---', + '*Last updated: 2026-05-21*', + ].join('\n'); + const state = '---\nmilestone: aimpf-v1.1\nstatus: executing\n---\n\n# State\n'; + + await writeFile(join(tmpDir, '.planning', 'STATE.md'), state); + await writeFile(join(tmpDir, '.planning', 'ROADMAP.md'), roadmap); + + const result = await roadmapGetPhase(['7'], tmpDir); + const data = result.data as Record; + + expect(data.found).toBe(true); + expect(data.phase_number).toBe('7'); + expect(data.phase_name).toBe('Branch Rename'); + }); }); // ─── extractPhasesFromSection + extractNextMilestoneSection (#2497) ────── diff --git a/sdk/src/query/roadmap.ts b/sdk/src/query/roadmap.ts index 04a77f0fd..1db0eed6d 100644 --- a/sdk/src/query/roadmap.ts +++ b/sdk/src/query/roadmap.ts @@ -592,8 +592,9 @@ function searchPhaseInContent(content: string, escapedPhase: string, phaseNum: s const headerMatch = content.match(phasePattern); if (!headerMatch) { - // Fallback: check if phase exists in summary list but missing detail section. - // Same canonical-token capture: surface the as-written checklist form. + // Fallback 1: bold-wrapped checklist entry `- [ ] **Phase N: name**`. + // Reports malformed_roadmap when a bold checklist entry exists without a + // companion `### Phase N:` detail heading (original fallback contract). const checklistPattern = new RegExp( `-\\s*\\[[ x]\\]\\s*\\*\\*Phase\\s+(${escapedPhase}):\\s*([^*]+)\\*\\*`, 'i' @@ -611,6 +612,32 @@ function searchPhaseInContent(content: string, escapedPhase: string, phaseNum: s }; } + // Fallback 2 (#3816): plain (non-bold) bullet entry `- [ ] Phase N: name` + // used by milestone-scoped ROADMAPs where phases are listed as bullets + // under a `### 🚧 ` heading, not as separate `### Phase N:` headings. + // Returns `found: true` — the bullet IS the authoritative phase record for + // these layouts, not just a malformed summary list entry. + const plainBulletPattern = new RegExp( + `-\\s*\\[[ x]\\]\\s*Phase\\s+(${escapedPhase}):\\s*([^\\n]+)`, + 'i' + ); + const plainBulletMatch = content.match(plainBulletPattern); + + if (plainBulletMatch) { + // Strip trailing annotations like `— pending /gsd:spec-phase 7` + const rawName = plainBulletMatch[2].trim(); + const phaseName = rawName.replace(/\s*[-—].*$/, '').trim() || rawName; + return { + found: true, + phase_number: plainBulletMatch[1], + phase_name: phaseName, + section: plainBulletMatch[0], + goal: null, + mode: null, + success_criteria: [], + }; + } + return null; } @@ -770,11 +797,19 @@ export const roadmapAnalyze: QueryHandler = async (_args, projectDir, workstream return { data: { error: 'ROADMAP.md not found', milestones: [], phases: [], current_phase: null } }; } - const content = await extractCurrentMilestone(rawContent, projectDir, workstream); + const rawMilestoneContent = await extractCurrentMilestone(rawContent, projectDir, workstream); + // #3816: exclude phases in the Backlog / Planned / Archived sections that + // may bleed into the active-milestone slice when those sections use plain + // headings (no version/emoji) that the boundary scanner does not stop at. + const backlogBoundaryIdx = rawMilestoneContent.search(/\n#{2,3}\s+(?:Backlog|Planned|Shipped|Archived)\b/i); + const content = backlogBoundaryIdx >= 0 ? rawMilestoneContent.slice(0, backlogBoundaryIdx) : rawMilestoneContent; const phasesDir = planningPaths(projectDir, workstream).phases; // IMPORTANT: Create regex INSIDE the function to avoid /g lastIndex persistence + // Heading-format phases: "### Phase N: Name" const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi; + // #3816: plain-bullet phases "- [ ] Phase N: Name" used by milestone-scoped layouts + const bulletPhasePattern = /^[-*]\s*\[[ x]\]\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gim; const phases: Array> = []; let match: RegExpExecArray | null; @@ -855,6 +890,67 @@ export const roadmapAnalyze: QueryHandler = async (_args, projectDir, workstream }); } + // #3816: second pass — plain-bullet phases not captured by the heading scan. + // Milestone-scoped ROADMAPs list phases as `- [ ] Phase N: Name` bullets under + // a milestone heading without companion `### Phase N:` detail headings. + // Only add phases not already discovered by the heading scan. + const headingPhaseNums = new Set(phases.map(p => p.number as string)); + while ((match = bulletPhasePattern.exec(content)) !== null) { + const phaseNum = match[1]; + if (headingPhaseNums.has(phaseNum)) continue; // already captured above + + const rawName = match[2].replace(/\(INSERTED\)/i, '').trim(); + const phaseName = rawName.replace(/\s*[-—].*$/, '').trim() || rawName; + + const normalized = normalizePhaseName(phaseNum); + let diskStatus = 'no_directory'; + let planCount = 0; + let summaryCount = 0; + let hasContext = false; + let hasResearch = false; + + try { + const entries = await readdir(phasesDir, { withFileTypes: true }); + const dirs = entries.filter(e => e.isDirectory()).map(e => e.name); + const dirMatch = dirs.find(d => phaseTokenMatches(d, normalized)); + + if (dirMatch) { + const counts = await countPhasePlansAndSummaries(join(phasesDir, dirMatch)); + planCount = counts.planCount; + summaryCount = counts.summaryCount; + hasContext = counts.hasContext; + hasResearch = counts.hasResearch; + + if (summaryCount >= planCount && planCount > 0) diskStatus = 'complete'; + else if (summaryCount > 0) diskStatus = 'partial'; + else if (planCount > 0) diskStatus = 'planned'; + else if (hasResearch) diskStatus = 'researched'; + else if (hasContext) diskStatus = 'discussed'; + else diskStatus = 'empty'; + } + } catch { /* intentionally empty */ } + + // Check ROADMAP checkbox status from the bullet itself + const checkboxMatch = match[0].match(/\[(x| )\]/i); + const roadmapComplete = checkboxMatch ? checkboxMatch[1].toLowerCase() === 'x' : false; + if (roadmapComplete && diskStatus !== 'complete') diskStatus = 'complete'; + + headingPhaseNums.add(phaseNum); + phases.push({ + number: phaseNum, + name: phaseName, + goal: null, + depends_on: null, + mode: null, + plan_count: planCount, + summary_count: summaryCount, + has_context: hasContext, + has_research: hasResearch, + disk_status: diskStatus, + roadmap_complete: roadmapComplete, + }); + } + // Extract milestone info const milestones: Array<{ heading: string; version: string }> = []; const milestonePattern = /##\s*(.*v(\d+(?:\.\d+)+)[^(\n]*)/gi;