diff --git a/get-shit-done/bin/gsd-tools.cjs b/get-shit-done/bin/gsd-tools.cjs index cd857cd67..1b752fd05 100755 --- a/get-shit-done/bin/gsd-tools.cjs +++ b/get-shit-done/bin/gsd-tools.cjs @@ -483,6 +483,12 @@ async function runCommand(command, args, cwd, raw, defaultValue) { } else if (subcommand === 'prune') { const { 'keep-recent': keepRecent, 'dry-run': dryRun } = parseNamedArgs(args, ['keep-recent'], ['dry-run']); state.cmdStatePrune(cwd, { keepRecent: keepRecent || '3', dryRun: !!dryRun }, raw); + } else if (subcommand === 'milestone-switch') { + // Bug #2630: reset STATE.md frontmatter + Current Position for new milestone. + // NB: the flag is `--milestone`, not `--version` — gsd-tools reserves + // `--version` as a globally-invalid help flag (see NEVER_VALID_FLAGS above). + const { milestone, name } = parseNamedArgs(args, ['milestone', 'name']); + state.cmdStateMilestoneSwitch(cwd, milestone, name, raw); } else { state.cmdStateLoad(cwd, raw); } diff --git a/get-shit-done/bin/lib/state.cjs b/get-shit-done/bin/lib/state.cjs index bf6d664a6..ab7d82716 100644 --- a/get-shit-done/bin/lib/state.cjs +++ b/get-shit-done/bin/lib/state.cjs @@ -1253,6 +1253,70 @@ function cmdStatePlannedPhase(cwd, phaseNumber, planCount, raw) { output({ updated, phase: phaseNumber, plan_count: planCount }, raw, updated.length > 0 ? 'true' : 'false'); } +/** + * Bug #2630: reset STATE.md for a new milestone cycle. + * Stomps frontmatter milestone/milestone_name/status/progress AND rewrites + * the Current Position body. Preserves Accumulated Context. + * Symmetric with the SDK `stateMilestoneSwitch` handler. + */ +function cmdStateMilestoneSwitch(cwd, version, name, raw) { + if (!version || !String(version).trim()) { + output({ error: 'milestone required (--milestone )' }, raw); + return; + } + const resolvedName = (name && String(name).trim()) || 'milestone'; + const statePath = planningPaths(cwd).state; + const today = new Date().toISOString().split('T')[0]; + + const lockPath = acquireStateLock(statePath); + try { + const content = fs.existsSync(statePath) ? fs.readFileSync(statePath, 'utf-8') : ''; + const existingFm = extractFrontmatter(content); + const body = stripFrontmatter(content); + + const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i; + const resetPositionBody = + `\nPhase: Not started (defining requirements)\n` + + `Plan: —\n` + + `Status: Defining requirements\n` + + `Last activity: ${today} — Milestone ${version} started\n\n`; + let newBody; + if (positionPattern.test(body)) { + newBody = body.replace(positionPattern, (_m, header) => `${header}${resetPositionBody}`); + } else { + const preface = body.trim().length > 0 ? body : '# Project State\n'; + newBody = `${preface.trimEnd()}\n\n## Current Position\n${resetPositionBody}`; + } + + const fm = { + gsd_state_version: existingFm.gsd_state_version || '1.0', + milestone: version, + milestone_name: resolvedName, + status: 'planning', + last_updated: new Date().toISOString(), + last_activity: today, + progress: { + total_phases: 0, + completed_phases: 0, + total_plans: 0, + completed_plans: 0, + percent: 0, + }, + }; + + const yamlStr = reconstructFrontmatter(fm); + const assembled = `---\n${yamlStr}\n---\n\n${newBody.replace(/^\n+/, '')}`; + atomicWriteFileSync(statePath, normalizeMd(assembled), 'utf-8'); + output( + { switched: true, version, name: resolvedName, status: 'planning' }, + raw, + 'true', + ); + } finally { + releaseStateLock(lockPath); + } +} + /** * Gate 1: Validate STATE.md against filesystem. * Returns { valid, warnings, drift } JSON. @@ -1644,6 +1708,7 @@ module.exports = { cmdStateValidate, cmdStateSync, cmdStatePrune, + cmdStateMilestoneSwitch, cmdSignalWaiting, cmdSignalResume, }; diff --git a/get-shit-done/workflows/new-milestone.md b/get-shit-done/workflows/new-milestone.md index cd51afe3a..a0ba51821 100644 --- a/get-shit-done/workflows/new-milestone.md +++ b/get-shit-done/workflows/new-milestone.md @@ -173,6 +173,19 @@ This document evolves at phase transitions and milestone boundaries. ## 5. Update STATE.md +Reset STATE.md frontmatter AND body atomically via the SDK. This writes the new +milestone version/name into the YAML frontmatter, resets `status` to +`planning`, zeroes `progress.*` counters, and rewrites the `## Current Position` +section to the new-milestone template. Accumulated Context (decisions, +blockers, todos) is preserved across the switch — symmetric with +`milestone.complete`. + +```bash +gsd-sdk query state.milestone-switch --milestone "v[X.Y]" --name "[Name]" +``` + +The resulting Current Position section looks like: + ```markdown ## Current Position @@ -182,7 +195,11 @@ Status: Defining requirements Last activity: [today] — Milestone v[X.Y] started ``` -Keep Accumulated Context section from previous milestone. +Bug #2630: a prior version of this workflow rewrote the Current Position body +manually but left the frontmatter pointing at the previous milestone, so every +downstream reader (`state.json`, `getMilestoneInfo`, progress bars) reported the +stale milestone until the first phase advance forced a resync. Always use the +SDK handler above — do not hand-edit STATE.md here. ## 6. Cleanup and Commit diff --git a/sdk/src/query/index.ts b/sdk/src/query/index.ts index a109d9487..67e6248db 100644 --- a/sdk/src/query/index.ts +++ b/sdk/src/query/index.ts @@ -32,6 +32,7 @@ import { stateRecordMetric, stateUpdateProgress, stateAddDecision, stateAddBlocker, stateResolveBlocker, stateRecordSession, stateSignalWaiting, stateSignalResume, stateValidate, stateSync, statePrune, + stateMilestoneSwitch, } from './state-mutation.js'; import { configSet, configSetModelProfile, configNewProject, configEnsureSection, @@ -133,6 +134,7 @@ export const QUERY_MUTATION_COMMANDS = new Set([ 'state.signal-resume', 'state signal-resume', 'state.sync', 'state sync', 'state.prune', 'state prune', + 'state.milestone-switch', 'state milestone-switch', 'frontmatter.set', 'frontmatter.merge', 'frontmatter.validate', 'frontmatter validate', 'config-set', 'config-set-model-profile', 'config-new-project', 'config-ensure-section', 'commit', 'check-commit', 'commit-to-subrepo', @@ -321,6 +323,8 @@ export function createRegistry( registry.register('state.validate', stateValidate); registry.register('state.sync', stateSync); registry.register('state.prune', statePrune); + registry.register('state.milestone-switch', stateMilestoneSwitch); + registry.register('state milestone-switch', stateMilestoneSwitch); registry.register('state signal-waiting', stateSignalWaiting); registry.register('state signal-resume', stateSignalResume); registry.register('state validate', stateValidate); diff --git a/sdk/src/query/state-mutation.test.ts b/sdk/src/query/state-mutation.test.ts index 1e91d0a92..46e1fe998 100644 --- a/sdk/src/query/state-mutation.test.ts +++ b/sdk/src/query/state-mutation.test.ts @@ -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; + 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 | 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; + expect(data.error).toBeDefined(); + }); +}); diff --git a/sdk/src/query/state-mutation.ts b/sdk/src/query/state-mutation.ts index a107b5461..5af4aebed 100644 --- a/sdk/src/query/state-mutation.ts +++ b/sdk/src/query/state-mutation.ts @@ -982,6 +982,124 @@ export const statePlannedPhase: QueryHandler = async (args, projectDir, workstre return { data: { updated, phase: phaseNumber, plan_count: planCount } }; }; +// ─── stateMilestoneSwitch (bug #2630) ───────────────────────────────────── + +/** + * Query handler for `state.milestone-switch` — resets STATE.md for a new + * milestone cycle (bug #2630 regression guard). + * + * The `/gsd:new-milestone` workflow only rewrote STATE.md's body (Current + * Position section). The YAML frontmatter (`milestone`, `milestone_name`, + * `status`, `progress.*`) was never touched on a mid-flight switch, so queries + * that read frontmatter (`state.json`, `getMilestoneInfo`, every handler that + * calls `buildStateFrontmatter`) kept reporting the old milestone and stale + * progress counters until the first phase advance forced a resync. + * + * This handler performs the reset atomically under the STATE.md lock: + * - Stomps frontmatter milestone/milestone_name with the caller-supplied + * values so `parseMilestoneFromState` reports the new milestone immediately. + * - Resets `status` to `'planning'` (workflow is at "Defining requirements"). + * - Resets `progress` counters to zero (new milestone, nothing executed yet). + * - Rewrites the `## Current Position` body to the new-milestone template so + * subsequent body-derived field extraction stays consistent with frontmatter. + * - Preserves Accumulated Context (decisions, todos, blockers) — symmetric + * with `milestone.complete` which also keeps history. + * + * Args (named, matches gsd-tools style): + * - `--version ` (required) + * - `--name ` (optional; defaults to 'milestone') + * + * Sibling CJS parity: `cmdInitNewMilestone` in `init.cjs` is read-only (like + * the TS `initNewMilestone`). The workflow-level fix is to call + * `state.milestone-switch` from `/gsd:new-milestone` Step 5 in place of the + * manual body rewrite. + */ +export const stateMilestoneSwitch: QueryHandler = async (args, projectDir, workstream) => { + // NOTE: the CLI flag is `--milestone` (not `--version`). gsd-tools reserves + // `--version` as a globally-invalid help flag, so the workflow invokes this + // handler with `--milestone vX.Y`. The internal variable is still `version` + // because the value is a milestone version string. + const parsed = parseNamedArgs(args, ['milestone', 'name']); + const version = (parsed.milestone as string | null)?.trim(); + const name = ((parsed.name as string | null) ?? 'milestone').trim() || 'milestone'; + + if (!version) { + return { data: { error: 'milestone required (--milestone )' } }; + } + + const today = new Date().toISOString().split('T')[0]!; + const statePath = planningPaths(projectDir, workstream).state; + const lockPath = await acquireStateLock(statePath); + + try { + let content = ''; + try { + content = await readFile(statePath, 'utf-8'); + } catch { /* STATE.md may not exist yet */ } + + const existingFm = extractFrontmatter(content); + const body = stripFrontmatter(content); + + // Reset Current Position section body so body-derived extraction stays + // consistent with the new frontmatter. + const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i; + const resetPositionBody = + `\nPhase: Not started (defining requirements)\n` + + `Plan: —\n` + + `Status: Defining requirements\n` + + `Last activity: ${today} — Milestone ${version} started\n\n`; + let newBody: string; + if (positionPattern.test(body)) { + newBody = body.replace(positionPattern, (_m, header: string) => `${header}${resetPositionBody}`); + } else { + // Preserve any existing body but prepend a Current Position section. + const preface = body.trim().length > 0 ? body : '# Project State\n'; + newBody = `${preface.trimEnd()}\n\n## Current Position\n${resetPositionBody}`; + } + + // Build fresh frontmatter explicitly — do NOT rely on buildStateFrontmatter + // here, because getMilestoneInfo reads the ON-DISK STATE.md and would + // return the OLD milestone until we write it first. This is the crux of + // bug #2630: any sync-based approach races against the very file it is + // about to rewrite. + const fm: Record = { + gsd_state_version: '1.0', + milestone: version, + milestone_name: name, + status: 'planning', + last_updated: new Date().toISOString(), + last_activity: today, + progress: { + total_phases: 0, + completed_phases: 0, + total_plans: 0, + completed_plans: 0, + percent: 0, + }, + }; + // Preserve frontmatter-only fields the caller may still care about + // (paused_at cleared deliberately — a new milestone is a fresh start). + if (existingFm.gsd_state_version) { + fm.gsd_state_version = existingFm.gsd_state_version; + } + + const yamlStr = reconstructFrontmatter(fm); + const assembled = `---\n${yamlStr}\n---\n\n${newBody.replace(/^\n+/, '')}`; + await writeFile(statePath, normalizeMd(assembled), 'utf-8'); + + return { + data: { + switched: true, + version, + name, + status: 'planning', + }, + }; + } finally { + await releaseStateLock(lockPath); + } +}; + // ─── parseNamedArgs (matches gsd-tools.cjs) ─────────────────────────────── function parseNamedArgs( diff --git a/tests/bug-2630-state-frontmatter-milestone-switch.test.cjs b/tests/bug-2630-state-frontmatter-milestone-switch.test.cjs new file mode 100644 index 000000000..099ecb42d --- /dev/null +++ b/tests/bug-2630-state-frontmatter-milestone-switch.test.cjs @@ -0,0 +1,119 @@ +/** + * GSD Tools Tests — Bug #2630 + * + * Regression guard: `state milestone-switch` resets STATE.md YAML frontmatter + * (milestone, milestone_name, status, progress.*) AND the `## Current Position` + * body in a single atomic write. Prior to the fix, the `/gsd:new-milestone` + * workflow rewrote the body but left the frontmatter pointing at the previous + * milestone, so every downstream reader (state.json, getMilestoneInfo, etc.) + * reported the stale milestone. + */ + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); + +const STALE_STATE = `--- +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 +`; + +describe('state milestone-switch (#2630)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + STALE_STATE, + 'utf-8', + ); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + '# Roadmap\n\n## v1.1 Notifications\n\n### Phase 6: Notify\n', + 'utf-8', + ); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + '{}', + 'utf-8', + ); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('writes new milestone into frontmatter and resets progress + Current Position', () => { + const result = runGsdTools( + ['state', 'milestone-switch', '--milestone', 'v1.1', '--name', 'Notifications'], + tmpDir, + ); + assert.equal(result.success, true, result.error || result.output); + + const after = fs.readFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + 'utf-8', + ); + + // Frontmatter reflects the NEW milestone — the core of bug #2630. + assert.match(after, /^milestone:\s*v1\.1\s*$/m, 'frontmatter milestone not switched'); + assert.match( + after, + /^milestone_name:\s*Notifications\s*$/m, + 'frontmatter milestone_name not switched', + ); + assert.match(after, /^status:\s*planning\s*$/m, 'status not reset to planning'); + // Progress counters reset to zero. + assert.match(after, /^\s*completed_phases:\s*0\s*$/m, 'completed_phases not reset'); + assert.match(after, /^\s*completed_plans:\s*0\s*$/m, 'completed_plans not reset'); + assert.match(after, /^\s*percent:\s*0\s*$/m, 'percent not reset'); + + // Body Current Position reset to the new-milestone template. + assert.match(after, /Status:\s*Defining requirements/, 'body Status not reset'); + assert.match( + after, + /Phase:\s*Not started \(defining requirements\)/, + 'body Phase not reset', + ); + + // Accumulated Context is preserved. + assert.match(after, /\[Phase 1\]:\s*Use Node 20/, 'Accumulated Context lost'); + }); + + test('rejects missing --milestone', () => { + const result = runGsdTools( + ['state', 'milestone-switch', '--name', 'Something'], + tmpDir, + ); + // gsd-tools emits JSON with { error: ... } to stdout even on error paths. + const combined = (result.output || '') + (result.error || ''); + assert.match(combined, /milestone required/i); + }); +});