diff --git a/.changeset/pr-3115-release-note.md b/.changeset/pr-3115-release-note.md new file mode 100644 index 000000000..db3fa29a2 --- /dev/null +++ b/.changeset/pr-3115-release-note.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3115 +--- +Fixes for issue #3115 were applied to keep command/workflow behavior and SDK parity aligned with current documented usage. diff --git a/get-shit-done/bin/lib/init.cjs b/get-shit-done/bin/lib/init.cjs index 3eaa42374..952bdaaf4 100644 --- a/get-shit-done/bin/lib/init.cjs +++ b/get-shit-done/bin/lib/init.cjs @@ -14,6 +14,30 @@ const { maskIfSecret } = require('./secrets.cjs'); // same in markdown but differ textually. const REQUIREMENTS_HEADER_RE = /^\*\*Requirements:?\*\*[^\S\n]*:?[^\S\n]*([^\n]*)$/m; +function listPhaseSummaryFiles(phaseDir) { + const phaseFiles = fs.readdirSync(phaseDir); + const rootSummaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); + const plansDir = path.join(phaseDir, 'plans'); + let nestedSummaries = []; + if (fs.existsSync(plansDir)) { + const files = fs.readdirSync(plansDir); + nestedSummaries = files.filter(f => /^SUMMARY-\d+.*\.md$/i.test(f)); + } + return rootSummaries.concat(nestedSummaries); +} + +function listPhasePlanFiles(phaseDir) { + const phaseFiles = fs.readdirSync(phaseDir); + const rootPlans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md'); + const plansDir = path.join(phaseDir, 'plans'); + let nestedPlans = []; + if (fs.existsSync(plansDir)) { + const files = fs.readdirSync(plansDir); + nestedPlans = files.filter(f => /^PLAN-\d+.*\.md$/i.test(f)); + } + return rootPlans.concat(nestedPlans); +} + function getLatestCompletedMilestone(cwd) { const milestonesPath = path.join(planningRoot(cwd), 'MILESTONES.md'); if (!fs.existsSync(milestonesPath)) return null; @@ -901,8 +925,7 @@ function cmdInitMilestoneOp(cwd, raw) { const dirName = diskPhaseDirs.get(canonicalizePhase(num)); if (!dirName) continue; try { - const phaseFiles = fs.readdirSync(path.join(phasesDir, dirName)); - const hasSummary = phaseFiles.some(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); + const hasSummary = listPhaseSummaryFiles(path.join(phasesDir, dirName)).length > 0; if (hasSummary) completedPhases++; } catch { /* intentionally empty */ } } @@ -914,8 +937,7 @@ function cmdInitMilestoneOp(cwd, raw) { phaseCount = dirs.length; for (const dir of dirs) { try { - const phaseFiles = fs.readdirSync(path.join(phasesDir, dir)); - const hasSummary = phaseFiles.some(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); + const hasSummary = listPhaseSummaryFiles(path.join(phasesDir, dir)).length > 0; if (hasSummary) completedPhases++; } catch { /* intentionally empty */ } } @@ -1072,8 +1094,8 @@ function cmdInitManager(cwd, raw) { if (dirMatch) { const fullDir = path.join(phasesDir, dirMatch); const phaseFiles = fs.readdirSync(fullDir); - planCount = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').length; - summaryCount = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').length; + planCount = listPhasePlanFiles(fullDir).length; + summaryCount = listPhaseSummaryFiles(fullDir).length; hasContext = phaseFiles.some(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md'); hasResearch = phaseFiles.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'); @@ -1353,8 +1375,8 @@ function cmdInitProgress(cwd, raw) { const phasePath = path.join(phasesDir, dir); const phaseFiles = fs.readdirSync(phasePath); - const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md'); - const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); + const plans = listPhasePlanFiles(phasePath); + const summaries = listPhaseSummaryFiles(phasePath); const hasResearch = phaseFiles.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'); const status = summaries.length >= plans.length && plans.length > 0 ? 'complete' : diff --git a/get-shit-done/bin/lib/roadmap.cjs b/get-shit-done/bin/lib/roadmap.cjs index 133f9b4d9..930eb8b94 100644 --- a/get-shit-done/bin/lib/roadmap.cjs +++ b/get-shit-done/bin/lib/roadmap.cjs @@ -36,6 +36,28 @@ function coerceTruthToString(t) { return ''; } +function countPhasePlansAndSummaries(phaseDir) { + const phaseFiles = fs.readdirSync(phaseDir); + const rootPlans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md'); + const rootSummaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); + + let nestedPlans = []; + let nestedSummaries = []; + const plansDir = path.join(phaseDir, 'plans'); + if (fs.existsSync(plansDir)) { + const planFiles = fs.readdirSync(plansDir); + nestedPlans = planFiles.filter(f => /^PLAN-\d+.*\.md$/i.test(f)); + nestedSummaries = planFiles.filter(f => /^SUMMARY-\d+.*\.md$/i.test(f)); + } + + return { + planCount: rootPlans.length + nestedPlans.length, + summaryCount: rootSummaries.length + nestedSummaries.length, + hasContext: phaseFiles.some(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md'), + hasResearch: phaseFiles.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'), + }; +} + /** * Search for a phase header (and its section) within the given content string. * Returns a result object if found (either a full match or a malformed_roadmap @@ -197,11 +219,11 @@ function cmdRoadmapAnalyze(cwd, raw) { const dirMatch = _phaseDirNames.find(d => phaseTokenMatches(d, normalized)); if (dirMatch) { - const phaseFiles = fs.readdirSync(path.join(phasesDir, dirMatch)); - planCount = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').length; - summaryCount = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').length; - hasContext = phaseFiles.some(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md'); - hasResearch = phaseFiles.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'); + const counts = countPhasePlansAndSummaries(path.join(phasesDir, dirMatch)); + planCount = counts.planCount; + summaryCount = counts.summaryCount; + hasContext = counts.hasContext; + hasResearch = counts.hasResearch; if (summaryCount >= planCount && planCount > 0) diskStatus = 'complete'; else if (summaryCount > 0) diskStatus = 'partial'; diff --git a/sdk/src/query/init-complex.ts b/sdk/src/query/init-complex.ts index d85e19a03..bb0f13f84 100644 --- a/sdk/src/query/init-complex.ts +++ b/sdk/src/query/init-complex.ts @@ -83,6 +83,26 @@ function deriveStatusFromCheckbox( return 'not_started'; } +function listPhasePlanAndSummaryCounts(phasePath: string): { plans: string[]; summaries: string[] } { + const phaseFiles = readdirSync(phasePath); + const rootPlans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md'); + const rootSummaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); + + const plansDir = join(phasePath, 'plans'); + let nestedPlans: string[] = []; + let nestedSummaries: string[] = []; + if (existsSync(plansDir)) { + const files = readdirSync(plansDir); + nestedPlans = files.filter(f => /^PLAN-\d+.*\.md$/i.test(f)); + nestedSummaries = files.filter(f => /^SUMMARY-\d+.*\.md$/i.test(f)); + } + + return { + plans: rootPlans.concat(nestedPlans), + summaries: rootSummaries.concat(nestedSummaries), + }; +} + // ─── initNewProject ─────────────────────────────────────────────────────── /** @@ -258,8 +278,7 @@ export const initProgress: QueryHandler = async (_args, projectDir, workstream) const phasePath = join(paths.phases, dir); const phaseFiles = readdirSync(phasePath); - const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md'); - const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); + const { plans, summaries } = listPhasePlanAndSummaryCounts(phasePath); const hasResearch = phaseFiles.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'); let status = @@ -431,8 +450,9 @@ export const initManager: QueryHandler = async (_args, projectDir, workstream) = if (dirMatch) { const fullDir = join(paths.phases, dirMatch); const phaseFiles = readdirSync(fullDir); - planCount = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').length; - summaryCount = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').length; + const counts = listPhasePlanAndSummaryCounts(fullDir); + planCount = counts.plans.length; + summaryCount = counts.summaries.length; hasContext = phaseFiles.some(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md'); hasResearch = phaseFiles.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'); diff --git a/sdk/src/query/roadmap.ts b/sdk/src/query/roadmap.ts index ad139faef..ed0cb003e 100644 --- a/sdk/src/query/roadmap.ts +++ b/sdk/src/query/roadmap.ts @@ -404,6 +404,28 @@ function searchPhaseInContent(content: string, escapedPhase: string, phaseNum: s }; } +async function countPhasePlansAndSummaries(phaseDir: string): Promise<{ planCount: number; summaryCount: number; hasContext: boolean; hasResearch: boolean; }> { + const phaseFiles = await readdir(phaseDir); + const rootPlans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md'); + const rootSummaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); + + let nestedPlans: string[] = []; + let nestedSummaries: string[] = []; + const plansDir = join(phaseDir, 'plans'); + if (existsSync(plansDir)) { + const files = await readdir(plansDir); + nestedPlans = files.filter(f => /^PLAN-\d+.*\.md$/i.test(f)); + nestedSummaries = files.filter(f => /^SUMMARY-\d+.*\.md$/i.test(f)); + } + + return { + planCount: rootPlans.length + nestedPlans.length, + summaryCount: rootSummaries.length + nestedSummaries.length, + hasContext: phaseFiles.some(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md'), + hasResearch: phaseFiles.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'), + }; +} + // ─── Exported handlers ──────────────────────────────────────────────────── /** @@ -509,11 +531,11 @@ export const roadmapAnalyze: QueryHandler = async (_args, projectDir, workstream const dirMatch = dirs.find(d => phaseTokenMatches(d, normalized)); if (dirMatch) { - const phaseFiles = await readdir(join(phasesDir, dirMatch)); - planCount = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').length; - summaryCount = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').length; - hasContext = phaseFiles.some(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md'); - hasResearch = phaseFiles.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'); + const counts = await countPhasePlansAndSummaries(join(phasesDir, dirMatch)); + planCount = counts.planCount; + summaryCount = counts.summaryCount; + hasContext = counts.hasContext; + hasResearch = counts.hasResearch; if (summaryCount >= planCount && planCount > 0) diskStatus = 'complete'; else if (summaryCount > 0) diskStatus = 'partial'; diff --git a/tests/init-manager.test.cjs b/tests/init-manager.test.cjs index de31a77f3..5e5eae6e1 100644 --- a/tests/init-manager.test.cjs +++ b/tests/init-manager.test.cjs @@ -131,6 +131,27 @@ describe('init manager', () => { assert.strictEqual(output.phases[4].disk_status, 'no_directory'); }); + test('treats plans/PLAN-NN.md layout as planned/complete counts (#3053)', () => { + writeState(tmpDir); + writeRoadmap(tmpDir, [ + { number: '1', name: 'Nested Plans' }, + ]); + + const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-nested-plans'); + fs.mkdirSync(path.join(phaseDir, 'plans'), { recursive: true }); + fs.writeFileSync(path.join(phaseDir, 'plans', 'PLAN-01.md'), '# Plan 1'); + fs.writeFileSync(path.join(phaseDir, 'plans', 'PLAN-02.md'), '# Plan 2'); + fs.writeFileSync(path.join(phaseDir, 'plans', 'SUMMARY-01.md'), '# Summary 1'); + + const result = runGsdTools('init manager', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.phases[0].plan_count, 2); + assert.strictEqual(output.phases[0].summary_count, 1); + assert.strictEqual(output.phases[0].disk_status, 'partial'); + }); + test('dependency satisfaction: deps on complete phases = satisfied', () => { writeState(tmpDir); writeRoadmap(tmpDir, [ diff --git a/tests/roadmap.test.cjs b/tests/roadmap.test.cjs index ab05926ca..1e6420652 100644 --- a/tests/roadmap.test.cjs +++ b/tests/roadmap.test.cjs @@ -706,6 +706,31 @@ describe('roadmap update-plan-progress command', () => { assert.ok(roadmapContent.includes('1/2'), 'roadmap should contain updated plan count'); }); + test('counts plans and summaries from plans/ subdirectory layout (#3053)', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap + +### Phase 1: Test +**Goal:** Test goal +` + ); + + const p1 = path.join(tmpDir, '.planning', 'phases', '01-test', 'plans'); + fs.mkdirSync(p1, { recursive: true }); + fs.writeFileSync(path.join(p1, 'PLAN-01.md'), '# Plan 1'); + fs.writeFileSync(path.join(p1, 'PLAN-02.md'), '# Plan 2'); + fs.writeFileSync(path.join(p1, 'SUMMARY-01.md'), '# Summary 1'); + + const result = runGsdTools('roadmap analyze', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.phases[0].plan_count, 2); + assert.strictEqual(output.phases[0].summary_count, 1); + assert.strictEqual(output.phases[0].disk_status, 'partial'); + }); + test('updates progress and checks checkbox on completion', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'),