fix: scope ROADMAP.md searches to current milestone (#1059)

Phase numbers reset per milestone (v1.0 Phase 1-26, v1.1 Phase 1-6,
v1.2 Phase 1-6). Functions that searched ROADMAP.md for phase headings
or checkboxes would match archived milestone entries inside <details>
blocks before reaching the current milestone's entries.

This caused:
- roadmap_complete: true for phases that aren't complete (checkbox
  regex matched the archived [x] Phase N from a previous milestone)
- phase-complete marking the wrong checkbox (archived instead of current)
- phase-add computing wrong maxPhase from archived headings
- progress not showing phases defined in ROADMAP but not yet on disk
This commit is contained in:
Shard
2026-03-15 19:39:59 +02:00
committed by GitHub
parent 637a3e720c
commit ca4ae7b3b8
6 changed files with 125 additions and 40 deletions

View File

@@ -331,6 +331,34 @@ function getArchivedPhaseDirs(cwd) {
return results;
}
// ─── Roadmap milestone scoping ───────────────────────────────────────────────
/**
* Strip shipped milestone content wrapped in <details> blocks.
* Used to isolate current milestone phases when searching ROADMAP.md
* for phase headings or checkboxes — prevents matching archived milestone
* phases that share the same numbers as current milestone phases.
*/
function stripShippedMilestones(content) {
return content.replace(/<details>[\s\S]*?<\/details>/gi, '');
}
/**
* Replace a pattern only in the current milestone section of ROADMAP.md
* (everything after the last </details> close tag). Used for write operations
* that must not accidentally modify archived milestone checkboxes/tables.
*/
function replaceInCurrentMilestone(content, pattern, replacement) {
const lastDetailsClose = content.lastIndexOf('</details>');
if (lastDetailsClose === -1) {
return content.replace(pattern, replacement);
}
const offset = lastDetailsClose + '</details>'.length;
const before = content.slice(0, offset);
const after = content.slice(offset);
return before + after.replace(pattern, replacement);
}
// ─── Roadmap & model utilities ────────────────────────────────────────────────
function getRoadmapPhaseInternal(cwd, phaseNum) {
@@ -339,7 +367,7 @@ function getRoadmapPhaseInternal(cwd, phaseNum) {
if (!fs.existsSync(roadmapPath)) return null;
try {
const content = fs.readFileSync(roadmapPath, 'utf-8');
const content = stripShippedMilestones(fs.readFileSync(roadmapPath, 'utf-8'));
const escapedPhase = escapeRegex(phaseNum.toString());
const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+${escapedPhase}:\\s*([^\\n]+)`, 'i');
const headerMatch = content.match(phasePattern);
@@ -352,7 +380,7 @@ function getRoadmapPhaseInternal(cwd, phaseNum) {
const sectionEnd = nextHeaderMatch ? headerIndex + nextHeaderMatch.index : content.length;
const section = content.slice(headerIndex, sectionEnd).trim();
const goalMatch = section.match(/\*\*Goal:\*\*\s*([^\n]+)/i);
const goalMatch = section.match(/\*\*Goal(?:\*\*:|\*?\*?:\*\*)\s*([^\n]+)/i);
const goal = goalMatch ? goalMatch[1].trim() : null;
return {
@@ -416,7 +444,7 @@ function getMilestoneInfo(cwd) {
}
// Second: heading-format roadmaps — strip shipped milestones in <details> blocks
const cleaned = roadmap.replace(/<details>[\s\S]*?<\/details>/gi, '');
const cleaned = stripShippedMilestones(roadmap);
// Extract version and name from the same ## heading for consistency
const headingMatch = cleaned.match(/## .*v(\d+\.\d+)[:\s]+([^\n(]+)/);
if (headingMatch) {
@@ -444,7 +472,7 @@ function getMilestoneInfo(cwd) {
function getMilestonePhaseFilter(cwd) {
const milestonePhaseNums = new Set();
try {
const roadmap = fs.readFileSync(path.join(cwd, '.planning', 'ROADMAP.md'), 'utf-8');
const roadmap = stripShippedMilestones(fs.readFileSync(path.join(cwd, '.planning', 'ROADMAP.md'), 'utf-8'));
const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:/gi;
let m;
while ((m = phasePattern.exec(roadmap)) !== null) {
@@ -491,5 +519,7 @@ module.exports = {
generateSlugInternal,
getMilestoneInfo,
getMilestonePhaseFilter,
stripShippedMilestones,
replaceInCurrentMilestone,
toPosixPath,
};

View File

@@ -5,7 +5,7 @@
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const { loadConfig, resolveModelInternal, findPhaseInternal, getRoadmapPhaseInternal, pathExistsInternal, generateSlugInternal, getMilestoneInfo, normalizePhaseName, toPosixPath, output, error } = require('./core.cjs');
const { loadConfig, resolveModelInternal, findPhaseInternal, getRoadmapPhaseInternal, pathExistsInternal, generateSlugInternal, getMilestoneInfo, getMilestonePhaseFilter, stripShippedMilestones, normalizePhaseName, toPosixPath, output, error } = require('./core.cjs');
function cmdInitExecutePhase(cwd, phase, raw) {
if (!phase) {
@@ -600,20 +600,46 @@ function cmdInitProgress(cwd, raw) {
const config = loadConfig(cwd);
const milestone = getMilestoneInfo(cwd);
// Analyze phases
// Analyze phases — filter to current milestone and include ROADMAP-only phases
const phasesDir = path.join(cwd, '.planning', 'phases');
const phases = [];
let currentPhase = null;
let nextPhase = null;
// Build set of phases defined in ROADMAP for the current milestone
const roadmapPhaseNums = new Set();
const roadmapPhaseNames = new Map();
try {
const roadmapContent = stripShippedMilestones(
fs.readFileSync(path.join(cwd, '.planning', 'ROADMAP.md'), 'utf-8')
);
const headingPattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
let hm;
while ((hm = headingPattern.exec(roadmapContent)) !== null) {
roadmapPhaseNums.add(hm[1]);
roadmapPhaseNames.set(hm[1], hm[2].replace(/\(INSERTED\)/i, '').trim());
}
} catch {}
const isDirInMilestone = getMilestonePhaseFilter(cwd);
const seenPhaseNums = new Set();
try {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort();
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name)
.filter(isDirInMilestone)
.sort((a, b) => {
const pa = a.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
const pb = b.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
if (!pa || !pb) return a.localeCompare(b);
return parseInt(pa[1], 10) - parseInt(pb[1], 10);
});
for (const dir of dirs) {
const match = dir.match(/^(\d+(?:\.\d+)*)-?(.*)/);
const match = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i);
const phaseNumber = match ? match[1] : dir;
const phaseName = match && match[2] ? match[2] : null;
seenPhaseNums.add(phaseNumber.replace(/^0+/, '') || '0');
const phasePath = path.join(phasesDir, dir);
const phaseFiles = fs.readdirSync(phasePath);
@@ -648,6 +674,29 @@ function cmdInitProgress(cwd, raw) {
}
} catch {}
// Add phases defined in ROADMAP but not yet scaffolded to disk
for (const [num, name] of roadmapPhaseNames) {
const stripped = num.replace(/^0+/, '') || '0';
if (!seenPhaseNums.has(stripped)) {
const phaseInfo = {
number: num,
name: name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''),
directory: null,
status: 'not_started',
plan_count: 0,
summary_count: 0,
has_research: false,
};
phases.push(phaseInfo);
if (!nextPhase && !currentPhase) {
nextPhase = phaseInfo;
}
}
}
// Re-sort phases by number after adding ROADMAP-only phases
phases.sort((a, b) => parseInt(a.number, 10) - parseInt(b.number, 10));
// Check for paused work
let pausedAt = null;
try {

View File

@@ -4,7 +4,7 @@
const fs = require('fs');
const path = require('path');
const { escapeRegex, normalizePhaseName, comparePhaseNum, findPhaseInternal, getArchivedPhaseDirs, generateSlugInternal, getMilestonePhaseFilter, toPosixPath, output, error } = require('./core.cjs');
const { escapeRegex, normalizePhaseName, comparePhaseNum, findPhaseInternal, getArchivedPhaseDirs, generateSlugInternal, getMilestonePhaseFilter, stripShippedMilestones, replaceInCurrentMilestone, toPosixPath, output, error } = require('./core.cjs');
const { extractFrontmatter } = require('./frontmatter.cjs');
const { writeStateMd } = require('./state.cjs');
@@ -318,10 +318,11 @@ function cmdPhaseAdd(cwd, description, raw) {
error('ROADMAP.md not found');
}
const content = fs.readFileSync(roadmapPath, 'utf-8');
const rawContent = fs.readFileSync(roadmapPath, 'utf-8');
const content = stripShippedMilestones(rawContent);
const slug = generateSlugInternal(description);
// Find highest integer phase number
// Find highest integer phase number (in current milestone only)
const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*:/gi;
let maxPhase = 0;
let m;
@@ -344,11 +345,11 @@ function cmdPhaseAdd(cwd, description, raw) {
// Find insertion point: before last "---" or at end
let updatedContent;
const lastSeparator = content.lastIndexOf('\n---');
const lastSeparator = rawContent.lastIndexOf('\n---');
if (lastSeparator > 0) {
updatedContent = content.slice(0, lastSeparator) + phaseEntry + content.slice(lastSeparator);
updatedContent = rawContent.slice(0, lastSeparator) + phaseEntry + rawContent.slice(lastSeparator);
} else {
updatedContent = content + phaseEntry;
updatedContent = rawContent + phaseEntry;
}
fs.writeFileSync(roadmapPath, updatedContent, 'utf-8');
@@ -374,7 +375,8 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
error('ROADMAP.md not found');
}
const content = fs.readFileSync(roadmapPath, 'utf-8');
const rawContent = fs.readFileSync(roadmapPath, 'utf-8');
const content = stripShippedMilestones(rawContent);
const slug = generateSlugInternal(description);
// Normalize input then strip leading zeros for flexible matching
@@ -415,23 +417,23 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
// Insert after the target phase section
const headerPattern = new RegExp(`(#{2,4}\\s*Phase\\s+0*${afterPhaseEscaped}:[^\\n]*\\n)`, 'i');
const headerMatch = content.match(headerPattern);
const headerMatch = rawContent.match(headerPattern);
if (!headerMatch) {
error(`Could not find Phase ${afterPhase} header`);
}
const headerIdx = content.indexOf(headerMatch[0]);
const afterHeader = content.slice(headerIdx + headerMatch[0].length);
const headerIdx = rawContent.indexOf(headerMatch[0]);
const afterHeader = rawContent.slice(headerIdx + headerMatch[0].length);
const nextPhaseMatch = afterHeader.match(/\n#{2,4}\s+Phase\s+\d/i);
let insertIdx;
if (nextPhaseMatch) {
insertIdx = headerIdx + headerMatch[0].length + nextPhaseMatch.index;
} else {
insertIdx = content.length;
insertIdx = rawContent.length;
}
const updatedContent = content.slice(0, insertIdx) + phaseEntry + content.slice(insertIdx);
const updatedContent = rawContent.slice(0, insertIdx) + phaseEntry + rawContent.slice(insertIdx);
fs.writeFileSync(roadmapPath, updatedContent, 'utf-8');
const result = {
@@ -728,7 +730,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
`(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${escapeRegex(phaseNum)}[:\\s][^\\n]*)`,
'i'
);
roadmapContent = roadmapContent.replace(checkboxPattern, `$1x$2 (completed ${today})`);
roadmapContent = replaceInCurrentMilestone(roadmapContent, checkboxPattern, `$1x$2 (completed ${today})`);
// Progress table: update Status to Complete, add date
const phaseEscaped = escapeRegex(phaseNum);
@@ -736,8 +738,8 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
`(\\|\\s*${phaseEscaped}\\.?\\s[^|]*\\|[^|]*\\|)\\s*[^|]*(\\|)\\s*[^|]*(\\|)`,
'i'
);
roadmapContent = roadmapContent.replace(
tablePattern,
roadmapContent = replaceInCurrentMilestone(
roadmapContent, tablePattern,
`$1 Complete $2 ${today} $3`
);
@@ -746,8 +748,8 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
`(#{2,4}\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`,
'i'
);
roadmapContent = roadmapContent.replace(
planCountPattern,
roadmapContent = replaceInCurrentMilestone(
roadmapContent, planCountPattern,
`$1${summaryCount}/${planCount} plans complete`
);
@@ -758,7 +760,8 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
if (fs.existsSync(reqPath)) {
// Extract the current phase section from roadmap (scoped to avoid cross-phase matching)
const phaseEsc = escapeRegex(phaseNum);
const phaseSectionMatch = roadmapContent.match(
const currentMilestoneRoadmap = stripShippedMilestones(roadmapContent);
const phaseSectionMatch = currentMilestoneRoadmap.match(
new RegExp(`(#{2,4}\\s*Phase\\s+${phaseEsc}[:\\s][\\s\\S]*?)(?=#{2,4}\\s*Phase\\s+|$)`, 'i')
);
@@ -821,7 +824,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
// for phases that are defined but not yet planned (no directory on disk)
if (isLastPhase && fs.existsSync(roadmapPath)) {
try {
const roadmapForPhases = fs.readFileSync(roadmapPath, 'utf-8');
const roadmapForPhases = stripShippedMilestones(fs.readFileSync(roadmapPath, 'utf-8'));
const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
let pm;
while ((pm = phasePattern.exec(roadmapForPhases)) !== null) {

View File

@@ -4,7 +4,7 @@
const fs = require('fs');
const path = require('path');
const { escapeRegex, normalizePhaseName, output, error, findPhaseInternal } = require('./core.cjs');
const { escapeRegex, normalizePhaseName, output, error, findPhaseInternal, stripShippedMilestones, replaceInCurrentMilestone } = require('./core.cjs');
function cmdRoadmapGetPhase(cwd, phaseNum, raw) {
const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md');
@@ -15,7 +15,7 @@ function cmdRoadmapGetPhase(cwd, phaseNum, raw) {
}
try {
const content = fs.readFileSync(roadmapPath, 'utf-8');
const content = stripShippedMilestones(fs.readFileSync(roadmapPath, 'utf-8'));
// Escape special regex chars in phase number, handle decimal
const escapedPhase = escapeRegex(phaseNum);
@@ -98,7 +98,8 @@ function cmdRoadmapAnalyze(cwd, raw) {
return;
}
const content = fs.readFileSync(roadmapPath, 'utf-8');
const rawContent = fs.readFileSync(roadmapPath, 'utf-8');
const content = stripShippedMilestones(rawContent);
const phasesDir = path.join(cwd, '.planning', 'phases');
// Extract all phase headings: ## Phase N: Name or ### Phase N: Name
@@ -153,7 +154,7 @@ function cmdRoadmapAnalyze(cwd, raw) {
} catch {}
// Check ROADMAP checkbox status
const checkboxPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*.*Phase\\s+${escapeRegex(phaseNum)}`, 'i');
const checkboxPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*.*Phase\\s+${escapeRegex(phaseNum)}[:\\s]`, 'i');
const checkboxMatch = content.match(checkboxPattern);
const roadmapComplete = checkboxMatch ? checkboxMatch[1] === 'x' : false;
@@ -262,8 +263,8 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
'i'
);
const dateField = isComplete ? ` ${today} ` : ' ';
roadmapContent = roadmapContent.replace(
tablePattern,
roadmapContent = replaceInCurrentMilestone(
roadmapContent, tablePattern,
`$1 ${summaryCount}/${planCount} $2 ${status.padEnd(11)}$3${dateField}$4`
);
@@ -275,7 +276,7 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
const planCountText = isComplete
? `${summaryCount}/${planCount} plans complete`
: `${summaryCount}/${planCount} plans executed`;
roadmapContent = roadmapContent.replace(planCountPattern, `$1${planCountText}`);
roadmapContent = replaceInCurrentMilestone(roadmapContent, planCountPattern, `$1${planCountText}`);
// If complete: check checkbox
if (isComplete) {
@@ -283,7 +284,7 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
`(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${phaseEscaped}[:\\s][^\\n]*)`,
'i'
);
roadmapContent = roadmapContent.replace(checkboxPattern, `$1x$2 (completed ${today})`);
roadmapContent = replaceInCurrentMilestone(roadmapContent, checkboxPattern, `$1x$2 (completed ${today})`);
}
fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8');

View File

@@ -276,14 +276,16 @@ function cmdStateUpdateProgress(cwd, raw) {
let content = fs.readFileSync(statePath, 'utf-8');
// Count summaries across all phases
// Count summaries across current milestone phases only
const phasesDir = path.join(cwd, '.planning', 'phases');
let totalPlans = 0;
let totalSummaries = 0;
if (fs.existsSync(phasesDir)) {
const isDirInMilestone = getMilestonePhaseFilter(cwd);
const phaseDirs = fs.readdirSync(phasesDir, { withFileTypes: true })
.filter(e => e.isDirectory()).map(e => e.name);
.filter(e => e.isDirectory()).map(e => e.name)
.filter(isDirInMilestone);
for (const dir of phaseDirs) {
const files = fs.readdirSync(path.join(phasesDir, dir));
totalPlans += files.filter(f => f.match(/-PLAN\.md$/i)).length;

View File

@@ -624,8 +624,8 @@ describe('getRoadmapPhaseInternal', () => {
assert.strictEqual(result.goal, 'Create REST endpoints');
});
test('returns null goal when Goal uses colon-outside-bold format', () => {
// Actual ROADMAP.md uses **Goal**: (colon outside bold) which the regex does not match
test('returns goal when Goal uses colon-outside-bold format', () => {
// **Goal**: (colon outside bold) is now supported alongside **Goal:**
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'### Phase 1: Foundation\n**Goal**: Build the base\n'
@@ -633,7 +633,7 @@ describe('getRoadmapPhaseInternal', () => {
const result = getRoadmapPhaseInternal(tmpDir, '1');
assert.strictEqual(result.found, true);
assert.strictEqual(result.phase_name, 'Foundation');
assert.strictEqual(result.goal, null);
assert.strictEqual(result.goal, 'Build the base');
});
test('returns null when roadmap missing', () => {