From ca4ae7b3b8469feef0f616a3ac89e1f8e325e23a Mon Sep 17 00:00:00 2001
From: Shard <32143208+0Shard@users.noreply.github.com>
Date: Sun, 15 Mar 2026 19:39:59 +0200
Subject: [PATCH] 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
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
---
get-shit-done/bin/lib/core.cjs | 38 ++++++++++++++++++---
get-shit-done/bin/lib/init.cjs | 57 ++++++++++++++++++++++++++++---
get-shit-done/bin/lib/phase.cjs | 41 +++++++++++-----------
get-shit-done/bin/lib/roadmap.cjs | 17 ++++-----
get-shit-done/bin/lib/state.cjs | 6 ++--
tests/core.test.cjs | 6 ++--
6 files changed, 125 insertions(+), 40 deletions(-)
diff --git a/get-shit-done/bin/lib/core.cjs b/get-shit-done/bin/lib/core.cjs
index 0c01b4ad9..2da9c4cdb 100644
--- a/get-shit-done/bin/lib/core.cjs
+++ b/get-shit-done/bin/lib/core.cjs
@@ -331,6 +331,34 @@ function getArchivedPhaseDirs(cwd) {
return results;
}
+// ─── Roadmap milestone scoping ───────────────────────────────────────────────
+
+/**
+ * Strip shipped milestone content wrapped in 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(/[\s\S]*?<\/details>/gi, '');
+}
+
+/**
+ * Replace a pattern only in the current milestone section of ROADMAP.md
+ * (everything after the last close tag). Used for write operations
+ * that must not accidentally modify archived milestone checkboxes/tables.
+ */
+function replaceInCurrentMilestone(content, pattern, replacement) {
+ const lastDetailsClose = content.lastIndexOf(' ');
+ if (lastDetailsClose === -1) {
+ return content.replace(pattern, replacement);
+ }
+ const offset = lastDetailsClose + ' '.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 blocks
- const cleaned = roadmap.replace(/[\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,
};
diff --git a/get-shit-done/bin/lib/init.cjs b/get-shit-done/bin/lib/init.cjs
index d63f489f2..5b40ba7cf 100644
--- a/get-shit-done/bin/lib/init.cjs
+++ b/get-shit-done/bin/lib/init.cjs
@@ -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 {
diff --git a/get-shit-done/bin/lib/phase.cjs b/get-shit-done/bin/lib/phase.cjs
index 4f3e1142f..d88be9459 100644
--- a/get-shit-done/bin/lib/phase.cjs
+++ b/get-shit-done/bin/lib/phase.cjs
@@ -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) {
diff --git a/get-shit-done/bin/lib/roadmap.cjs b/get-shit-done/bin/lib/roadmap.cjs
index b22d9c27b..3164b702c 100644
--- a/get-shit-done/bin/lib/roadmap.cjs
+++ b/get-shit-done/bin/lib/roadmap.cjs
@@ -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');
diff --git a/get-shit-done/bin/lib/state.cjs b/get-shit-done/bin/lib/state.cjs
index 10e999b2d..a12662004 100644
--- a/get-shit-done/bin/lib/state.cjs
+++ b/get-shit-done/bin/lib/state.cjs
@@ -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;
diff --git a/tests/core.test.cjs b/tests/core.test.cjs
index f5a5ff616..ae9ce8641 100644
--- a/tests/core.test.cjs
+++ b/tests/core.test.cjs
@@ -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', () => {