mirror of
https://github.com/glittercowboy/get-shit-done
synced 2026-04-25 17:25:23 +02:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>[] = [];
|
||||
|
||||
Reference in New Issue
Block a user