fix(sdk): state.prune current phase fallback handling (#3477)

* fix(sdk): derive state.prune phase from frontmatter fallbacks (#3471)

* chore(changeset): add #3477 fragment

* chore(sdk): clarify state.prune fallback guidance
This commit is contained in:
Tom Boucher
2026-05-13 19:24:07 -04:00
committed by GitHub
parent 639e4d603a
commit f4c4240fea
3 changed files with 92 additions and 2 deletions

View File

@@ -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.

View File

@@ -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<string, unknown>;
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<string, unknown>;
expect(data.pruned).toBe(false);
expect(typeof data.reason).toBe('string');
expect(String(data.reason)).toContain('Could not determine current phase');
});
});

View File

@@ -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<string, unknown>
: 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) {