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
This commit is contained in:
5
.changeset/tidy-herons-dance.md
Normal file
5
.changeset/tidy-herons-dance.md
Normal file
@@ -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.
|
||||
@@ -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<string, unknown>;
|
||||
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', () => {
|
||||
|
||||
@@ -99,6 +99,19 @@ function computeExpectedPhaseDirName(
|
||||
return `${prefix}${paddedNum}-${slug}`;
|
||||
}
|
||||
|
||||
async function shouldDropArchivedPhaseMatch(
|
||||
phaseInfo: Record<string, unknown> | null,
|
||||
roadmapPhase: Record<string, unknown> | null,
|
||||
projectDir: string,
|
||||
workstream?: string,
|
||||
): Promise<boolean> {
|
||||
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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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,
|
||||
|
||||
@@ -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',
|
||||
'---',
|
||||
'<objective>',
|
||||
'Archived milestone plan.',
|
||||
'</objective>',
|
||||
'<tasks>',
|
||||
'<task type=\"auto\">',
|
||||
' <name>Task 1</name>',
|
||||
'</task>',
|
||||
'</tasks>',
|
||||
].join('\n'));
|
||||
await writeFile(join(archiveDir, '02-01-SUMMARY.md'), '# Summary\n');
|
||||
|
||||
const result = await phasePlanIndex(['2'], tmpDir);
|
||||
const data = result.data as Record<string, unknown>;
|
||||
const plans = data.plans as Array<Record<string, unknown>>;
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -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<string, unknown> | null;
|
||||
const relDir = foundData?.directory;
|
||||
if (foundData?.found && typeof relDir === 'string' && relDir.trim() !== '') {
|
||||
phaseDir = join(projectDir, relDir);
|
||||
}
|
||||
}
|
||||
|
||||
if (!phaseDir) {
|
||||
return {
|
||||
data: {
|
||||
|
||||
Reference in New Issue
Block a user