From c7886415c3eacb57b7597c2115b04ec8014ba636 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 4 May 2026 22:51:15 -0400 Subject: [PATCH 1/2] fix(phase): canonicalize plan-summary matching for suffixless summaries --- get-shit-done/bin/lib/core.cjs | 20 ++++++++++++++++++-- get-shit-done/bin/lib/phase.cjs | 19 +++++++++++++++++-- sdk/src/query/phase.ts | 30 +++++++++++++++++++++++++----- tests/core.test.cjs | 10 ++++++++++ tests/phase.test.cjs | 15 +++++++++++++++ 5 files changed, 85 insertions(+), 9 deletions(-) diff --git a/get-shit-done/bin/lib/core.cjs b/get-shit-done/bin/lib/core.cjs index a51b80f09..266c96666 100644 --- a/get-shit-done/bin/lib/core.cjs +++ b/get-shit-done/bin/lib/core.cjs @@ -950,6 +950,17 @@ function phaseTokenMatches(dirName, normalized) { return false; } +function extractCanonicalPlanId(filename) { + const base = filename.replace(/-PLAN\.md$/i, '').replace(/-SUMMARY\.md$/i, '').replace(/\.md$/i, ''); + const parts = base.split('-').filter(Boolean); + const tokenRe = /^\d+[A-Z]?(?:\.\d+)*$/i; + const phaseIdx = parts.findIndex(p => tokenRe.test(p)); + if (phaseIdx >= 0 && phaseIdx + 1 < parts.length && tokenRe.test(parts[phaseIdx + 1])) { + return `${parts[phaseIdx]}-${parts[phaseIdx + 1]}`; + } + return base; +} + function searchPhaseInDir(baseDir, relBase, normalized) { try { const dirs = readSubdirectories(baseDir, true); @@ -970,11 +981,16 @@ function searchPhaseInDir(baseDir, relBase, normalized) { const summaries = unsortedSummaries.sort(); const completedPlanIds = new Set( - summaries.map(s => s.replace('-SUMMARY.md', '').replace('SUMMARY.md', '')) + summaries.flatMap(s => { + const exact = s.replace('-SUMMARY.md', '').replace('SUMMARY.md', ''); + const canonical = extractCanonicalPlanId(s); + return canonical === exact ? [exact] : [exact, canonical]; + }) ); const incompletePlans = plans.filter(p => { const planId = p.replace('-PLAN.md', '').replace('PLAN.md', ''); - return !completedPlanIds.has(planId); + const canonical = extractCanonicalPlanId(p); + return !completedPlanIds.has(planId) && !completedPlanIds.has(canonical); }); return { diff --git a/get-shit-done/bin/lib/phase.cjs b/get-shit-done/bin/lib/phase.cjs index 9c078e0aa..73586c2ee 100644 --- a/get-shit-done/bin/lib/phase.cjs +++ b/get-shit-done/bin/lib/phase.cjs @@ -50,6 +50,17 @@ function describeNonCanonicalPlans(dirFiles, matchedFiles) { ); } +function extractCanonicalPlanId(filename) { + const base = filename.replace(/-PLAN\.md$/i, '').replace(/-SUMMARY\.md$/i, '').replace(/\.md$/i, ''); + const parts = base.split('-').filter(Boolean); + const tokenRe = /^\d+[A-Z]?(?:\.\d+)*$/i; + const phaseIdx = parts.findIndex(p => tokenRe.test(p)); + if (phaseIdx >= 0 && phaseIdx + 1 < parts.length && tokenRe.test(parts[phaseIdx + 1])) { + return `${parts[phaseIdx]}-${parts[phaseIdx + 1]}`; + } + return base; +} + function cmdPhasesList(cwd, options, raw) { const phasesDir = path.join(planningDir(cwd), 'phases'); const { type, phase, includeArchived } = options; @@ -288,7 +299,11 @@ function cmdPhasePlanIndex(cwd, phase, raw) { // Build set of plan IDs with summaries const completedPlanIds = new Set( - summaryFiles.map(s => s.replace('-SUMMARY.md', '').replace('SUMMARY.md', '')) + summaryFiles.flatMap(s => { + const exact = s.replace('-SUMMARY.md', '').replace('SUMMARY.md', ''); + const canonical = extractCanonicalPlanId(s); + return canonical === exact ? [exact] : [exact, canonical]; + }) ); const plans = []; @@ -327,7 +342,7 @@ function cmdPhasePlanIndex(cwd, phase, raw) { filesModified = Array.isArray(fmFiles) ? fmFiles : [fmFiles]; } - const hasSummary = completedPlanIds.has(planId); + const hasSummary = completedPlanIds.has(planId) || completedPlanIds.has(extractCanonicalPlanId(planFile)); if (!hasSummary) { incomplete.push(planId); } diff --git a/sdk/src/query/phase.ts b/sdk/src/query/phase.ts index 5974e194d..ea1c3cac7 100644 --- a/sdk/src/query/phase.ts +++ b/sdk/src/query/phase.ts @@ -80,6 +80,17 @@ async function getPhaseFileStats(phaseDir: string): Promise<{ * * Port of searchPhaseInDir from core.cjs lines 956-1000. */ +function extractCanonicalPlanId(filename: string): string { + const base = filename.replace(/-PLAN\.md$/i, '').replace(/-SUMMARY\.md$/i, '').replace(/\.md$/i, ''); + const parts = base.split('-').filter(Boolean); + const tokenRe = /^\d+[A-Z]?(?:\.\d+)*$/i; + const phaseIdx = parts.findIndex((p) => tokenRe.test(p)); + if (phaseIdx >= 0 && phaseIdx + 1 < parts.length && tokenRe.test(parts[phaseIdx + 1])) { + return `${parts[phaseIdx]}-${parts[phaseIdx + 1]}`; + } + return base; +} + async function searchPhaseInDir(baseDir: string, relBase: string, normalized: string): Promise { try { const entries = await readdir(baseDir, { withFileTypes: true }); @@ -105,11 +116,16 @@ async function searchPhaseInDir(baseDir: string, relBase: string, normalized: st const summaries = unsortedSummaries.sort(); const completedPlanIds = new Set( - summaries.map(s => s.replace('-SUMMARY.md', '').replace('SUMMARY.md', '')) + summaries.flatMap((s) => { + const exact = s.replace('-SUMMARY.md', '').replace('SUMMARY.md', ''); + const canonical = extractCanonicalPlanId(s); + return canonical === exact ? [exact] : [exact, canonical]; + }) ); - const incompletePlans = plans.filter(p => { + const incompletePlans = plans.filter((p) => { const planId = p.replace('-PLAN.md', '').replace('PLAN.md', ''); - return !completedPlanIds.has(planId); + const canonical = extractCanonicalPlanId(p); + return !completedPlanIds.has(planId) && !completedPlanIds.has(canonical); }); return { @@ -265,7 +281,11 @@ export const phasePlanIndex: QueryHandler = async (args, projectDir, workstream) // Build set of plan IDs with summaries — match the planId derivation logic const completedPlanIds = new Set( - summaryFiles.map(s => s === 'SUMMARY.md' ? 'PLAN' : s.replace('-SUMMARY.md', '')) + summaryFiles.flatMap((s) => { + const exact = s === 'SUMMARY.md' ? 'PLAN' : s.replace('-SUMMARY.md', ''); + const canonical = extractCanonicalPlanId(s); + return canonical === exact ? [exact] : [exact, canonical]; + }) ); const plans: Array> = []; @@ -306,7 +326,7 @@ export const phasePlanIndex: QueryHandler = async (args, projectDir, workstream) filesModified = Array.isArray(fmFiles) ? fmFiles : [fmFiles]; } - const hasSummary = completedPlanIds.has(planId); + const hasSummary = completedPlanIds.has(planId) || completedPlanIds.has(extractCanonicalPlanId(planFile)); if (!hasSummary) { incomplete.push(planId); } diff --git a/tests/core.test.cjs b/tests/core.test.cjs index f5a4a48fe..6386dd47b 100644 --- a/tests/core.test.cjs +++ b/tests/core.test.cjs @@ -869,6 +869,16 @@ describe('searchPhaseInDir', () => { assert.ok(result.incomplete_plans.includes('01-02-PLAN.md')); }); + test('treats prefix summary as complete for descriptive plan filename (#3101)', () => { + const phaseDir = path.join(phasesDir, '01-foundation'); + fs.mkdirSync(phaseDir); + fs.writeFileSync(path.join(phaseDir, '01-01-auth-hardening-PLAN.md'), '# Plan 1'); + fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary 1'); + + const result = searchPhaseInDir(phasesDir, '.planning/phases', '01'); + assert.strictEqual(result.incomplete_plans.length, 0); + }); + test('detects research and context files', () => { const phaseDir = path.join(phasesDir, '01-foundation'); fs.mkdirSync(phaseDir); diff --git a/tests/phase.test.cjs b/tests/phase.test.cjs index 68779c1af..c1cc8a751 100644 --- a/tests/phase.test.cjs +++ b/tests/phase.test.cjs @@ -462,6 +462,21 @@ objective: API routes assert.deepStrictEqual(output.incomplete, ['03-02'], 'incomplete list correct'); }); + test('phase-plan-index matches descriptive plan with prefix summary (#3101)', () => { + const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); + fs.mkdirSync(phaseDir, { recursive: true }); + + fs.writeFileSync(path.join(phaseDir, '03-01-auth-hardening-PLAN.md'), `---\nwave: 1\n---\n## Task 1`); + fs.writeFileSync(path.join(phaseDir, '03-01-SUMMARY.md'), `# Summary`); + + const result = runGsdTools('phase-plan-index 03', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.plans[0].has_summary, true, 'descriptive plan should match prefix summary'); + assert.deepStrictEqual(output.incomplete, [], 'plan should not be marked incomplete'); + }); + test('detects checkpoints (autonomous: false)', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); fs.mkdirSync(phaseDir, { recursive: true }); From 0fe88b9e7ad4a9abd4d97ca62635d79fba3d3c1a Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 4 May 2026 23:32:15 -0400 Subject: [PATCH 2/2] chore(changeset): add release fragment for PR #3112 --- .changeset/pr-3112-release-note.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/pr-3112-release-note.md diff --git a/.changeset/pr-3112-release-note.md b/.changeset/pr-3112-release-note.md new file mode 100644 index 000000000..abfe2b89b --- /dev/null +++ b/.changeset/pr-3112-release-note.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3112 +--- +Fixes for issue #3112 were applied to keep command/workflow behavior and SDK parity aligned with current documented usage.