Merge pull request #3115 from gsd-build/fix/3053-sdk-ignores-multi-plan-phase-layout-plan
fix: count nested plans/ layout in phase status indexing
This commit is contained in:
5
.changeset/pr-3115-release-note.md
Normal file
5
.changeset/pr-3115-release-note.md
Normal file
@@ -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.
|
||||
@@ -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' :
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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, [
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user