fix(roadmap): accept ## and ### phase headers, detect malformed ROADMAPs (#598, #599)

- Parser now accepts both `## Phase X:` and `### Phase X:` headers
- `roadmap get-phase` detects when phases exist in summary list but
  missing detail sections, returns `error: 'malformed_roadmap'`
- `roadmap analyze` returns `missing_phase_details` array
- Updated gsd-roadmapper instructions with explicit format requirements
- Added 2 tests for new functionality (77 total, all passing)

Closes #598, closes #599

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Lex Christopherson
2026-02-15 08:38:54 -06:00
parent d86385887a
commit 7b140c2dd2
3 changed files with 129 additions and 21 deletions

View File

@@ -286,12 +286,46 @@ After roadmap creation, REQUIREMENTS.md gets updated with phase mappings:
## ROADMAP.md Structure
Use template from `~/.claude/get-shit-done/templates/roadmap.md`.
**CRITICAL: ROADMAP.md requires TWO phase representations. Both are mandatory.**
Key sections:
- Overview (2-3 sentences)
- Phases with Goal, Dependencies, Requirements, Success Criteria
- Progress table
### 1. Summary Checklist (under `## Phases`)
```markdown
- [ ] **Phase 1: Name** - One-line description
- [ ] **Phase 2: Name** - One-line description
- [ ] **Phase 3: Name** - One-line description
```
### 2. Detail Sections (under `## Phase Details`)
```markdown
### Phase 1: Name
**Goal**: What this phase delivers
**Depends on**: Nothing (first phase)
**Requirements**: REQ-01, REQ-02
**Success Criteria** (what must be TRUE):
1. Observable behavior from user perspective
2. Observable behavior from user perspective
**Plans**: TBD
### Phase 2: Name
**Goal**: What this phase delivers
**Depends on**: Phase 1
...
```
**The `### Phase X:` headers are parsed by downstream tools.** If you only write the summary checklist, phase lookups will fail.
### 3. Progress Table
```markdown
| Phase | Plans Complete | Status | Completed |
|-------|----------------|--------|-----------|
| 1. Name | 0/3 | Not started | - |
| 2. Name | 0/2 | Not started | - |
```
Reference full template: `~/.claude/get-shit-done/templates/roadmap.md`
## STATE.md Structure

View File

