fix(#2630): reset STATE.md frontmatter atomically on milestone switch (#2666)

The /gsd:new-milestone workflow Step 5 rewrote STATE.md's Current Position
body but never touched the YAML frontmatter, so every downstream reader
(state.json, getMilestoneInfo, progress bars) kept reporting the stale
milestone until the first phase advance forced a resync. Asymmetric with
milestone.complete, which uses readModifyWriteStateMdFull.

Add a new `state milestone-switch` handler (both SDK and CJS) that atomically:
- Stomps frontmatter milestone/milestone_name with caller-supplied values
- Resets status to 'planning' and progress counters to zero
- Rewrites the ## Current Position section to the new-milestone template
- Preserves Accumulated Context (decisions, blockers, todos)

Wire the workflow Step 5 to invoke `state.milestone-switch` instead of the
manual body rewrite. Note the flag is `--milestone` not `--version`:
gsd-tools reserves `--version` as a globally-invalid help flag.

Red vitest in sdk/src/query/state-mutation.test.ts asserts the frontmatter
reset. Regression guard via node:test in tests/bug-2630-*.test.cjs runs
through gsd-tools end-to-end.

Fixes #2630

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-04-24 18:05:10 -04:00
committed by GitHub
parent 8caa7d4c3a
commit e973ff4cb6
7 changed files with 435 additions and 1 deletions

View File

@@ -666,3 +666,108 @@ Resume file: None
expect(Number(progress.percent)).toBe(100);
});
});
// ─── stateMilestoneSwitch (#2630) ──────────────────────────────────────────
describe('stateMilestoneSwitch', () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'gsd-milestone-switch-'));
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
it('writes milestone/milestone_name/status into STATE.md frontmatter and resets progress on milestone switch', async () => {
// Previous milestone shipped: STATE.md frontmatter points at v1.0 with
// non-zero progress. ROADMAP.md now advertises the NEW milestone v1.1.
// Regardless of what getMilestoneInfo derives from the old STATE.md
// frontmatter, a milestone switch must stomp the frontmatter with the new
// version/name and reset progress counters.
const stateContent = `---
gsd_state_version: 1.0
milestone: v1.0
milestone_name: Foundation
status: completed
progress:
total_phases: 5
completed_phases: 5
total_plans: 12
completed_plans: 12
percent: 100
---
# Project State
## Current Position
Phase: 5 (Foundation) — COMPLETED
Plan: 3 of 3
Status: v1.0 milestone complete
Last activity: 2026-04-20 -- v1.0 shipped
## Accumulated Context
### Decisions
- [Phase 1]: Use Node 20
`;
const planningDir = join(tmpDir, '.planning');
await mkdir(join(planningDir, 'phases'), { recursive: true });
await writeFile(join(planningDir, 'STATE.md'), stateContent, 'utf-8');
// ROADMAP advertises the new milestone
await writeFile(
join(planningDir, 'ROADMAP.md'),
'# Roadmap\n\n## v1.1 Notifications\n\n### Phase 6: Notify\n',
'utf-8',
);
await writeFile(join(planningDir, 'config.json'), '{}', 'utf-8');
const { stateMilestoneSwitch } = await import('./state-mutation.js');
const result = await stateMilestoneSwitch(
['--milestone', 'v1.1', '--name', 'Notifications'],
tmpDir,
);
const data = result.data as Record<string, unknown>;
expect(data.switched).toBe(true);
expect(data.version).toBe('v1.1');
expect(data.name).toBe('Notifications');
const after = await readFile(join(planningDir, 'STATE.md'), 'utf-8');
const { extractFrontmatter } = await import('./frontmatter.js');
const fm = extractFrontmatter(after);
// The heart of #2630 — frontmatter must reflect the NEW milestone.
expect(fm.milestone).toBe('v1.1');
expect(fm.milestone_name).toBe('Notifications');
// Status resets to planning (Defining requirements phase).
expect(fm.status).toBe('planning');
// Progress counters reset for the new milestone (no phases executed yet).
const progress = fm.progress as Record<string, unknown> | undefined;
if (progress) {
expect(Number(progress.completed_phases ?? 0)).toBe(0);
expect(Number(progress.completed_plans ?? 0)).toBe(0);
expect(Number(progress.percent ?? 0)).toBe(0);
}
// Accumulated Context is preserved across the milestone switch.
expect(after).toContain('[Phase 1]: Use Node 20');
// Current Position body is reset to the new milestone's starting state.
expect(after).toMatch(/Status:\s*Defining requirements/);
});
it('rejects missing --milestone', async () => {
await writeFile(join(tmpDir, '.planning', 'config.json'), '{}', 'utf-8').catch(async () => {
await mkdir(join(tmpDir, '.planning'), { recursive: true });
await writeFile(join(tmpDir, '.planning', 'config.json'), '{}', 'utf-8');
});
const { stateMilestoneSwitch } = await import('./state-mutation.js');
const result = await stateMilestoneSwitch([], tmpDir);
const data = result.data as Record<string, unknown>;
expect(data.error).toBeDefined();
});
});