From a6e692f789ee23795723147af57e0ac6590b2112 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 24 Apr 2026 18:05:41 -0400 Subject: [PATCH] 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 --- get-shit-done/bin/lib/init.cjs | 20 +++++++++-- sdk/src/query/init-complex.test.ts | 55 ++++++++++++++++++++++++++++++ sdk/src/query/init-complex.ts | 49 ++++++++++++++++++++------ 3 files changed, 111 insertions(+), 13 deletions(-) diff --git a/get-shit-done/bin/lib/init.cjs b/get-shit-done/bin/lib/init.cjs index 1a1e0ae36..33bf62319 100644 --- a/get-shit-done/bin/lib/init.cjs +++ b/get-shit-done/bin/lib/init.cjs @@ -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; } } diff --git a/sdk/src/query/init-complex.test.ts b/sdk/src/query/init-complex.test.ts index f0769ffb9..53256a77c 100644 --- a/sdk/src/query/init-complex.test.ts +++ b/sdk/src/query/init-complex.test.ts @@ -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; + const phases = data.phases as Record[]; + + 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', () => { diff --git a/sdk/src/query/init-complex.ts b/sdk/src/query/init-complex.ts index 7628b6228..73feffd76 100644 --- a/sdk/src/query/init-complex.ts +++ b/sdk/src/query/init-complex.ts @@ -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 { + const states = new Map(); + 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, +): '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(); const seenPhaseNums = new Set(); + let checkboxStates = new Map(); 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 = { 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(); - 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[] = [];