6
.changeset/1316-state-prose-phase-name.md
Normal file
6
.changeset/1316-state-prose-phase-name.md
Normal file
@@ -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)
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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<string, unknown> = {};
|
||||
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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user