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:
5
.changeset/3816-milestone-archive-version-sort.md
Normal file
5
.changeset/3816-milestone-archive-version-sort.md
Normal 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)
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) ──────
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user