diff --git a/.changeset/steady-bears-purr.md b/.changeset/steady-bears-purr.md new file mode 100644 index 000000000..38adfeae0 --- /dev/null +++ b/.changeset/steady-bears-purr.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3477 +--- +**`gsd-sdk query state.prune` now derives current phase from frontmatter fallbacks** — the command no longer reports a false 'Only 0 phases' when STATE.md omits the body Current Phase field. diff --git a/sdk/src/query/state-mutation.test.ts b/sdk/src/query/state-mutation.test.ts index 00e1dfaf7..bef63a2a1 100644 --- a/sdk/src/query/state-mutation.test.ts +++ b/sdk/src/query/state-mutation.test.ts @@ -1135,3 +1135,64 @@ Last activity: 2026-04-20 -- v1.0 shipped expect(data.error).toBeDefined(); }); }); + +describe('statePrune current phase extraction (#3471)', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'gsd-sdk-stateprune-')); + }); + + afterEach(async () => { + if (tmpDir) await rm(tmpDir, { recursive: true, force: true }); + }); + + it('uses frontmatter progress.completed_phases when body Current Phase field is absent', async () => { + const stateContent = `--- +gsd_state_version: 1.0 +milestone: v1.1 +status: executing +progress: + total_phases: 21 + completed_phases: 12 + total_plans: 79 + completed_plans: 75 + percent: 95 +--- + +# Session State + +## Current Position + +Phase 12 execution in progress. +`; + await setupTestProject(tmpDir, stateContent); + const { statePrune } = await import('./state-mutation.js'); + const result = await statePrune(['--keep-recent', '3', '--dry-run'], tmpDir); + const data = result.data as Record; + + expect(data.pruned).toBe(false); + expect(data.dry_run).toBe(true); + expect(data.cutoff_phase).toBe(9); + expect(data.reason).toBeUndefined(); + }); + + it('returns a targeted reason when no current phase source can be parsed', async () => { + const stateContent = `--- +gsd_state_version: 1.0 +milestone: v1.1 +status: executing +--- + +# Session State +`; + await setupTestProject(tmpDir, stateContent); + const { statePrune } = await import('./state-mutation.js'); + const result = await statePrune(['--keep-recent', '3', '--dry-run'], tmpDir); + const data = result.data as Record; + + expect(data.pruned).toBe(false); + expect(typeof data.reason).toBe('string'); + expect(String(data.reason)).toContain('Could not determine current phase'); + }); +}); diff --git a/sdk/src/query/state-mutation.ts b/sdk/src/query/state-mutation.ts index e34aa288a..8ba3f80c8 100644 --- a/sdk/src/query/state-mutation.ts +++ b/sdk/src/query/state-mutation.ts @@ -1621,8 +1621,32 @@ export const statePrune: QueryHandler = async (args, projectDir, workstream) => } const fullContent = await readFile(statePath, 'utf-8'); - const currentPhaseRaw = stateExtractField(fullContent, 'Current Phase'); - const currentPhase = parseInt(String(currentPhaseRaw ?? ''), 10) || 0; + const fm = extractFrontmatter(fullContent); + const fmProgress = (typeof fm.progress === 'object' && fm.progress !== null) + ? fm.progress as Record + : null; + const phaseCandidates: unknown[] = [ + fm.current_phase, + stateExtractField(fullContent, 'Current Phase'), + fmProgress?.completed_phases, + fmProgress?.total_phases, + ]; + let currentPhase: number | null = null; + for (const candidate of phaseCandidates) { + const parsed = parseInt(String(candidate ?? '').trim(), 10); + if (Number.isInteger(parsed) && parsed > 0) { + currentPhase = parsed; + break; + } + } + if (currentPhase === null) { + return { + data: { + pruned: false, + reason: 'Could not determine current phase from STATE.md. Add **Current Phase:** N, frontmatter current_phase: N, progress.completed_phases, or progress.total_phases.', + }, + }; + } const cutoff = currentPhase - keepRecent; if (cutoff <= 0) {