diff --git a/get-shit-done/bin/lib/roadmap.cjs b/get-shit-done/bin/lib/roadmap.cjs index 8e39e2f7e..799f2d8e9 100644 --- a/get-shit-done/bin/lib/roadmap.cjs +++ b/get-shit-done/bin/lib/roadmap.cjs @@ -51,6 +51,17 @@ function countPhasePlansAndSummaries(phaseDir) { }; } +function phaseMarkdownRegexSource(phaseNum) { + const stripped = String(phaseNum).replace(/^[A-Z]{1,6}-(?=\d)/i, ''); + const match = stripped.match(/^0*(\d+)([A-Z])?((?:\.\d+)*)$/i); + if (!match) return escapeRegex(phaseNum); + + const integer = match[1].replace(/^0+/, '') || '0'; + const letter = match[2] ? escapeRegex(match[2]) : ''; + const decimal = match[3] ? escapeRegex(match[3]) : ''; + return `0*${escapeRegex(integer)}${letter}${decimal}`; +} + /** * 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 @@ -341,11 +352,11 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) { // Wrap entire read-modify-write in lock to prevent concurrent corruption withPlanningLock(cwd, () => { let roadmapContent = fs.readFileSync(roadmapPath, 'utf-8'); - const phaseEscaped = escapeRegex(phaseNum); + const phasePattern = phaseMarkdownRegexSource(phaseNum); // Progress table row: update Plans/Status/Date columns (handles 4 or 5 column tables) const tableRowPattern = new RegExp( - `^(\\|\\s*${phaseEscaped}\\.?\\s[^|]*(?:\\|[^\\n]*))$`, + `^(\\|\\s*${phasePattern}\\.?\\s[^|]*(?:\\|[^\\n]*))$`, 'im' ); const dateField = isComplete ? ` ${today} ` : ' '; @@ -367,7 +378,7 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) { // Update plan count in phase detail section const planCountPattern = new RegExp( - `(#{2,4}\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`, + `(#{2,4}\\s*Phase\\s+${phasePattern}(?=[:\\s])[\\s\\S]*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`, 'i' ); const planCountText = isComplete @@ -378,7 +389,7 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) { // If complete: check checkbox if (isComplete) { const checkboxPattern = new RegExp( - `(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${phaseEscaped}[:\\s][^\\n]*)`, + `(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${phasePattern}[:\\s][^\\n]*)`, 'i' ); roadmapContent = replaceInCurrentMilestone(roadmapContent, checkboxPattern, `$1x$2 (completed ${today})`); diff --git a/sdk/src/query/roadmap-update-plan-progress.test.ts b/sdk/src/query/roadmap-update-plan-progress.test.ts index 90a112d2e..f70eb536d 100644 --- a/sdk/src/query/roadmap-update-plan-progress.test.ts +++ b/sdk/src/query/roadmap-update-plan-progress.test.ts @@ -71,6 +71,47 @@ async function setupProject(opts: { // ─── planCountPattern regression: **Plans:** on its own line ───────────── describe('roadmapUpdatePlanProgress', () => { + it('updates unpadded ROADMAP phase entries when called with a padded phase argument', async () => { + const { roadmapUpdatePlanProgress } = await import('./roadmap-update-plan-progress.js'); + + const roadmap = [ + '# Roadmap', + '', + '## Current Milestone: v3.0', + '', + '- [ ] **Phase 3: build** - build it', + '', + '### Phase 3: build', + '', + '**Goal:** Build it', + '**Plans:** 0 plans', + '- [ ] 03-01-PLAN.md', + '', + '## Progress', + '', + '| Phase | Plans Complete | Status | Completed |', + '|-------|----------------|--------|-----------|', + '| 3. build | 0/1 | Planned | |', + '', + ].join('\n'); + + const { roadmapPath } = await setupProject({ + roadmap, + phaseDir: '03-build', + plans: ['03-01-PLAN.md'], + summaries: ['03-01-SUMMARY.md'], + }); + + await roadmapUpdatePlanProgress(['03'], tmpDir, undefined); + + const updated = await readFile(roadmapPath, 'utf-8'); + + expect(updated).toMatch(/- \[x\] \*\*Phase 3: build\*\* - build it \(completed \d{4}-\d{2}-\d{2}\)/); + expect(updated).toContain('**Plans:** 1/1 plans complete'); + expect(updated).toMatch(/\| 3\. build \| 1\/1 \| Complete\s+\| \d{4}-\d{2}-\d{2} \|/); + expect(updated).toContain('- [x] 03-01-PLAN.md'); + }); + it('does not overwrite plan bullet list when **Plans:** is on its own line (regression #2728 propagation)', async () => { const { roadmapUpdatePlanProgress } = await import('./roadmap-update-plan-progress.js'); diff --git a/sdk/src/query/roadmap-update-plan-progress.ts b/sdk/src/query/roadmap-update-plan-progress.ts index 75068447f..5afa47115 100644 --- a/sdk/src/query/roadmap-update-plan-progress.ts +++ b/sdk/src/query/roadmap-update-plan-progress.ts @@ -14,6 +14,17 @@ import { escapeRegex, planningPaths } from './helpers.js'; import { GSDError, ErrorClassification } from '../errors.js'; import type { QueryHandler } from './utils.js'; +function phaseMarkdownRegexSource(phaseNum: string): string { + const stripped = String(phaseNum).replace(/^[A-Z]{1,6}-(?=\d)/i, ''); + const match = stripped.match(/^0*(\d+)([A-Z])?((?:\.\d+)*)$/i); + if (!match) return escapeRegex(phaseNum); + + const integer = match[1]!.replace(/^0+/, '') || '0'; + const letter = match[2] ? escapeRegex(match[2]) : ''; + const decimal = match[3] ? escapeRegex(match[3]) : ''; + return `0*${escapeRegex(integer)}${letter}${decimal}`; +} + export const roadmapUpdatePlanProgress: QueryHandler = async (args, projectDir, workstream) => { // Support --phase flag form in addition to positional (fixes #2796). // execute-phase.md:228 passes --phase so positional-only parsing silently @@ -78,10 +89,10 @@ export const roadmapUpdatePlanProgress: QueryHandler = async (args, projectDir, } await readModifyWriteRoadmapMd(projectDir, (roadmapContent) => { - const phaseEscaped = escapeRegex(phaseNum); + const phasePattern = phaseMarkdownRegexSource(phaseNum); const tableRowPattern = new RegExp( - `^(\\|\\s*${phaseEscaped}\\.?\\s[^|]*(?:\\|[^\\n]*))$`, + `^(\\|\\s*${phasePattern}\\.?\\s[^|]*(?:\\|[^\\n]*))$`, 'im', ); const dateField = isComplete ? ` ${today} ` : ' '; @@ -100,7 +111,7 @@ export const roadmapUpdatePlanProgress: QueryHandler = async (args, projectDir, }); const planCountPattern = new RegExp( - `(#{2,4}\\s*Phase\\s+${phaseEscaped}(?:(?!\\n#{2,4})[\\s\\S])*?\\*\\*Plans:\\*\\*[ \\t]*)[^\\n]+`, + `(#{2,4}\\s*Phase\\s+${phasePattern}(?=[:\\s])(?:(?!\\n#{2,4})[\\s\\S])*?\\*\\*Plans:\\*\\*[ \\t]*)[^\\n]+`, 'i', ); const planCountText = isComplete @@ -110,7 +121,7 @@ export const roadmapUpdatePlanProgress: QueryHandler = async (args, projectDir, if (isComplete) { const checkboxPattern = new RegExp( - `(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${phaseEscaped}[:\\s][^\\n]*)`, + `(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${phasePattern}[:\\s][^\\n]*)`, 'i', ); roadmapContent = replaceInCurrentMilestone( diff --git a/tests/roadmap.test.cjs b/tests/roadmap.test.cjs index 1e6420652..399b60a63 100644 --- a/tests/roadmap.test.cjs +++ b/tests/roadmap.test.cjs @@ -771,6 +771,41 @@ describe('roadmap update-plan-progress command', () => { assert.ok(roadmapContent.includes('1/1'), 'roadmap should contain updated plan count'); }); + test('updates unpadded ROADMAP phase entries when called with padded phase argument', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap + +- [ ] **Phase 3: Build** - description + +### Phase 3: Build +**Goal:** Test goal +**Plans:** 0 plans +- [ ] 03-01-PLAN.md + +## Progress + +| Phase | Plans Complete | Status | Completed | +|-------|----------------|--------|-----------| +| 3. Build | 0/1 | Planned | | +` + ); + + const p3 = path.join(tmpDir, '.planning', 'phases', '03-build'); + fs.mkdirSync(p3, { recursive: true }); + fs.writeFileSync(path.join(p3, '03-01-PLAN.md'), '# Plan 1'); + fs.writeFileSync(path.join(p3, '03-01-SUMMARY.md'), '# Summary 1'); + + const result = runGsdTools('roadmap update-plan-progress 03', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const roadmapContent = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); + assert.match(roadmapContent, /- \[x\] \*\*Phase 3: Build\*\* - description \(completed \d{4}-\d{2}-\d{2}\)/); + assert.ok(roadmapContent.includes('**Plans:** 1/1 plans complete'), 'phase detail plan count should be updated'); + assert.match(roadmapContent, /\| 3\. Build \| 1\/1 \| Complete\s+\| \d{4}-\d{2}-\d{2} \|/); + assert.ok(roadmapContent.includes('- [x] 03-01-PLAN.md'), 'completed plan checkbox should still be marked'); + }); + test('missing ROADMAP.md returns updated false', () => { // Create phase dir with plans and summaries but NO ROADMAP.md const p1 = path.join(tmpDir, '.planning', 'phases', '01-test'); @@ -858,4 +893,3 @@ describe('roadmap update-plan-progress command', () => { // ───────────────────────────────────────────────────────────────────────────── // phase add command // ───────────────────────────────────────────────────────────────────────────── -