fix(#2646): honor ROADMAP [x] checkboxes when no phases/ directory exists (#2669)

initProgress (and its CJS twin) hardcoded `not_started` for ROADMAP-only
phases, so `completed_count` stayed at 0 even when the ROADMAP showed
`- [x] Phase N`. Extract ROADMAP checkbox states into a shared helper
and use `- [x]` as the completion signal when no phase directory is
present. Disk status continues to win when both exist.

Adds a regression test that reproduces the bug with no phases/ dir and
one `[x]` / one `[ ]` phase, asserting completed_count===1.

Fixes #2646
This commit is contained in:
Tom Boucher
2026-04-24 18:05:41 -04:00
committed by GitHub
parent b67ab38098
commit a6e692f789
3 changed files with 111 additions and 13 deletions

View File

@@ -1230,6 +1230,7 @@ function cmdInitProgress(cwd, raw) {
// Build set of phases defined in ROADMAP for the current milestone
const roadmapPhaseNums = new Set();
const roadmapPhaseNames = new Map();
const roadmapCheckboxStates = new Map();
try {
const roadmapContent = extractCurrentMilestone(
fs.readFileSync(path.join(planningDir(cwd), 'ROADMAP.md'), 'utf-8'), cwd
@@ -1240,6 +1241,13 @@ function cmdInitProgress(cwd, raw) {
roadmapPhaseNums.add(hm[1]);
roadmapPhaseNames.set(hm[1], hm[2].replace(/\(INSERTED\)/i, '').trim());
}
// #2646: parse `- [x] Phase N` checkbox states so ROADMAP-only phases
// inherit completion from the ROADMAP when no phase directory exists.
const cbPattern = /-\s*\[(x| )\]\s*.*Phase\s+(\d+[A-Z]?(?:\.\d+)*)[:\s]/gi;
let cbm;
while ((cbm = cbPattern.exec(roadmapContent)) !== null) {
roadmapCheckboxStates.set(cbm[2], cbm[1].toLowerCase() === 'x');
}
} catch { /* intentionally empty */ }
const isDirInMilestone = getMilestonePhaseFilter(cwd);
@@ -1295,21 +1303,27 @@ function cmdInitProgress(cwd, raw) {
}
} catch { /* intentionally empty */ }
// Add phases defined in ROADMAP but not yet scaffolded to disk
// Add phases defined in ROADMAP but not yet scaffolded to disk. When the
// ROADMAP has a `- [x] Phase N` checkbox, honor it as 'complete' so
// completed_count and status reflect the ROADMAP source of truth (#2646).
for (const [num, name] of roadmapPhaseNames) {
const stripped = num.replace(/^0+/, '') || '0';
if (!seenPhaseNums.has(stripped)) {
const checkboxComplete =
roadmapCheckboxStates.get(num) === true ||
roadmapCheckboxStates.get(stripped) === true;
const status = checkboxComplete ? 'complete' : 'not_started';
const phaseInfo = {
number: num,
name: name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''),
directory: null,
status: 'not_started',
status,
plan_count: 0,
summary_count: 0,
has_research: false,
};
phases.push(phaseInfo);
if (!nextPhase && !currentPhase) {
if (!nextPhase && !currentPhase && status !== 'complete') {
nextPhase = phaseInfo;
}
}

View File

@@ -172,6 +172,61 @@ describe('initProgress', () => {
expect(typeof data.roadmap_path).toBe('string');
expect(typeof data.config_path).toBe('string');
});
// ── #2646: ROADMAP checkbox fallback when no phases/ directory ─────────
it('derives completed_count from ROADMAP [x] checkboxes when phases/ is absent', async () => {
// Fresh fixture: NO phases/ directory at all, checkbox-driven ROADMAP.
const tmp = await mkdtemp(join(tmpdir(), 'gsd-init-complex-2646-'));
try {
await mkdir(join(tmp, '.planning'), { recursive: true });
await writeFile(join(tmp, '.planning', 'config.json'), JSON.stringify({
model_profile: 'balanced',
commit_docs: false,
git: {
branching_strategy: 'none',
phase_branch_template: 'gsd/phase-{phase}-{slug}',
milestone_branch_template: 'gsd/{milestone}-{slug}',
quick_branch_template: null,
},
workflow: { research: true, plan_check: true, verifier: true, nyquist_validation: true },
}));
await writeFile(join(tmp, '.planning', 'STATE.md'), [
'---',
'milestone: v1.0',
'---',
].join('\n'));
await writeFile(join(tmp, '.planning', 'ROADMAP.md'), [
'# Roadmap',
'',
'## v1.0: Checkbox-Driven',
'',
'- [x] Phase 1: Scaffold',
'- [ ] Phase 2: Build',
'',
'### Phase 1: Scaffold',
'',
'**Goal:** Scaffold the thing',
'',
'### Phase 2: Build',
'',
'**Goal:** Build the thing',
'',
].join('\n'));
const result = await initProgress([], tmp);
const data = result.data as Record<string, unknown>;
const phases = data.phases as Record<string, unknown>[];
expect(data.phase_count).toBe(2);
expect(data.completed_count).toBe(1);
const phase1 = phases.find(p => p.number === '1');
const phase2 = phases.find(p => p.number === '2');
expect(phase1?.status).toBe('complete');
expect(phase2?.status).toBe('not_started');
} finally {
await rm(tmp, { recursive: true, force: true });
}
});
});
describe('initManager', () => {

View File

@@ -53,6 +53,36 @@ function pathExists(base: string, relPath: string): boolean {
return existsSync(join(base, relPath));
}
/**
* Extract ROADMAP checkbox states: `- [x] Phase N` → true, `- [ ] Phase N` → false.
* Shared by initProgress and initManager so both treat ROADMAP as the
* fallback/override source of truth for completion.
*/
function extractCheckboxStates(content: string): Map<string, boolean> {
const states = new Map<string, boolean>();
const pattern = /-\s*\[(x| )\]\s*.*Phase\s+(\d+[A-Z]?(?:\.\d+)*)[:\s]/gi;
let m: RegExpExecArray | null;
while ((m = pattern.exec(content)) !== null) {
states.set(m[2], m[1].toLowerCase() === 'x');
}
return states;
}
/**
* Derive progress-level status from a ROADMAP checkbox when the phase has
* no on-disk directory. Returns 'complete' for `[x]`, 'not_started' otherwise.
* Disk status (when present) always wins — it's more recent truth for in-flight work.
*/
function deriveStatusFromCheckbox(
phaseNum: string,
checkboxStates: Map<string, boolean>,
): 'complete' | 'not_started' {
const stripped = phaseNum.replace(/^0+/, '') || '0';
if (checkboxStates.get(phaseNum) === true) return 'complete';
if (checkboxStates.get(stripped) === true) return 'complete';
return 'not_started';
}
// ─── initNewProject ───────────────────────────────────────────────────────
/**
@@ -191,6 +221,7 @@ export const initProgress: QueryHandler = async (_args, projectDir, _workstream)
// Build set of phases from ROADMAP for the current milestone
const roadmapPhaseNames = new Map<string, string>();
const seenPhaseNums = new Set<string>();
let checkboxStates = new Map<string, boolean>();
try {
const rawRoadmap = await readFile(paths.roadmap, 'utf-8');
@@ -202,6 +233,7 @@ export const initProgress: QueryHandler = async (_args, projectDir, _workstream)
const pName = hm[2].replace(/\(INSERTED\)/i, '').trim();
roadmapPhaseNames.set(pNum, pName);
}
checkboxStates = extractCheckboxStates(roadmapContent);
} catch { /* intentionally empty */ }
// Scan phase directories
@@ -256,21 +288,23 @@ export const initProgress: QueryHandler = async (_args, projectDir, _workstream)
}
} catch { /* intentionally empty */ }
// Add ROADMAP-only phases not yet on disk
// Add ROADMAP-only phases not yet on disk. For phases with a ROADMAP
// `[x]` checkbox, treat them as complete (#2646).
for (const [num, name] of roadmapPhaseNames) {
const stripped = num.replace(/^0+/, '') || '0';
if (!seenPhaseNums.has(stripped)) {
const status = deriveStatusFromCheckbox(num, checkboxStates);
const phaseInfo: Record<string, unknown> = {
number: num,
name: name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''),
directory: null,
status: 'not_started',
status,
plan_count: 0,
summary_count: 0,
has_research: false,
};
phases.push(phaseInfo);
if (!nextPhase && !currentPhase) {
if (!nextPhase && !currentPhase && status !== 'complete') {
nextPhase = phaseInfo;
}
}
@@ -349,13 +383,8 @@ export const initManager: QueryHandler = async (_args, projectDir, _workstream)
.map(e => e.name);
} catch { /* intentionally empty */ }
// Pre-extract checkbox states in a single pass
const checkboxStates = new Map<string, boolean>();
const cbPattern = /-\s*\[(x| )\]\s*.*Phase\s+(\d+[A-Z]?(?:\.\d+)*)[:\s]/gi;
let cbMatch: RegExpExecArray | null;
while ((cbMatch = cbPattern.exec(content)) !== null) {
checkboxStates.set(cbMatch[2], cbMatch[1].toLowerCase() === 'x');
}
// Pre-extract checkbox states in a single pass (shared helper — #2646)
const checkboxStates = extractCheckboxStates(content);
const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
const phases: Record<string, unknown>[] = [];