fix(gsd-tools): support .planning/milestones/v*-phases/ layout in validators and find-phase

Fixes #3164

Validators and find-phase hardcoded phasesDir = .planning/phases/, so projects
using the milestone-archive layout (.planning/milestones/v*-phases/) had an empty
diskPhases set, triggering W006 for every active phase and find-phase returning
found:false.

Add collectDiskPhases(planBase) helper that scans both flat layout and all
.planning/milestones/v*-phases/ subdirs. Wire it into cmdValidateConsistency,
cmdValidateHealth (both the Check 4 validPhases set and Check 8 diskPhases),
and refactor cmdFindPhase to iterate candidate search dirs so it also searches
milestone-archive dirs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-05-06 15:10:15 -04:00
parent 94f835af40
commit 4847277082
4 changed files with 239 additions and 57 deletions

View File

@@ -6,6 +6,10 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased](https://github.com/gsd-build/get-shit-done/compare/v1.39.1...HEAD)
### Fixed
- **Milestone-archive layout support** — `validate consistency`, `validate health`, and `find-phase` now scan `.planning/milestones/v*-phases/` directories in addition to the flat `.planning/phases/` layout. Projects that have graduated to milestone-archive layout no longer receive spurious W006 "Phase N in ROADMAP.md but no directory on disk" warnings for every active phase. (#3164)
### Feature
- **Six namespace meta-skills with keyword-tag descriptions** — replace the flat 86-skill

View File

@@ -212,48 +212,61 @@ function cmdFindPhase(cwd, phase, raw) {
error('phase identifier required');
}
const phasesDir = path.join(planningDir(cwd), 'phases');
const planBase = planningDir(cwd);
const normalized = normalizePhaseName(phase);
const notFound = { found: false, directory: null, phase_number: null, phase_name: null, plans: [], summaries: [] };
// Build candidate search dirs: flat layout first, then milestone-archive layout.
const searchDirs = [];
const flatPhasesDir = path.join(planBase, 'phases');
if (fs.existsSync(flatPhasesDir)) searchDirs.push(flatPhasesDir);
try {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b));
const match = dirs.find(d => phaseTokenMatches(d, normalized));
if (!match) {
output(notFound, raw, '');
return;
const milestonesDir = path.join(planBase, 'milestones');
const entries = fs.readdirSync(milestonesDir, { withFileTypes: true });
for (const e of entries) {
if (e.isDirectory() && /^v\d+.*-phases$/.test(e.name)) {
searchDirs.push(path.join(milestonesDir, e.name));
}
}
} catch { /* no milestones dir */ }
// Extract phase number — supports project-code-prefixed (CK-01-name), numeric (01-name), and custom IDs
const dirMatch = match.match(/^(?:[A-Z]{1,6}-)(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i)
|| match.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i);
const phaseNumber = dirMatch ? dirMatch[1] : normalized;
const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null;
for (const searchDir of searchDirs) {
try {
const entries = fs.readdirSync(searchDir, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b));
const phaseDir = path.join(phasesDir, match);
const phaseFiles = fs.readdirSync(phaseDir);
const plans = phaseFiles.filter(isCanonicalPlanFile).sort();
const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').sort();
// #2893 — same diagnostic as phase-plan-index for consistency.
const planNamingWarning = describeNonCanonicalPlans(phaseFiles, plans);
const match = dirs.find(d => phaseTokenMatches(d, normalized));
if (!match) continue;
const result = {
found: true,
directory: toPosixPath(path.join(path.relative(cwd, planningDir(cwd)), 'phases', match)),
phase_number: phaseNumber,
phase_name: phaseName,
plans,
summaries,
};
if (planNamingWarning) result.warning = planNamingWarning;
// Extract phase number — supports project-code-prefixed (CK-01-name), numeric (01-name), and custom IDs
const dirMatch = match.match(/^(?:[A-Z]{1,6}-)(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i)
|| match.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i);
const phaseNumber = dirMatch ? dirMatch[1] : normalized;
const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null;
output(result, raw, result.directory);
} catch {
output(notFound, raw, '');
const phaseDir = path.join(searchDir, match);
const phaseFiles = fs.readdirSync(phaseDir);
const plans = phaseFiles.filter(isCanonicalPlanFile).sort();
const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').sort();
// #2893 — same diagnostic as phase-plan-index for consistency.
const planNamingWarning = describeNonCanonicalPlans(phaseFiles, plans);
const result = {
found: true,
directory: toPosixPath(path.join(path.relative(cwd, planBase), path.relative(planBase, searchDir), match)),
phase_number: phaseNumber,
phase_name: phaseName,
plans,
summaries,
};
if (planNamingWarning) result.warning = planNamingWarning;
output(result, raw, result.directory);
return;
} catch { continue; }
}
output(notFound, raw, '');
}
function extractObjective(content) {

View File

@@ -396,6 +396,37 @@ function cmdVerifyKeyLinks(cwd, planFilePath, raw) {
}, raw, verified === results.length ? 'valid' : 'invalid');
}
// Returns a Set of phase numbers found on disk, scanning both the flat
// .planning/phases/ layout and the milestone-archive .planning/milestones/v*-phases/ layout.
function collectDiskPhases(planBase) {
const diskPhases = new Set();
const scanDir = (dir) => {
try {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const e of entries) {
if (e.isDirectory()) {
const m = e.name.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
if (m) diskPhases.add(m[1]);
}
}
} catch { /* dir absent */ }
};
scanDir(path.join(planBase, 'phases'));
try {
const milestonesDir = path.join(planBase, 'milestones');
const entries = fs.readdirSync(milestonesDir, { withFileTypes: true });
for (const e of entries) {
if (e.isDirectory() && /^v\d+.*-phases$/.test(e.name)) {
scanDir(path.join(milestonesDir, e.name));
}
}
} catch { /* no milestones dir */ }
return diskPhases;
}
function cmdValidateConsistency(cwd, raw) {
const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
const phasesDir = path.join(planningDir(cwd), 'phases');
@@ -420,16 +451,8 @@ function cmdValidateConsistency(cwd, raw) {
roadmapPhases.add(m[1]);
}
// Get phases on disk
const diskPhases = new Set();
try {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
for (const dir of dirs) {
const dm = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
if (dm) diskPhases.add(dm[1]);
}
} catch { /* intentionally empty */ }
// Get phases on disk (flat layout + milestone-archive layout)
const diskPhases = collectDiskPhases(planningDir(cwd));
// Check: phases in ROADMAP but not on disk
for (const p of roadmapPhases) {
@@ -598,16 +621,7 @@ function cmdValidateHealth(cwd, options, raw) {
// (not yet materialized on disk) and shipped-milestone history phases
// (archived / cleared off disk). Matching only against on-disk dirs
// produces false W002 warnings in both cases.
const validPhases = new Set();
try {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
for (const e of entries) {
if (e.isDirectory()) {
const m = e.name.match(/^(\d+[A-Z]?(?:\.\d+)*)/);
if (m) validPhases.add(m[1]);
}
}
} catch { /* intentionally empty */ }
const validPhases = collectDiskPhases(planBase);
// Union in every phase declared anywhere in ROADMAP.md (current + shipped + backlog).
try {
if (fs.existsSync(roadmapPath)) {
@@ -765,11 +779,7 @@ function cmdValidateHealth(cwd, options, raw) {
roadmapPhases.add(m[1]);
}
const diskPhases = new Set();
for (const e of phaseDirEntries) {
const dm = e.name.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
if (dm) diskPhases.add(dm[1]);
}
const diskPhases = collectDiskPhases(planBase);
// Build a set of phases explicitly marked not-yet-started in the ROADMAP
// summary list (- [ ] **Phase N:**). These phases are intentionally absent

View File

@@ -0,0 +1,155 @@
'use strict';
/**
* #3164 — gsd-tools doesn't support .planning/milestones/v*-phases/ layout.
*
* Validators hardcode `phasesDir = .planning/phases/`. On projects that have
* graduated to milestone-archive layout (.planning/milestones/v*-phases/),
* the old path doesn't exist and diskPhases stays empty, triggering W006
* "Phase N in ROADMAP.md but no directory on disk" for every active phase.
*
* Fix: resolve phasesDir to the active milestone's archive dir when
* .planning/phases/ does not exist.
*/
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs');
function setupMilestoneArchiveProject(tmpDir, options = {}) {
const {
milestone = 'v1.7',
phases = ['64-secondary-grader-fix'],
roadmapPhases = ['64'],
} = options;
// Remove the default .planning/phases/ dir (milestone-archive layout has no flat phases/)
fs.rmSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true, force: true });
// Create milestone-archive phase directories
const archiveDir = path.join(tmpDir, '.planning', 'milestones', `${milestone}-phases`);
for (const phase of phases) {
const phaseDir = path.join(archiveDir, phase);
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, 'PLAN.md'), `# Plan\nPhase ${phase}\n`);
}
// Write STATE.md with current milestone
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`milestone: ${milestone}\n# Session State\n\nPhase: ${roadmapPhases[0]}\n`
);
// Write PROJECT.md
fs.writeFileSync(
path.join(tmpDir, '.planning', 'PROJECT.md'),
'# Project\n\n## What This Is\nTest.\n## Core Value\nTest.\n## Requirements\nTest.\n'
);
// Write ROADMAP.md with phases in the milestone section
const phaseLines = roadmapPhases.map(n => `### Phase ${n}: Description\n\nGoal: implement it.\n`).join('\n');
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n\n## Roadmap ${milestone}: Current\n\n${phaseLines}\n`
);
// Write config.json
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ model_profile: 'balanced', commit_docs: true }, null, 2)
);
}
describe('#3164 — validate consistency: milestone-archive layout', () => {
let tmpDir;
beforeEach(() => { tmpDir = createTempProject(); });
afterEach(() => { cleanup(tmpDir); });
test('no W006 warnings for phases that exist in .planning/milestones/v*-phases/', () => {
setupMilestoneArchiveProject(tmpDir, {
milestone: 'v1.7',
phases: ['64-secondary-grader-fix'],
roadmapPhases: ['64'],
});
const result = runGsdTools('validate consistency', tmpDir);
assert.ok(result.success, `validate consistency should succeed: ${result.error}`);
const out = JSON.parse(result.output);
const w006 = (out.warnings || []).filter(w => w.includes('Phase 64') && w.includes('no directory'));
assert.deepStrictEqual(
w006, [],
`Got spurious W006 for phase 64 in milestone-archive layout:\n ${w006.join('\n ')}`
);
});
test('no W006 when multiple phases exist in milestone-archive layout', () => {
setupMilestoneArchiveProject(tmpDir, {
milestone: 'v1.7',
phases: ['48-feature-a', '51-feature-b', '64-feature-c'],
roadmapPhases: ['48', '51', '64'],
});
const result = runGsdTools('validate consistency', tmpDir);
assert.ok(result.success, `validate consistency should succeed: ${result.error}`);
const out = JSON.parse(result.output);
const w006 = (out.warnings || []).filter(w => w.includes('no directory'));
assert.deepStrictEqual(
w006, [],
`Got spurious W006 warnings in milestone-archive layout:\n ${w006.join('\n ')}`
);
});
});
describe('#3164 — validate health: milestone-archive layout', () => {
let tmpDir;
beforeEach(() => { tmpDir = createTempProject(); });
afterEach(() => { cleanup(tmpDir); });
test('no W006 warnings for phases that exist in .planning/milestones/v*-phases/', () => {
setupMilestoneArchiveProject(tmpDir, {
milestone: 'v1.7',
phases: ['64-secondary-grader-fix'],
roadmapPhases: ['64'],
});
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `validate health should succeed: ${result.error}`);
const out = JSON.parse(result.output);
const w006 = (out.warnings || []).filter(w => {
const msg = typeof w === 'string' ? w : w.message;
return msg && msg.includes('Phase 64') && msg.includes('no directory');
});
assert.deepStrictEqual(
w006, [],
`Got spurious W006 for phase 64 in milestone-archive validate health:\n ${w006.map(w => typeof w === 'string' ? w : w.message).join('\n ')}`
);
});
});
describe('#3164 — find-phase: milestone-archive layout', () => {
let tmpDir;
beforeEach(() => { tmpDir = createTempProject(); });
afterEach(() => { cleanup(tmpDir); });
test('find-phase 64 returns found:true for phase in .planning/milestones/v*-phases/', () => {
setupMilestoneArchiveProject(tmpDir, {
milestone: 'v1.7',
phases: ['64-secondary-grader-fix'],
roadmapPhases: ['64'],
});
const result = runGsdTools('find-phase 64', tmpDir);
assert.ok(result.success, `find-phase should succeed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.found, true, `find-phase 64 should return found:true, got: ${JSON.stringify(out)}`);
});
});