Compare commits

...

1 Commits

Author SHA1 Message Date
Gabriel Rodrigues Garcia
e6e33602c3 fix(init): ignore archived phases from prior milestones sharing a phase number (#2186)
When a new milestone reuses a phase number that exists in an archived
milestone (e.g., v2.0 Phase 2 while v1.0-phases/02-old-feature exists),
findPhaseInternal falls through to the archive and returns the old
phase. init plan-phase and init execute-phase then emitted archived
values for phase_dir, phase_slug, has_context, has_research, and
*_path fields, while phase_req_ids came from the current ROADMAP —
producing a silent inconsistency that pointed downstream agents at a
shipped phase from a previous milestone.

cmdInitPhaseOp already guarded against this (see lines 617-642);
apply the same guard in cmdInitPlanPhase, cmdInitExecutePhase, and
cmdInitVerifyWork: if findPhaseInternal returns an archived match
and the current ROADMAP.md has the phase, discard the archived
phaseInfo so the ROADMAP fallback path produces clean values.

Adds three regression tests covering plan-phase, execute-phase, and
verify-work under the shared-number scenario.
2026-04-13 10:59:11 -04:00
2 changed files with 100 additions and 0 deletions

View File

@@ -58,6 +58,16 @@ function cmdInitExecutePhase(cwd, phase, raw, options = {}) {
const roadmapPhase = getRoadmapPhaseInternal(cwd, phase);
// If findPhaseInternal matched an archived phase from a prior milestone, but
// the phase exists in the current milestone's ROADMAP.md, ignore the archive
// match — we are initializing a new phase in the current milestone that
// happens to share a number with an archived one. Without this, phase_dir,
// phase_slug and related fields would point at artifacts from a previous
// milestone.
if (phaseInfo?.archived && roadmapPhase?.found) {
phaseInfo = null;
}
// Fallback to ROADMAP.md if no phase directory exists yet
if (!phaseInfo && roadmapPhase?.found) {
const phaseName = roadmapPhase.phase_name;
@@ -181,6 +191,16 @@ function cmdInitPlanPhase(cwd, phase, raw, options = {}) {
const roadmapPhase = getRoadmapPhaseInternal(cwd, phase);
// If findPhaseInternal matched an archived phase from a prior milestone, but
// the phase exists in the current milestone's ROADMAP.md, ignore the archive
// match — we are planning a new phase in the current milestone that happens
// to share a number with an archived one. Without this, phase_dir,
// phase_slug, has_context and has_research would point at artifacts from a
// previous milestone.
if (phaseInfo?.archived && roadmapPhase?.found) {
phaseInfo = null;
}
// Fallback to ROADMAP.md if no phase directory exists yet
if (!phaseInfo && roadmapPhase?.found) {
const phaseName = roadmapPhase.phase_name;
@@ -552,6 +572,16 @@ function cmdInitVerifyWork(cwd, phase, raw) {
const config = loadConfig(cwd);
let phaseInfo = findPhaseInternal(cwd, phase);
// If findPhaseInternal matched an archived phase from a prior milestone, but
// the phase exists in the current milestone's ROADMAP.md, ignore the archive
// match — same pattern as cmdInitPhaseOp.
if (phaseInfo?.archived) {
const roadmapPhase = getRoadmapPhaseInternal(cwd, phase);
if (roadmapPhase?.found) {
phaseInfo = null;
}
}
// Fallback to ROADMAP.md if no phase directory exists yet
if (!phaseInfo) {
const roadmapPhase = getRoadmapPhaseInternal(cwd, phase);

View File

@@ -352,6 +352,76 @@ describe('init commands ROADMAP fallback when phase directory does not exist (#1
});
});
// ─────────────────────────────────────────────────────────────────────────────
// init ignores archived phases from prior milestones that share a phase number
// ─────────────────────────────────────────────────────────────────────────────
describe('init commands ignore archived phases from prior milestones sharing a number', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
// Current milestone ROADMAP has Phase 2 but no disk directory yet
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# v2.0 Roadmap\n\n### Phase 2: New Feature\n**Goal:** New v2.0 feature\n**Requirements**: NEW-01, NEW-02\n**Plans:** TBD\n'
);
// Prior milestone archive has a shipped Phase 2 with different slug and artifacts
const archivedDir = path.join(tmpDir, '.planning', 'milestones', 'v1.0-phases', '02-old-feature');
fs.mkdirSync(archivedDir, { recursive: true });
fs.writeFileSync(path.join(archivedDir, '2-CONTEXT.md'), '# OLD v1.0 Phase 2 context');
fs.writeFileSync(path.join(archivedDir, '2-RESEARCH.md'), '# OLD v1.0 Phase 2 research');
});
afterEach(() => {
cleanup(tmpDir);
});
test('init plan-phase prefers current ROADMAP entry over archived v1.0 phase of same number', () => {
const result = runGsdTools('init plan-phase 2', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_found, true);
assert.strictEqual(output.phase_name, 'New Feature',
'phase_name must come from current ROADMAP.md, not archived v1.0');
assert.strictEqual(output.phase_slug, 'new-feature');
assert.strictEqual(output.phase_dir, null,
'phase_dir must be null — current milestone has no directory yet');
assert.strictEqual(output.has_context, false,
'has_context must not inherit archived v1.0 artifacts');
assert.strictEqual(output.has_research, false,
'has_research must not inherit archived v1.0 artifacts');
assert.ok(!output.context_path,
'context_path must not point at archived v1.0 file');
assert.ok(!output.research_path,
'research_path must not point at archived v1.0 file');
assert.strictEqual(output.phase_req_ids, 'NEW-01, NEW-02');
});
test('init execute-phase prefers current ROADMAP entry over archived v1.0 phase of same number', () => {
const result = runGsdTools('init execute-phase 2', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_found, true);
assert.strictEqual(output.phase_name, 'New Feature');
assert.strictEqual(output.phase_slug, 'new-feature');
assert.strictEqual(output.phase_dir, null);
assert.strictEqual(output.phase_req_ids, 'NEW-01, NEW-02');
});
test('init verify-work prefers current ROADMAP entry over archived v1.0 phase of same number', () => {
const result = runGsdTools('init verify-work 2', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_found, true);
assert.strictEqual(output.phase_name, 'New Feature');
assert.strictEqual(output.phase_dir, null);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// cmdInitTodos (INIT-01)
// ─────────────────────────────────────────────────────────────────────────────