diff --git a/sdk/package-lock.json b/sdk/package-lock.json index 81d09b38f..2c5feb86b 100644 --- a/sdk/package-lock.json +++ b/sdk/package-lock.json @@ -22,7 +22,7 @@ "vitest": "^3.1.1" }, "engines": { - "node": ">=20" + "node": ">=22.0.0" } }, "node_modules/@anthropic-ai/claude-agent-sdk": { diff --git a/sdk/src/query/phase-lifecycle.test.ts b/sdk/src/query/phase-lifecycle.test.ts index 9a4a0200d..bd2e422e5 100644 --- a/sdk/src/query/phase-lifecycle.test.ts +++ b/sdk/src/query/phase-lifecycle.test.ts @@ -1015,6 +1015,74 @@ describe('phaseComplete', () => { // By Phase table should have a row for phase 10 expect(state).toMatch(/\|\s*10\s*\|\s*3\s*\|/); }); + + it('does not overwrite plan checkbox when **Plans:** is on its own line (regression #2728)', async () => { + const { phaseComplete } = await import('./phase-lifecycle.js'); + + const roadmap = [ + '# Roadmap', + '', + '## Current Milestone: v3.0', + '', + '- [ ] Phase 7: marketing-landing-v2', + '', + '### Phase 7: marketing-landing-v2', + '', + '**Goal:** Landing page', + '**Plans:**', + '- [x] 07-01-cherry-pick-foundation-PLAN.md — Wave 1', + '- [x] 07-02-routing-auth-seo-PLAN.md — Wave 2', + '', + '### Phase 8: p3-nice-to-haves', + '', + '**Goal:** Nice to haves', + '**Plans:** 3 plans', + '', + ].join('\n'); + + const state = [ + '---', + 'gsd_state_version: 1.0', + 'milestone: v3.0', + 'status: executing', + 'progress:', + ' total_phases: 2', + ' completed_phases: 0', + ' total_plans: 4', + ' completed_plans: 2', + ' percent: 50', + '---', + '', + '# Project State', + '', + 'Phase: 7 of 2 — EXECUTING', + 'Status: Executing Phase 7', + ].join('\n'); + + await setupTestProject(tmpDir, { + roadmap, + state, + phases: ['07-marketing-landing-v2', '08-p3-nice-to-haves'], + }); + + const p7Dir = join(tmpDir, '.planning', 'phases', '07-marketing-landing-v2'); + await writeFile(join(p7Dir, '07-01-PLAN.md'), 'plan1', 'utf-8'); + await writeFile(join(p7Dir, '07-02-PLAN.md'), 'plan2', 'utf-8'); + await writeFile(join(p7Dir, '07-01-SUMMARY.md'), 'summary1', 'utf-8'); + await writeFile(join(p7Dir, '07-02-SUMMARY.md'), 'summary2', 'utf-8'); + + await phaseComplete(['7'], tmpDir); + + const updated = await readFile(join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); + + // The plan lines must NOT be replaced with "N/N plans complete" + expect(updated).toContain('07-01-cherry-pick-foundation-PLAN.md'); + expect(updated).toContain('07-02-routing-auth-seo-PLAN.md'); + expect(updated).not.toMatch(/^2\/2 plans complete/m); + + // Phase 8's **Plans:** line must NOT be touched + expect(updated).toContain('**Plans:** 3 plans'); + }); }); // ─── phasesClear ──────────────────────────────────────────────────────────── diff --git a/sdk/src/query/phase-lifecycle.ts b/sdk/src/query/phase-lifecycle.ts index 5211c46a6..c02e81ee2 100644 --- a/sdk/src/query/phase-lifecycle.ts +++ b/sdk/src/query/phase-lifecycle.ts @@ -1194,7 +1194,7 @@ export const phaseComplete: QueryHandler = async (args, projectDir, workstream) // Update plan count in phase section const planCountPattern = new RegExp( - `(#{2,4}\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`, + `(#{2,4}\\s*Phase\\s+${phaseEscaped}(?:(?!\\n#{2,4})[\\s\\S])*?\\*\\*Plans:\\*\\*[ \\t]*)[^\\n]+`, 'i', ); roadmapContent = replaceInCurrentMilestone( diff --git a/sdk/src/query/roadmap-update-plan-progress.test.ts b/sdk/src/query/roadmap-update-plan-progress.test.ts new file mode 100644 index 000000000..90a112d2e --- /dev/null +++ b/sdk/src/query/roadmap-update-plan-progress.test.ts @@ -0,0 +1,192 @@ +/** + * Unit tests for roadmap.update-plan-progress query handler. + * + * Focuses on the planCountPattern regex fix: when **Plans:** is on its own + * line (followed by a bullet list), the handler must NOT overwrite the next + * line with "N/N plans complete/executed". + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, writeFile, readFile, mkdir, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +// ─── Helpers ────────────────────────────────────────────────────────────── + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'gsd-roadmap-update-')); +}); + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); +}); + +async function setupProject(opts: { + roadmap: string; + phaseDir: string; + plans?: string[]; + summaries?: string[]; +}) { + const planningDir = join(tmpDir, '.planning'); + const phasesDir = join(planningDir, 'phases'); + const phaseFullDir = join(phasesDir, opts.phaseDir); + + const phaseNum = parseInt(opts.phaseDir, 10) || 1; + + await mkdir(phaseFullDir, { recursive: true }); + await writeFile(join(planningDir, 'ROADMAP.md'), opts.roadmap, 'utf-8'); + await writeFile( + join(planningDir, 'STATE.md'), + [ + '---', + 'gsd_state_version: 1.0', + 'milestone: v3.0', + 'status: executing', + '---', + '', + '# Project State', + '', + `Phase: ${phaseNum} of 2 — EXECUTING`, + ].join('\n'), + 'utf-8', + ); + await writeFile( + join(planningDir, 'config.json'), + JSON.stringify({ model_profile: 'balanced', phase_naming: 'sequential' }), + 'utf-8', + ); + + for (const plan of opts.plans ?? []) { + await writeFile(join(phaseFullDir, plan), 'plan content', 'utf-8'); + } + for (const summary of opts.summaries ?? []) { + await writeFile(join(phaseFullDir, summary), 'summary content', 'utf-8'); + } + + return { roadmapPath: join(planningDir, 'ROADMAP.md') }; +} + +// ─── planCountPattern regression: **Plans:** on its own line ───────────── + +describe('roadmapUpdatePlanProgress', () => { + 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'); + + const roadmap = [ + '# Roadmap', + '', + '## Current Milestone: v3.0', + '', + '- [ ] Phase 7: marketing-landing-v2', + '', + '### Phase 7: marketing-landing-v2', + '', + '**Goal:** Landing page', + '**Plans:**', + '- [x] 07-01-cherry-pick-foundation-PLAN.md — Wave 1', + '- [ ] 07-02-routing-auth-seo-PLAN.md — Wave 2', + '', + '### Phase 8: p3-nice-to-haves', + '', + '**Goal:** Nice to haves', + '**Plans:** 3 plans', + '', + ].join('\n'); + + const { roadmapPath } = await setupProject({ + roadmap, + phaseDir: '07-marketing-landing-v2', + plans: ['07-01-PLAN.md', '07-02-PLAN.md'], + summaries: ['07-01-SUMMARY.md'], + }); + + await roadmapUpdatePlanProgress(['7'], tmpDir, undefined); + + const updated = await readFile(roadmapPath, 'utf-8'); + + // The bullet list lines must survive intact — not replaced by "N/N plans ..." + expect(updated).toContain('07-01-cherry-pick-foundation-PLAN.md'); + expect(updated).toContain('07-02-routing-auth-seo-PLAN.md'); + // The replacement text must not appear at the start of a line + expect(updated).not.toMatch(/^1\/2 plans/m); + + // Phase 8's **Plans:** line must NOT be touched (cross-section boundary guard) + expect(updated).toContain('**Plans:** 3 plans'); + }); + + it('updates inline **Plans:** count when it is on the same line as existing text', async () => { + const { roadmapUpdatePlanProgress } = await import('./roadmap-update-plan-progress.js'); + + const roadmap = [ + '# Roadmap', + '', + '## Current Milestone: v3.0', + '', + '- [ ] Phase 9: foundation', + '', + '### Phase 9: foundation', + '', + '**Goal:** Build foundation', + '**Plans:** 0 plans', + '', + ].join('\n'); + + const { roadmapPath } = await setupProject({ + roadmap, + phaseDir: '09-foundation', + plans: ['09-01-PLAN.md', '09-02-PLAN.md'], + summaries: ['09-01-SUMMARY.md'], + }); + + await roadmapUpdatePlanProgress(['9'], tmpDir, undefined); + + const updated = await readFile(roadmapPath, 'utf-8'); + + // Inline count must be updated + expect(updated).toContain('**Plans:** 1/2 plans executed'); + // Original placeholder must be gone + expect(updated).not.toContain('**Plans:** 0 plans'); + }); + + it('does not cross section boundaries when searching for **Plans:**', async () => { + const { roadmapUpdatePlanProgress } = await import('./roadmap-update-plan-progress.js'); + + // Phase 9 has NO Plans: line; Phase 10 does. The regex must NOT match Phase 10's Plans: line + // when updating Phase 9. + const roadmap = [ + '# Roadmap', + '', + '## Current Milestone: v3.0', + '', + '- [ ] Phase 9: foundation', + '', + '### Phase 9: foundation', + '', + '**Goal:** Build foundation', + '', + '### Phase 10: queries', + '', + '**Goal:** Port queries', + '**Plans:** 5 plans', + '', + ].join('\n'); + + const { roadmapPath } = await setupProject({ + roadmap, + phaseDir: '09-foundation', + plans: ['09-01-PLAN.md'], + summaries: [], + }); + + await roadmapUpdatePlanProgress(['9'], tmpDir, undefined); + + const updated = await readFile(roadmapPath, 'utf-8'); + + // Phase 10's Plans: line must remain untouched + expect(updated).toContain('**Plans:** 5 plans'); + // Must not be rewritten to Phase 9's count + expect(updated).not.toContain('**Plans:** 0/1 plans'); + }); +}); diff --git a/sdk/src/query/roadmap-update-plan-progress.ts b/sdk/src/query/roadmap-update-plan-progress.ts index 8ced729d4..c46bf9af6 100644 --- a/sdk/src/query/roadmap-update-plan-progress.ts +++ b/sdk/src/query/roadmap-update-plan-progress.ts @@ -84,7 +84,7 @@ export const roadmapUpdatePlanProgress: QueryHandler = async (args, projectDir, }); const planCountPattern = new RegExp( - `(#{2,4}\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`, + `(#{2,4}\\s*Phase\\s+${phaseEscaped}(?:(?!\\n#{2,4})[\\s\\S])*?\\*\\*Plans:\\*\\*[ \\t]*)[^\\n]+`, 'i', ); const planCountText = isComplete