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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-05-22 14:54:15 -04:00
committed by GitHub
parent 1f96da2488
commit 3eec334533
6 changed files with 395 additions and 38 deletions

View File

@@ -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)

View File

@@ -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<string | null> {
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/
* <milestone>-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<string> {
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',
});
}

View File

@@ -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<string, unknown>;
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<string, unknown>;
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 ────────────────────────────────────────────────────────

View File

@@ -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<string | null> {
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<string[]> {
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);

View File

@@ -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<string, unknown>;
const phases = data.phases as Array<Record<string, unknown>>;
// 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<string, unknown>;
expect(data.found).toBe(true);
expect(data.phase_number).toBe('7');
expect(data.phase_name).toBe('Branch Rename');
});
});
// ─── extractPhasesFromSection + extractNextMilestoneSection (#2497) ──────

View File

@@ -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 `### 🚧 <milestone>` 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<Record<string, unknown>> = [];
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;