Merge pull request #3122 from gsd-build/fix/3088-gsd-complete-milestone-leaves-state-md-n

fix: normalize stale STATE narrative tails on milestone completion
This commit is contained in:
Tom Boucher
2026-05-04 23:31:06 -04:00
committed by GitHub
4 changed files with 91 additions and 1 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 3122
---
**Milestone close now repairs missing STATE narrative sections** — when `## Current Position` or `## Operator Next Steps` headings are absent, milestone completion appends canonical sections so state remains deterministic and consistently points operators to `/gsd-new-milestone`.

View File

@@ -199,7 +199,7 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
atomicWriteFileSync(milestonesPath, normalizeMd(`# Milestones\n\n${milestoneEntry}`));
}
// Update STATE.md — use shared helpers that handle both **bold:** and plain Field: formats
// Update STATE.md — keep frontmatter/body semantically aligned after closure
if (fs.existsSync(statePath)) {
let stateContent = fs.readFileSync(statePath, 'utf-8');
@@ -208,6 +208,31 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
stateContent = stateReplaceFieldWithFallback(stateContent, 'Last Activity Description', null,
`${version} milestone completed and archived`);
// Reset Current Position narrative so resume/progress flows do not keep
// pointing at closed-phase execution instructions.
const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
const closedPositionBody =
`\nPhase: Milestone ${version} complete\n` +
`Plan: —\n` +
`Status: Awaiting next milestone\n` +
`Last activity: ${today} — Milestone ${version} completed and archived\n\n`;
if (positionPattern.test(stateContent)) {
stateContent = stateContent.replace(positionPattern, (_m, header) => `${header}${closedPositionBody}`);
} else {
stateContent = `${stateContent.trimEnd()}\n\n## Current Position\n${closedPositionBody}`;
}
// Normalize operator-next-step tails that can become stale after close.
const operatorPattern = /(##\s*Operator Next Steps\s*\n)([\s\S]*?)(?=\n##|$)/i;
if (operatorPattern.test(stateContent)) {
stateContent = stateContent.replace(
operatorPattern,
`$1\n- Start the next milestone with /gsd-new-milestone\n\n`,
);
} else {
stateContent = `${stateContent.trimEnd()}\n\n## Operator Next Steps\n\n- Start the next milestone with /gsd-new-milestone\n`;
}
writeStateMd(statePath, stateContent, cwd);
}

View File

@@ -1841,6 +1841,29 @@ export const milestoneComplete: QueryHandler = async (args, projectDir, workstre
null,
`${version} milestone completed and archived`,
);
const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
const closedPositionBody =
`\nPhase: Milestone ${version} complete\n` +
`Plan: —\n` +
`Status: Awaiting next milestone\n` +
`Last activity: ${today} — Milestone ${version} completed and archived\n\n`;
if (positionPattern.test(next)) {
next = next.replace(positionPattern, (_m, header) => `${header}${closedPositionBody}`);
} else {
next = `${next.trimEnd()}\n\n## Current Position\n${closedPositionBody}`;
}
const operatorPattern = /(##\s*Operator Next Steps\s*\n)([\s\S]*?)(?=\n##|$)/i;
if (operatorPattern.test(next)) {
next = next.replace(
operatorPattern,
`$1\n- Start the next milestone with /gsd-new-milestone\n\n`,
);
} else {
next = `${next.trimEnd()}\n\n## Operator Next Steps\n\n- Start the next milestone with /gsd-new-milestone\n`;
}
return next;
}, workstream);
}

View File

@@ -227,6 +227,43 @@ describe('milestone complete command', () => {
);
});
test('normalizes stale STATE.md narrative tails after milestone complete (#3088)', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap v1.0\n`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# State\n\n**Status:** In progress\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n\n## Current Position\n\nPhase: 03 — EXECUTING\nPlan: 03-02\nStatus: Executing\nLast activity: 2025-01-01 — Running phase\n\n## Operator Next Steps\n\n- Re-run /gsd-complete-milestone v1.0\n`
);
const result = runGsdTools('milestone complete v1.0 --name Test', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(state.includes('Phase: Milestone v1.0 complete'));
assert.ok(state.includes('Status: Awaiting next milestone'));
assert.ok(!state.includes('Re-run /gsd-complete-milestone'));
assert.ok(state.includes('/gsd-new-milestone'));
});
test('appends canonical narrative sections when STATE.md headings are missing (#3088)', () => {
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap v1.0\n`);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# State\n\n**Status:** In progress\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
);
const result = runGsdTools('milestone complete v1.0 --name Test', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(state.includes('## Current Position'));
assert.ok(state.includes('Phase: Milestone v1.0 complete'));
assert.ok(state.includes('## Operator Next Steps'));
assert.ok(state.includes('/gsd-new-milestone'));
});
test('handles missing ROADMAP.md gracefully', () => {
// Only STATE.md — no ROADMAP.md, no REQUIREMENTS.md
fs.writeFileSync(