fix(sdk): skip terminal-labeled phases in init.progress next_phase (#3478)
* fix(sdk): treat terminal roadmap labels as complete in init.progress (#3472) * chore(changeset): add #3478 fragment * test(sdk): assert promoted/registered/inserted labels stay non-terminal
This commit is contained in:
5
.changeset/noble-mice-squeak.md
Normal file
5
.changeset/noble-mice-squeak.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
type: Fixed
|
||||||
|
pr: 3478
|
||||||
|
---
|
||||||
|
**`gsd-sdk query init.progress` now respects terminal phase labels in roadmap headings** — phases marked COMPLETE/SHIPPED/DEFERRED/SUPERSEDED/MERGED/FOLDED are counted complete and skipped as next pending phase.
|
||||||
@@ -295,6 +295,70 @@ describe('initProgress', () => {
|
|||||||
await rm(tmp, { recursive: true, force: true });
|
await rm(tmp, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('treats terminal heading labels as complete when selecting next_phase (#3472)', async () => {
|
||||||
|
const tmp = await mkdtemp(join(tmpdir(), 'gsd-init-complex-3472-'));
|
||||||
|
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: Current',
|
||||||
|
'',
|
||||||
|
'## Phase 4.12: Old Work (COMPLETE)',
|
||||||
|
'',
|
||||||
|
'## Phase 4.13: Another old one (SHIPPED 2026-05-12)',
|
||||||
|
'',
|
||||||
|
'## Phase 4.17: Human Auth (DEFERRED)',
|
||||||
|
'',
|
||||||
|
'## Phase 4.23: New pending work',
|
||||||
|
'',
|
||||||
|
'## Phase 4.24: Follow-up item (PROMOTED)',
|
||||||
|
'',
|
||||||
|
'## Phase 4.25: Another follow-up (REGISTERED)',
|
||||||
|
'',
|
||||||
|
'## Phase 4.26: Triage marker (INSERTED)',
|
||||||
|
'',
|
||||||
|
].join('\n'));
|
||||||
|
|
||||||
|
const result = await initProgress([], tmp);
|
||||||
|
const data = result.data as Record<string, unknown>;
|
||||||
|
const phases = data.phases as Record<string, unknown>[];
|
||||||
|
const phase412 = phases.find(p => p.number === '4.12');
|
||||||
|
const phase413 = phases.find(p => p.number === '4.13');
|
||||||
|
const phase417 = phases.find(p => p.number === '4.17');
|
||||||
|
const phase424 = phases.find(p => p.number === '4.24');
|
||||||
|
const phase425 = phases.find(p => p.number === '4.25');
|
||||||
|
const phase426 = phases.find(p => p.number === '4.26');
|
||||||
|
|
||||||
|
expect(phase412?.status).toBe('complete');
|
||||||
|
expect(phase413?.status).toBe('complete');
|
||||||
|
expect(phase417?.status).toBe('complete');
|
||||||
|
expect(phase424?.status).not.toBe('complete');
|
||||||
|
expect(phase425?.status).not.toBe('complete');
|
||||||
|
expect(phase426?.status).not.toBe('complete');
|
||||||
|
expect(data.completed_count).toBe(3);
|
||||||
|
expect((data.next_phase as Record<string, unknown>).number).toBe('4.23');
|
||||||
|
} finally {
|
||||||
|
await rm(tmp, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('initManager', () => {
|
describe('initManager', () => {
|
||||||
|
|||||||
@@ -101,6 +101,25 @@ function extractCheckboxStates(content: string): Map<string, boolean> {
|
|||||||
return states;
|
return states;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract terminal phase markers from ROADMAP phase headings, e.g.
|
||||||
|
* `(COMPLETE)`, `(SHIPPED ...)`, `(DEFERRED)`, `(SUPERSEDED ...)`.
|
||||||
|
* These labels mean the phase should not be selected as next pending work.
|
||||||
|
*/
|
||||||
|
function extractTerminalStatusLabels(content: string): Set<string> {
|
||||||
|
const terminal = new Set<string>();
|
||||||
|
const headingPattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
|
||||||
|
const terminalRe = /(?:\(|\*\*)\s*(SHIPPED|COMPLETE|DEFERRED|SUPERSEDED|MERGED\s+INTO|FOLDED\s+INTO)\b/i;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = headingPattern.exec(content)) !== null) {
|
||||||
|
if (terminalRe.test(m[2])) {
|
||||||
|
terminal.add(m[1]);
|
||||||
|
terminal.add(m[1].replace(/^0+/, '') || '0');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return terminal;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Derive progress-level status from a ROADMAP checkbox when the phase has
|
* Derive progress-level status from a ROADMAP checkbox when the phase has
|
||||||
* no on-disk directory. Returns 'complete' for `[x]`, 'not_started' otherwise.
|
* no on-disk directory. Returns 'complete' for `[x]`, 'not_started' otherwise.
|
||||||
@@ -291,6 +310,7 @@ export const initProgress: QueryHandler = async (_args, projectDir, workstream)
|
|||||||
const roadmapPhaseNames = new Map<string, string>();
|
const roadmapPhaseNames = new Map<string, string>();
|
||||||
const seenPhaseNums = new Set<string>();
|
const seenPhaseNums = new Set<string>();
|
||||||
let checkboxStates = new Map<string, boolean>();
|
let checkboxStates = new Map<string, boolean>();
|
||||||
|
let terminalLabels = new Set<string>();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const rawRoadmap = await readFile(paths.roadmap, 'utf-8');
|
const rawRoadmap = await readFile(paths.roadmap, 'utf-8');
|
||||||
@@ -303,6 +323,7 @@ export const initProgress: QueryHandler = async (_args, projectDir, workstream)
|
|||||||
roadmapPhaseNames.set(pNum, pName);
|
roadmapPhaseNames.set(pNum, pName);
|
||||||
}
|
}
|
||||||
checkboxStates = extractCheckboxStates(roadmapContent);
|
checkboxStates = extractCheckboxStates(roadmapContent);
|
||||||
|
terminalLabels = extractTerminalStatusLabels(roadmapContent);
|
||||||
} catch { /* intentionally empty */ }
|
} catch { /* intentionally empty */ }
|
||||||
|
|
||||||
// Scan phase directories
|
// Scan phase directories
|
||||||
@@ -345,6 +366,9 @@ export const initProgress: QueryHandler = async (_args, projectDir, workstream)
|
|||||||
if (roadmapComplete && status !== 'complete') {
|
if (roadmapComplete && status !== 'complete') {
|
||||||
status = 'complete';
|
status = 'complete';
|
||||||
}
|
}
|
||||||
|
if (terminalLabels.has(phaseNumber) || terminalLabels.has(strippedNum)) {
|
||||||
|
status = 'complete';
|
||||||
|
}
|
||||||
|
|
||||||
const phaseInfo: Record<string, unknown> = {
|
const phaseInfo: Record<string, unknown> = {
|
||||||
number: phaseNumber,
|
number: phaseNumber,
|
||||||
@@ -373,17 +397,18 @@ export const initProgress: QueryHandler = async (_args, projectDir, workstream)
|
|||||||
const stripped = num.replace(/^0+/, '') || '0';
|
const stripped = num.replace(/^0+/, '') || '0';
|
||||||
if (!seenPhaseNums.has(stripped)) {
|
if (!seenPhaseNums.has(stripped)) {
|
||||||
const status = deriveStatusFromCheckbox(num, checkboxStates);
|
const status = deriveStatusFromCheckbox(num, checkboxStates);
|
||||||
|
const terminalComplete = terminalLabels.has(num) || terminalLabels.has(stripped);
|
||||||
const phaseInfo: Record<string, unknown> = {
|
const phaseInfo: Record<string, unknown> = {
|
||||||
number: num,
|
number: num,
|
||||||
name: name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''),
|
name: name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''),
|
||||||
directory: null,
|
directory: null,
|
||||||
status,
|
status: terminalComplete ? 'complete' : status,
|
||||||
plan_count: 0,
|
plan_count: 0,
|
||||||
summary_count: 0,
|
summary_count: 0,
|
||||||
has_research: false,
|
has_research: false,
|
||||||
};
|
};
|
||||||
phases.push(phaseInfo);
|
phases.push(phaseInfo);
|
||||||
if (!nextPhase && !currentPhase && status !== 'complete') {
|
if (!nextPhase && !currentPhase && phaseInfo.status !== 'complete') {
|
||||||
nextPhase = phaseInfo;
|
nextPhase = phaseInfo;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user