diff --git a/get-shit-done/bin/lib/state-command-router.cjs b/get-shit-done/bin/lib/state-command-router.cjs index 5aeec9b41..3e5e7f389 100644 --- a/get-shit-done/bin/lib/state-command-router.cjs +++ b/get-shit-done/bin/lib/state-command-router.cjs @@ -71,7 +71,8 @@ function routeStateCommand({ state, args, cwd, raw, parseNamedArgs, error }) { 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 === 'complete-phase') { - state.cmdStateCompletePhase(cwd, raw); + const { phase: p } = parseNamedArgs(args, ['phase']); + state.cmdStateCompletePhase(cwd, raw, p || args[2]); } else if (subcommand === 'milestone-switch') { const { milestone, name } = parseNamedArgs(args, ['milestone', 'name']); state.cmdStateMilestoneSwitch(cwd, milestone, name, raw); diff --git a/get-shit-done/bin/lib/state.cjs b/get-shit-done/bin/lib/state.cjs index 08692df79..7bfbd35fc 100644 --- a/get-shit-done/bin/lib/state.cjs +++ b/get-shit-done/bin/lib/state.cjs @@ -1690,32 +1690,36 @@ function cmdStatePrune(cwd, options, raw) { * that the phase execution is finished and the project is ready for the next phase. * Implements the `gsd state complete-phase` subcommand (issue #2735). */ -function cmdStateCompletePhase(cwd, raw) { +function resolvePhaseIdForCompletePhase(content, overridePhase) { + const candidate = overridePhase || + stateExtractField(content, 'Current Phase') || + stateExtractField(content, 'Phase') || + ''; + + // Accept canonical phase token only (e.g. 3, 03, 3A, 3.3, 10.2) + const phaseMatch = String(candidate).match(/(\d+[A-Z]?(?:\.\d+)*)/i); + return phaseMatch ? phaseMatch[1] : null; +} + +function cmdStateCompletePhase(cwd, raw, overridePhase) { const statePath = planningPaths(cwd).state; if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; } + const content = fs.readFileSync(statePath, 'utf-8'); + const resolvedPhase = resolvePhaseIdForCompletePhase(content, overridePhase); + if (!resolvedPhase || /^phase$/i.test(resolvedPhase)) { + output({ error: 'Unable to resolve current phase. Pass an explicit phase: state complete-phase --phase ' }, raw); + return; + } + const today = new Date().toISOString().split('T')[0]; const updated = []; - let resolvedPhase = '?'; readModifyWriteStateMd(statePath, (content) => { - // Read the current phase number for descriptive messages. - // - // The 'Phase' fallback can match the decorated body line under - // `## Current Position` (e.g. `Phase: 01 (Foo) — EXECUTING`), which would - // flow downstream into messy `Status: Phase 01 (Foo) — EXECUTING complete` - // output. Strip everything past the leading numeric/decimal token so the - // fallback path produces a clean phase identifier matching the canonical - // `Current Phase` field. CodeRabbit nitpick on PR #2761. - const rawPhase = stateExtractField(content, 'Current Phase') || - stateExtractField(content, 'Phase') || - ''; - const phaseToken = rawPhase.match(/^\s*([\w.-]+)/); - const currentPhase = phaseToken ? phaseToken[1] : '?'; - resolvedPhase = currentPhase; + const currentPhase = resolvedPhase; // Update Status field const statusValue = `Phase ${currentPhase} complete`; diff --git a/tests/state.test.cjs b/tests/state.test.cjs index 39f6a8187..e21b46375 100644 --- a/tests/state.test.cjs +++ b/tests/state.test.cjs @@ -2471,6 +2471,61 @@ describe('state complete-phase: decorated Phase fallback (#2761 nitpick)', () => `Status should reference canonical Current Phase (03), got: ${updated}`, ); }); + + test('rejects unresolved literal Phase token and does not corrupt STATE.md (#3063)', () => { + const stateMd = [ + '---', + 'milestone: v1.0', + '---', + '', + '# State', + '', + '**Status:** Executing', + '**Last Activity:** 2024-01-15', + '', + '## Current Position', + '', + 'Phase: narrative only', + '', + ].join('\n'); + const statePath = path.join(tmpDir, '.planning', 'STATE.md'); + fs.writeFileSync(statePath, stateMd); + + const result = runGsdTools('state complete-phase', tmpDir); + assert.ok(result.success, 'command should return JSON error payload, not crash'); + const output = JSON.parse(result.output); + assert.ok(output.error, 'expected clear resolution error'); + + const after = fs.readFileSync(statePath, 'utf-8'); + assert.ok(!after.includes('Phase: Phase — COMPLETE')); + assert.ok(!after.includes('Status: Phase Phase complete')); + }); + + test('supports explicit phase override for complete-phase disambiguation (#3063)', () => { + const stateMd = [ + '---', + 'milestone: v1.0', + '---', + '', + '# State', + '', + '**Status:** Executing', + '**Last Activity:** 2024-01-15', + '', + '## Current Position', + '', + 'Phase: narrative only', + '', + ].join('\n'); + const statePath = path.join(tmpDir, '.planning', 'STATE.md'); + fs.writeFileSync(statePath, stateMd); + + const result = runGsdTools('state complete-phase --phase 3.3', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const after = fs.readFileSync(statePath, 'utf-8'); + assert.ok(after.includes('**Status:** Phase 3.3 complete')); + }); }); // ─────────────────────────────────────────────────────────────────────────────