From 409295b450e4085369e1a92951ccb2074403da34 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 13 May 2026 19:25:33 -0400 Subject: [PATCH] fix(sdk): preserve same-milestone archived phase handling (#3480) * fix(sdk): preserve same-milestone archived phase resolution (#3469) * chore(changeset): add #3480 fragment * fix(sdk): guard roadmapPhase null narrowing in initPhaseOp * fix(sdk): use explicit roadmapPhase narrowing in archived guard --- .changeset/tidy-herons-dance.md | 5 ++++ sdk/src/query/init.test.ts | 49 +++++++++++++++++++++++++++++++++ sdk/src/query/init.ts | 19 +++++++++++-- sdk/src/query/phase.test.ts | 30 ++++++++++++++++++++ sdk/src/query/phase.ts | 9 ++++++ 5 files changed, 109 insertions(+), 3 deletions(-) create mode 100644 .changeset/tidy-herons-dance.md diff --git a/.changeset/tidy-herons-dance.md b/.changeset/tidy-herons-dance.md new file mode 100644 index 000000000..03ac678ae --- /dev/null +++ b/.changeset/tidy-herons-dance.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3480 +--- +**SDK phase init/index queries now preserve same-milestone archived phase directories** — init and phase-plan-index no longer drop valid `milestones/vX-phases/*` matches as missing. diff --git a/sdk/src/query/init.test.ts b/sdk/src/query/init.test.ts index 7343854cb..16bb97a00 100644 --- a/sdk/src/query/init.test.ts +++ b/sdk/src/query/init.test.ts @@ -358,6 +358,55 @@ describe('initExecutePhase', () => { expect(data.branching_strategy).toBe('phase'); expect(typeof data.branch_name).toBe('string'); }); + + it('keeps same-milestone archived phase directory instead of nulling it (#3469)', async () => { + const tmp = await mkdtemp(join(tmpdir(), 'gsd-init-3469-')); + try { + await mkdir(join(tmp, '.planning', 'milestones', 'v2.0-phases', '02-auth'), { recursive: true }); + await writeFile(join(tmp, '.planning', 'PROJECT.md'), '# Project\n\n## What This Is\n\nA project.\n\n## Core Value\n\nValue here.\n\n## Requirements\n\n- Req 1\n'); + await writeFile(join(tmp, '.planning', 'ROADMAP.md'), [ + '# Roadmap', + '', + '## v2.0: Current', + '', + '### Phase 2: Auth', + '', + '**Goal:** Implement auth', + '', + ].join('\n')); + await writeFile(join(tmp, '.planning', 'STATE.md'), [ + '---', + 'milestone: v2.0', + 'status: executing', + '---', + '', + '# Session State', + ].join('\n')); + await writeFile(join(tmp, '.planning', 'config.json'), JSON.stringify({ + model_profile: 'balanced', + commit_docs: false, + git: { + branching_strategy: 'none', + phase_branch_template: 'gsd/phase-{phase}-{slug}', + milestone_branch_template: 'gsd/{milestone}-{slug}', + quick_branch_template: null, + }, + workflow: { research: true, plan_check: true, verifier: true, nyquist_validation: true }, + })); + await writeFile( + join(tmp, '.planning', 'milestones', 'v2.0-phases', '02-auth', '02-01-PLAN.md'), + '# Plan\n', + ); + + const result = await initExecutePhase(['2'], tmp); + const data = result.data as Record; + expect(data.phase_found).toBe(true); + expect(data.phase_dir).toBe('.planning/milestones/v2.0-phases/02-auth'); + expect(data.plan_count).toBe(1); + } finally { + await rm(tmp, { recursive: true, force: true }); + } + }); }); describe('initPlanPhase', () => { diff --git a/sdk/src/query/init.ts b/sdk/src/query/init.ts index 5c7e29959..aa071bcc8 100644 --- a/sdk/src/query/init.ts +++ b/sdk/src/query/init.ts @@ -99,6 +99,19 @@ function computeExpectedPhaseDirName( return `${prefix}${paddedNum}-${slug}`; } +async function shouldDropArchivedPhaseMatch( + phaseInfo: Record | null, + roadmapPhase: Record | null, + projectDir: string, + workstream?: string, +): Promise { + if (!phaseInfo?.archived || !roadmapPhase || !roadmapPhase.found) return false; + const archivedTag = String(phaseInfo.archived ?? ''); + const milestone = await getMilestoneInfo(projectDir, workstream); + if (milestone?.version && archivedTag === milestone.version) return false; + return true; +} + /** * Get the latest completed milestone from MILESTONES.md. * Port of getLatestCompletedMilestone from init.cjs lines 10-25. @@ -169,7 +182,7 @@ async function getPhaseInfoWithFallback( const roadmapPhase = roadmapResult.data as Record | null; // Match init.cjs: drop archived disk match when the phase is listed in the current ROADMAP - if (phaseInfo?.archived && roadmapPhase?.found) { + if (await shouldDropArchivedPhaseMatch(phaseInfo, roadmapPhase, projectDir, workstream)) { phaseInfo = null; } @@ -212,7 +225,7 @@ async function getPhaseInfoForVerifyWork( const roadmapResult = await roadmapGetPhase([phase], projectDir, workstream); const roadmapPhase = roadmapResult.data as Record | null; - if (phaseInfo?.archived && roadmapPhase?.found) { + if (await shouldDropArchivedPhaseMatch(phaseInfo, roadmapPhase, projectDir, workstream)) { phaseInfo = null; } @@ -694,7 +707,7 @@ export const initPhaseOp: QueryHandler = async (args, projectDir, workstream) => const roadmapPhase = roadmapResult.data as Record | null; // If the only match comes from an archived milestone, prefer current ROADMAP - if (phaseInfo?.archived && roadmapPhase?.found) { + if (roadmapPhase?.found && await shouldDropArchivedPhaseMatch(phaseInfo, roadmapPhase, projectDir, workstream)) { const phaseName = roadmapPhase.phase_name as string; phaseInfo = { found: true, diff --git a/sdk/src/query/phase.test.ts b/sdk/src/query/phase.test.ts index 2be0e4a59..108fd1022 100644 --- a/sdk/src/query/phase.test.ts +++ b/sdk/src/query/phase.test.ts @@ -307,6 +307,36 @@ describe('phasePlanIndex', () => { expect(data.plans).toEqual([]); }); + it('falls back to archived milestone directory when root phases dir has no match (#3469)', async () => { + const archiveDir = join(tmpDir, '.planning', 'milestones', 'v2.0-phases', '02-auth'); + await mkdir(archiveDir, { recursive: true }); + await writeFile(join(archiveDir, '02-01-PLAN.md'), [ + '---', + 'phase: 02', + 'plan: 01', + 'wave: 1', + 'autonomous: true', + '---', + '', + 'Archived milestone plan.', + '', + '', + '', + ' Task 1', + '', + '', + ].join('\n')); + await writeFile(join(archiveDir, '02-01-SUMMARY.md'), '# Summary\n'); + + const result = await phasePlanIndex(['2'], tmpDir); + const data = result.data as Record; + const plans = data.plans as Array>; + + expect(data.error).toBeUndefined(); + expect(plans.length).toBe(1); + expect(plans[0].id).toBe('02-01'); + }); + // ── #3266 regression tests ───────────────────────────────────────────── it('#3266: wave 0 round-trip — plan with wave: 0 lands in waves["0"] with PlanInfo.wave === 0', async () => { diff --git a/sdk/src/query/phase.ts b/sdk/src/query/phase.ts index f42272f83..12e04e0f4 100644 --- a/sdk/src/query/phase.ts +++ b/sdk/src/query/phase.ts @@ -261,6 +261,15 @@ export const phasePlanIndex: QueryHandler = async (args, projectDir, workstream) } } catch { /* phases dir doesn't exist */ } + if (!phaseDir) { + const found = await findPhase([phase], projectDir, workstream); + const foundData = found.data as Record | null; + const relDir = foundData?.directory; + if (foundData?.found && typeof relDir === 'string' && relDir.trim() !== '') { + phaseDir = join(projectDir, relDir); + } + } + if (!phaseDir) { return { data: {