diff --git a/.changeset/1316-state-prose-phase-name.md b/.changeset/1316-state-prose-phase-name.md new file mode 100644 index 000000000..1757416e1 --- /dev/null +++ b/.changeset/1316-state-prose-phase-name.md @@ -0,0 +1,6 @@ +--- +type: Fixed +pr: 1351 +--- + +**`phase complete` now preserves prose-block STATE phase names** — template-shaped `Current Position` prose now advances with the next phase name, avoids missing-field warnings, and keeps `Last activity:` on the template em-dash delimiter. (#1316) diff --git a/src/phase.cts b/src/phase.cts index 576c1ccc6..832f0444e 100644 --- a/src/phase.cts +++ b/src/phase.cts @@ -1342,6 +1342,21 @@ function writePlanningFileSet(writes: WriteSpec[]): void { } } +function phaseDisplayNameFromRoadmap(roadmapContent: string | null, phaseNum: string | null): string | null { + if (!roadmapContent || !phaseNum) return null; + const phaseEscaped = phaseMarkdownRegexSource(phaseNum); + const heading = roadmapContent.match(new RegExp(`^#{2,4}\\s*Phase\\s+${phaseEscaped}\\s*:\\s*([^\\n]+)`, 'im')); + if (!heading) return null; + const name = heading[1].replace(/\(INSERTED\)/i, '').trim(); + return name || null; +} + +function phaseDisplayNameFromSlug(slug: string | null): string | null { + if (!slug) return null; + const name = slug.replace(/-/g, ' ').trim(); + return name || null; +} + function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void { if (!phaseNum) { error('phase number required for phase complete'); @@ -1642,6 +1657,9 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void { let stateContent = originalStateContent; const phaseValue = nextPhaseNum || phaseNum; + const nextPhaseDisplayName = + phaseDisplayNameFromRoadmap(roadmapContent, nextPhaseNum) ?? + phaseDisplayNameFromSlug(nextPhaseName); const existingPhaseField = stateExtractField(stateContent, 'Current Phase') || stateExtractField(stateContent, 'Phase'); @@ -1651,12 +1669,14 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void { const nameMatch = existingPhaseField.match(/\(([^)]+)\)/); if (totalMatch) { const total = totalMatch[1]; - const nameStr = nextPhaseName - ? ` (${nextPhaseName.replace(/-/g, ' ')})` + const nameStr = nextPhaseDisplayName + ? ` (${nextPhaseDisplayName})` : nameMatch ? ` (${nameMatch[1]})` : ''; newPhaseValue = `${phaseValue} of ${total}${nameStr}`; + } else if (nextPhaseDisplayName) { + newPhaseValue = `${phaseValue} — ${nextPhaseDisplayName}`; } } stateContent = stateReplaceFieldWithFallback( @@ -1666,13 +1686,10 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void { newPhaseValue, ); - if (nextPhaseName) { - stateContent = stateReplaceFieldWithFallback( - stateContent, - 'Current Phase Name', - null, - nextPhaseName.replace(/-/g, ' '), - ); + if (nextPhaseDisplayName) { + stateContent = + stateReplaceField(stateContent, 'Current Phase Name', nextPhaseDisplayName) || + stateContent; } stateContent = stateReplaceFieldWithFallback( @@ -1689,19 +1706,20 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void { 'Not started', ); - stateContent = stateReplaceFieldWithFallback( - stateContent, - 'Last Activity', - 'Last activity', - today, - ); + const lastActivityDescription = `Phase ${phaseNum} complete${nextPhaseNum ? `, transitioned to Phase ${nextPhaseNum}` : ''}`; + if (/^Last activity:/m.test(stateContent)) { + stateContent = + stateReplaceField(stateContent, 'Last activity', `${today} — ${lastActivityDescription}`) || + stateContent; + } else { + stateContent = + stateReplaceField(stateContent, 'Last Activity', today) || + stateContent; + } - stateContent = stateReplaceFieldWithFallback( - stateContent, - 'Last Activity Description', - null, - `Phase ${phaseNum} complete${nextPhaseNum ? `, transitioned to Phase ${nextPhaseNum}` : ''}`, - ); + stateContent = + stateReplaceField(stateContent, 'Last Activity Description', lastActivityDescription) || + stateContent; const completedRaw = stateExtractField(stateContent, 'Completed Phases'); if (completedRaw !== null) { diff --git a/src/state.cts b/src/state.cts index d51a0bf50..a950540b0 100644 --- a/src/state.cts +++ b/src/state.cts @@ -1050,6 +1050,31 @@ function matchSessionSection(body: string): RegExpMatchArray | null { || body.match(/(?:^|\n)##[ \t]*Session Continuity[ \t]*\n([\s\S]*?)(?=\n##|$)/i); } +function parseProsePhaseField(value: string | null): { phase: string | null; name: string | null } { + if (!value) return { phase: null, name: null }; + const phaseMatch = value.match(/\b(\d+[A-Z]?(?:\.\d+)*)\b/i); + const parenName = value.match(/\(([^)]+)\)/); + const dashName = value.match(/—\s*([^(\n]+?)(?:\s*\(|$)/); + const rawName = parenName?.[1] ?? dashName?.[1] ?? null; + const name = rawName && !/^(?:complete|executing|not started)$/i.test(rawName.trim()) + ? rawName.trim() + : null; + return { + phase: phaseMatch ? phaseMatch[1] : null, + name, + }; +} + +function parseProseLastActivityField(value: string | null): { date: string | null; description: string | null } { + if (!value) return { date: null, description: null }; + const match = value.match(/^(\d{4}-\d{2}-\d{2})(?:\s+[—-]{1,2}\s+(.+))?$/); + if (!match) return { date: value, description: null }; + return { + date: match[1], + description: match[2]?.trim() || null, + }; +} + function cmdStateSnapshot(cwd: string, raw: boolean): void { const statePath = planningPaths(cwd).state; @@ -1080,15 +1105,18 @@ function cmdStateSnapshot(cwd: string, raw: boolean): void { }; // Extract basic fields — frontmatter keys take precedence over body - const currentPhase = fmScalar('current_phase') ?? stateExtractField(body, 'Current Phase'); - const currentPhaseName = fmScalar('current_phase_name') ?? stateExtractField(body, 'Current Phase Name'); + const prosePhase = parseProsePhaseField(stateExtractField(body, 'Phase')); + const currentPhase = fmScalar('current_phase') ?? stateExtractField(body, 'Current Phase') ?? prosePhase.phase; + const currentPhaseName = fmScalar('current_phase_name') ?? stateExtractField(body, 'Current Phase Name') ?? prosePhase.name; const totalPhasesRaw = fmScalar('total_phases') ?? stateExtractField(body, 'Total Phases'); const currentPlan = fmScalar('current_plan') ?? stateExtractField(body, 'Current Plan'); const totalPlansRaw = fmScalar('total_plans_in_phase') ?? stateExtractField(body, 'Total Plans in Phase'); const status = fmScalar('status') ?? stateExtractField(body, 'Status'); const progressRaw = fmScalar('progress') ?? stateExtractField(body, 'Progress'); - const lastActivity = fmScalar('last_activity') ?? stateExtractField(body, 'Last Activity'); - const lastActivityDesc = fmScalar('last_activity_desc') ?? stateExtractField(body, 'Last Activity Description'); + const rawLastActivity = stateExtractField(body, 'Last Activity') ?? stateExtractField(body, 'Last activity'); + const proseLastActivity = parseProseLastActivityField(rawLastActivity); + const lastActivity = fmScalar('last_activity') ?? proseLastActivity.date ?? rawLastActivity; + const lastActivityDesc = fmScalar('last_activity_desc') ?? stateExtractField(body, 'Last Activity Description') ?? proseLastActivity.description; const pausedAt = fmScalar('paused_at') ?? stateExtractField(body, 'Paused At'); // Parse numeric fields @@ -1180,14 +1208,18 @@ function cmdStateSnapshot(cwd: string, raw: boolean): void { * reliably via `state json` instead of fragile regex parsing. */ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined): Record { - const currentPhase = stateExtractField(bodyContent, 'Current Phase'); - const currentPhaseName = stateExtractField(bodyContent, 'Current Phase Name'); + const prosePhase = parseProsePhaseField(stateExtractField(bodyContent, 'Phase')); + const currentPhase = stateExtractField(bodyContent, 'Current Phase') ?? prosePhase.phase; + const currentPhaseName = stateExtractField(bodyContent, 'Current Phase Name') ?? prosePhase.name; const currentPlan = stateExtractField(bodyContent, 'Current Plan'); const totalPhasesRaw = stateExtractField(bodyContent, 'Total Phases'); const totalPlansRaw = stateExtractField(bodyContent, 'Total Plans in Phase'); const status = stateExtractField(bodyContent, 'Status'); const progressRaw = stateExtractField(bodyContent, 'Progress'); - const lastActivity = stateExtractField(bodyContent, 'Last Activity'); + const rawLastActivity = stateExtractField(bodyContent, 'Last Activity') ?? stateExtractField(bodyContent, 'Last activity'); + const proseLastActivity = parseProseLastActivityField(rawLastActivity); + const lastActivity = proseLastActivity.date ?? rawLastActivity; + const lastActivityDesc = stateExtractField(bodyContent, 'Last Activity Description') ?? proseLastActivity.description; // Bug #2444: scope Stopped At extraction to the ## Session section so that // historical "Stopped at:" prose elsewhere in the body (e.g. in a // Session Continuity Archive section) never overwrites the current value. @@ -1326,6 +1358,7 @@ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined): Re if (pausedAt) fm['paused_at'] = pausedAt; fm['last_updated'] = realClock.nowIso(); if (lastActivity) fm['last_activity'] = lastActivity; + if (lastActivityDesc) fm['last_activity_desc'] = lastActivityDesc; const progress: Record = {}; if (totalPhases !== null) progress['total_phases'] = totalPhases; @@ -1889,13 +1922,13 @@ function cmdStateBeginPhase(cwd: string, phaseNumber: string | number, phaseName } // Update Last activity line if present - const newActivity = `Last activity: ${today} -- Phase ${phaseNumber} execution started`; + const newActivity = `Last activity: ${today} — Phase ${phaseNumber} execution started`; if (/^Last activity:/im.test(posBody)) { posBody = posBody.replace(/^Last activity:.*$/im, newActivity); } else { // Pipe-table format in Current Position (#1255) // Value must match the inline branch (date + narrative), not bare date. - const activityValue = `${today} -- Phase ${phaseNumber} execution started`; + const activityValue = `${today} — Phase ${phaseNumber} execution started`; const replaced = stateReplaceField(posBody, 'Last Activity', activityValue) ?? stateReplaceField(posBody, 'Last activity', activityValue); if (replaced !== null) posBody = replaced; @@ -1912,7 +1945,7 @@ function cmdStateBeginPhase(cwd: string, phaseNumber: string | number, phaseName if (positionMatch) { const header = positionMatch[1]; let posBody = positionMatch[2]; - const resumeActivity = `Last activity: ${today} -- Phase ${phaseNumber} execution resumed (wave continue)`; + const resumeActivity = `Last activity: ${today} — Phase ${phaseNumber} execution resumed (wave continue)`; if (/^Last activity:/im.test(posBody)) { posBody = posBody.replace(/^Last activity:.*$/im, resumeActivity); body = body.replace(positionPattern, () => `${header}${posBody}`); @@ -2083,7 +2116,7 @@ function cmdStatePlannedPhase(cwd: string, phaseNumber: string | number, planCou // Update Current Position section body = updateCurrentPositionFields(body, { status: 'Ready to execute', - lastActivity: `${today} -- Phase ${phaseNumber} planning complete`, + lastActivity: `${today} — Phase ${phaseNumber} planning complete`, }); return reassemble(body); @@ -2659,13 +2692,13 @@ function cmdStateCompletePhase(cwd: string, raw: boolean, overridePhase?: string } // Update Last activity line if present - const newActivity = `Last activity: ${today} -- Phase ${currentPhase} marked complete`; + const newActivity = `Last activity: ${today} — Phase ${currentPhase} marked complete`; if (/^Last activity:/im.test(posBody)) { posBody = posBody.replace(/^Last activity:.*$/im, newActivity); } else { // Pipe-table format in Current Position (#1255) // Value must match the inline branch (date + narrative), not bare date. - const activityValue = `${today} -- Phase ${currentPhase} marked complete`; + const activityValue = `${today} — Phase ${currentPhase} marked complete`; const replaced = stateReplaceField(posBody, 'Last Activity', activityValue) ?? stateReplaceField(posBody, 'Last activity', activityValue); if (replaced !== null) posBody = replaced; diff --git a/tests/phase.test.cjs b/tests/phase.test.cjs index 2c0c7a2f8..204557c45 100644 --- a/tests/phase.test.cjs +++ b/tests/phase.test.cjs @@ -19,7 +19,7 @@ const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); const os = require('node:os'); -const { execFileSync } = require('node:child_process'); +const { execFileSync, spawnSync } = require('node:child_process'); const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); const GSD_TOOLS_BIN = path.resolve(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs'); @@ -4279,6 +4279,71 @@ describe('bug-3287 — init plan-phase exposes expected_phase_dir with project_c return { planningDir, phase5Dir }; } + function setupPhase1316Project(tmpDir) { + const planningDir = path.join(tmpDir, '.planning'); + const phasesDir = path.join(planningDir, 'phases'); + fs.mkdirSync(planningDir, { recursive: true }); + fs.mkdirSync(phasesDir, { recursive: true }); + + fs.writeFileSync( + path.join(planningDir, 'ROADMAP.md'), + [ + '# Roadmap', + '', + '## Current Milestone: v3.0', + '', + '- [ ] Phase 32: Backlog-Closeout Lib Extraction', + '- [ ] Phase 33: Follow Up Implementation', + '', + '### Phase 32: Backlog-Closeout Lib Extraction', + '**Goal:** Complete closeout extraction', + '**Plans:** 1 plans', + '', + '### Phase 33: Follow Up Implementation', + '**Goal:** Continue implementation', + ].join('\n'), + ); + + fs.writeFileSync( + path.join(planningDir, 'STATE.md'), + [ + '---', + 'gsd_state_version: 1.0', + 'status: executing', + 'current_phase: "32"', + 'last_activity: "2026-06-14"', + 'progress:', + ' total_phases: 2', + ' completed_phases: 0', + ' total_plans: 1', + ' completed_plans: 0', + ' percent: 0', + '---', + '', + '# Project State', + '', + '## Current Position', + '', + 'Phase: 32 — Backlog-Closeout Lib Extraction', + 'Plan: 1 of 1', + 'Status: Executing Phase 32', + 'Last activity: 2026-06-14 — recorded planning complete', + '', + '## Session', + '', + 'Last session: 2026-06-14T00:00:00.000Z', + ].join('\n'), + ); + + const phase32Dir = path.join(phasesDir, '32-backlog-closeout-lib-extraction'); + fs.mkdirSync(phase32Dir, { recursive: true }); + fs.writeFileSync(path.join(phase32Dir, '32-01-PLAN.md'), '# Plan', 'utf8'); + fs.writeFileSync(path.join(phase32Dir, '32-01-SUMMARY.md'), '# Summary', 'utf8'); + fs.mkdirSync(path.join(phasesDir, '33-follow-up-implementation'), { recursive: true }); + + return { planningDir }; + } + describe('bug #3517: phase.complete leaves STATE.md with stale fields', () => { let tmpDir; @@ -4434,6 +4499,39 @@ describe('bug-3287 — init plan-phase exposes expected_phase_dir with project_c assert.match(state, /Phase:\s*0?6\b/, 'numeric Phase line should advance to phase 6'); }); + test('prose-block STATE keeps next phase name without field-miss warnings (#1316)', () => { + const { planningDir } = setupPhase1316Project(tmpDir); + + const result = spawnSync(process.execPath, [GSD_TOOLS_BIN, 'phase', 'complete', '32'], { + cwd: tmpDir, + encoding: 'utf8', + env: process.env, + }); + + assert.strictEqual(result.status, 0, `phase complete failed: ${result.stderr || result.stdout}`); + assert.ok( + !result.stderr.includes('Current Phase Name'), + `phase.complete must not warn about missing Current Phase Name on prose-block STATE.md; stderr:\n${result.stderr}`, + ); + assert.ok( + !result.stderr.includes('Last Activity Description'), + `phase.complete must not warn about missing Last Activity Description on prose-block STATE.md; stderr:\n${result.stderr}`, + ); + + const state = fs.readFileSync(path.join(planningDir, 'STATE.md'), 'utf8'); + assert.match(state, /current_phase:\s*"?33"?/, 'current_phase frontmatter must advance to 33'); + assert.match( + state, + /^Phase:\s*33\s+—\s+Follow Up Implementation\b/m, + `Current Position Phase line must keep the next phase name; state:\n${state}`, + ); + assert.match( + state, + /^Last activity:\s*\d{4}-\d{2}-\d{2}\s+—\s+Phase 32 complete/m, + `Last activity line must use the template em-dash delimiter with narrative; state:\n${state}`, + ); + }); + test('body By Phase table row for completed phase shows correct plan count', () => { setupPhase3517Project(tmpDir); const statePath = path.join(tmpDir, '.planning', 'STATE.md'); diff --git a/tests/state.test.cjs b/tests/state.test.cjs index 4c7098306..8306e8c3d 100644 --- a/tests/state.test.cjs +++ b/tests/state.test.cjs @@ -3825,8 +3825,8 @@ describe('#1255 — begin/complete-phase advance status for pipe-table STATE.md' // Last activity cell must include date + narrative (not bare date) assert.ok( - /\|\s*Last activity\s*\|[^|]*--\s*Phase 1 execution started\s*\|/i.test(cpSection), - `Current Position Last activity cell must include narrative '-- Phase 1 execution started'; got Current Position:\n${cpSection}` + /\|\s*Last activity\s*\|[^|]*—\s*Phase 1 execution started\s*\|/i.test(cpSection), + `Current Position Last activity cell must include narrative '— Phase 1 execution started'; got Current Position:\n${cpSection}` ); } finally { cleanup(dir); @@ -3909,8 +3909,8 @@ describe('#1255 — begin/complete-phase advance status for pipe-table STATE.md' // Bug 2: Last activity cell must include date + narrative (not bare date) assert.ok( - /\|\s*Last activity\s*\|[^|]*--\s*Phase 1 marked complete\s*\|/i.test(cpSection), - `Current Position Last activity cell must include narrative '-- Phase 1 marked complete'; got Current Position:\n${cpSection}` + /\|\s*Last activity\s*\|[^|]*—\s*Phase 1 marked complete\s*\|/i.test(cpSection), + `Current Position Last activity cell must include narrative '— Phase 1 marked complete'; got Current Position:\n${cpSection}` ); } finally { cleanup(dir);