- 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:
@@ -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
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user