@@ -835,14 +835,33 @@ function cmdRoadmapGetPhase(cwd, phaseNum, raw) {
// Escape special regex chars in phase number, handle decimal
const escapedPhase = phaseNum.replace(/\./g, '\\.');
// Match "### Phase X:" or "### Phase X.Y:" with optional name
// Match "## Phase X:" or "### Phase X:" with optional name
const phasePattern = new RegExp(
`###\\s*Phase\\s+${escapedPhase}:\\s*([^\\n]+)`,
`#{2,3}\\s*Phase\\s+${escapedPhase}:\\s*([^\\n]+)`,
'i'
);
const headerMatch = content.match(phasePattern);
if (!headerMatch) {
// Fallback: check if phase exists in summary list but missing detail section
const checklistPattern = new RegExp(
`-\\s*\\[[ x]\\]\\s*\\*\\*Phase\\s+${escapedPhase}:\\s*([^*]+)\\*\\*`,
'i'
);
const checklistMatch = content.match(checklistPattern);
if (checklistMatch) {
// Phase exists in summary but missing detail section - malformed ROADMAP
output({
found: false,
phase_number: phaseNum,
phase_name: checklistMatch[1].trim(),
error: 'malformed_roadmap',
message: `Phase ${phaseNum} exists in summary list but missing "### Phase ${phaseNum}:" detail section. ROADMAP.md needs both formats.`
}, raw, '');
return;
}
output({ found: false, phase_number: phaseNum }, raw, '');
return;
}
@@ -850,9 +869,9 @@ function cmdRoadmapGetPhase(cwd, phaseNum, raw) {
const phaseName = headerMatch[1].trim();
const headerIndex = headerMatch.index;
// Find the end of this section (next ### or end of file)
// Find the end of this section (next ## or ### phase header, or end of file)
const restOfContent = content.slice(headerIndex);
const nextHeaderMatch = restOfContent.match(/\n###\s+Phase\s+\d/i);
const nextHeaderMatch = restOfContent.match(/\n#{2,3}\s+Phase\s+\d/i);
const sectionEnd = nextHeaderMatch
? headerIndex + nextHeaderMatch.index
: content.length;
@@ -2430,8 +2449,8 @@ function cmdRoadmapAnalyze(cwd, raw) {
const content = fs.readFileSync(roadmapPath, 'utf-8');
const phasesDir = path.join(cwd, '.planning', 'phases');
// Extract all phase headings: ### Phase N: Name
const phasePattern = /###\s*Phase\s+(\d+(?:\.\d+)?)\s*:\s*([^\n]+)/gi;
// Extract all phase headings: ## Phase N: Name or ### Phase N: Name
const phasePattern = /#{2,3}\s*Phase\s+(\d+(?:\.\d+)?)\s*:\s*([^\n]+)/gi;
const phases = [];
let match;
@@ -2442,7 +2461,7 @@ function cmdRoadmapAnalyze(cwd, raw) {
// Extract goal from the section
const sectionStart = match.index;
const restOfContent = content.slice(sectionStart);
const nextHeader = restOfContent.match(/\n###\s+Phase\s+\d/i);
const nextHeader = restOfContent.match(/\n#{2,3}\s+Phase\s+\d/i);
const sectionEnd = nextHeader ? sectionStart + nextHeader.index : content.length;
const section = content.slice(sectionStart, sectionEnd);
@@ -2520,6 +2539,16 @@ function cmdRoadmapAnalyze(cwd, raw) {
const totalSummaries = phases.reduce((sum, p) => sum + p.summary_count, 0);
const completedPhases = phases.filter(p => p.disk_status === 'complete').length;
// Detect phases in summary list without detail sections (malformed ROADMAP)
const checklistPattern = /-\s*\[[ x]\]\s*\*\*Phase\s+(\d+(?:\.\d+)?)/gi;
const checklistPhases = new Set();
let checklistMatch;
while ((checklistMatch = checklistPattern.exec(content)) !== null) {
checklistPhases.add(checklistMatch[1]);
}
const detailPhases = new Set(phases.map(p => p.number));
const missingDetails = [...checklistPhases].filter(p => !detailPhases.has(p));
const result = {
milestones,
phases,
@@ -2530,6 +2559,7 @@ function cmdRoadmapAnalyze(cwd, raw) {
progress_percent: totalPlans > 0 ? Math.round((totalSummaries / totalPlans) * 100) : 0,
current_phase: currentPhase ? currentPhase.number : null,
next_phase: nextPhase ? nextPhase.number : null,
missing_phase_details: missingDetails.length > 0 ? missingDetails : null,
};
output(result, raw);
@@ -2551,7 +2581,7 @@ function cmdPhaseAdd(cwd, description, raw) {
const slug = generateSlugInternal(description);
// Find highest integer phase number
const phasePattern = /###\s*Phase\s+(\d+)(?:\.\d+)?:/gi;
const phasePattern = /#{2,3}\s*Phase\s+(\d+)(?:\.\d+)?:/gi;
let maxPhase = 0;
let m;
while ((m = phasePattern.exec(content)) !== null) {
@@ -2609,7 +2639,7 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
// Verify target phase exists
const afterPhaseEscaped = afterPhase.replace(/\./g, '\\.');
const targetPattern = new RegExp(`###\\s*Phase\\s+${afterPhaseEscaped}:`, 'i');
const targetPattern = new RegExp(`#{2,3}\\s*Phase\\s+${afterPhaseEscaped}:`, 'i');
if (!targetPattern.test(content)) {
error(`Phase ${afterPhase} not found in ROADMAP.md`);
}
@@ -2641,7 +2671,7 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
const phaseEntry = `\n### Phase ${decimalPhase}: ${description} (INSERTED)\n\n**Goal:** [Urgent work - to be planned]\n**Depends on:** Phase ${afterPhase}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /gsd:plan-phase ${decimalPhase} to break down)\n`;
// Insert after the target phase section
const headerPattern = new RegExp(`(###\\s*Phase\\s+${afterPhaseEscaped}:[^\\n]*\\n)`, 'i');
const headerPattern = new RegExp(`(#{2,3}\\s*Phase\\s+${afterPhaseEscaped}:[^\\n]*\\n)`, 'i');
const headerMatch = content.match(headerPattern);
if (!headerMatch) {
error(`Could not find Phase ${afterPhase} header`);
@@ -2649,7 +2679,7 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
const headerIdx = content.indexOf(headerMatch[0]);
const afterHeader = content.slice(headerIdx + headerMatch[0].length);
const nextPhaseMatch = afterHeader.match(/\n###\s+Phase\s+\d/i);
const nextPhaseMatch = afterHeader.match(/\n#{2,3}\s+Phase\s+\d/i);
let insertIdx;
if (nextPhaseMatch) {
@@ -2832,7 +2862,7 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
// Remove the target phase section
const targetEscaped = targetPhase.replace(/\./g, '\\.');
const sectionPattern = new RegExp(
`\\n?###\\s*Phase\\s+${targetEscaped}\\s*:[\\s\\S]*?(?=\\n###\\s+Phase\\s+\\d|$)`,
`\\n?#{2,3}\\s*Phase\\s+${targetEscaped}\\s*:[\\s\\S]*?(?=\\n#{2,3}\\s+Phase\\s+\\d|$)`,
'i'
);
roadmapContent = roadmapContent.replace(sectionPattern, '');
@@ -2858,9 +2888,9 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
const oldPad = oldStr.padStart(2, '0');
const newPad = newStr.padStart(2, '0');
// Phase headings: ### Phase 18: → ### Phase 17:
// Phase headings: ## Phase 18: or ### Phase 18: → ## Phase 17: or ### Phase 17:
roadmapContent = roadmapContent.replace(
new RegExp(`(###\\s*Phase\\s+)${oldStr}(\\s*:)`, 'gi'),
new RegExp(`(#{2,3}\\s*Phase\\s+)${oldStr}(\\s*:)`, 'gi'),
`$1${newStr}$2`
);
@@ -2971,7 +3001,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
// Update plan count in phase section
const planCountPattern = new RegExp(
`(###\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`,
`(#{2,3}\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`,
'i'
);
roadmapContent = roadmapContent.replace(
@@ -3206,7 +3236,7 @@ function cmdValidateConsistency(cwd, raw) {
// Extract phases from ROADMAP
const roadmapPhases = new Set();
const phasePattern = /###\s*Phase\s+(\d+(?:\.\d+)?)\s*:/gi;
const phasePattern = /#{2,3}\s*Phase\s+(\d+(?:\.\d+)?)\s*:/gi;
let m;
while ((m = phasePattern.exec(roadmapContent)) !== null) {
roadmapPhases.add(m[1]);

View File

@@ -520,6 +520,50 @@ This phase covers:
assert.strictEqual(output.found, false, 'should return not found');
assert.strictEqual(output.error, 'ROADMAP.md not found', 'should explain why');
});
test('accepts ## phase headers (two hashes)', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap v1.0
## Phase 1: Foundation
**Goal:** Set up project infrastructure
**Plans:** 2 plans
## Phase 2: API
**Goal:** Build REST API
`
);
const result = runGsdTools('roadmap get-phase 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.found, true, 'phase with ## header should be found');
assert.strictEqual(output.phase_name, 'Foundation', 'phase name extracted');
assert.strictEqual(output.goal, 'Set up project infrastructure', 'goal extracted');
});
test('detects malformed ROADMAP with summary list but no detail sections', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap v1.0
## Phases
- [ ] **Phase 1: Foundation** - Set up project
- [ ] **Phase 2: API** - Build REST API
`
);
const result = runGsdTools('roadmap get-phase 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.found, false, 'phase should not be found');
assert.strictEqual(output.error, 'malformed_roadmap', 'should identify malformed roadmap');
assert.ok(output.message.includes('missing'), 'should explain the issue');
});
});
// ─────────────────────────────────────────────────────────────────────────────