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', () => {