diff --git a/docs/adr/1769-state-md-transition-module.md b/docs/adr/1769-state-md-transition-module.md index 7751fefcc..8e60714f1 100644 --- a/docs/adr/1769-state-md-transition-module.md +++ b/docs/adr/1769-state-md-transition-module.md @@ -212,6 +212,6 @@ alongside, leave callbacks — parallel worlds don't converge (ADR-857's failure | 2 | `advancePlan` | #1782 | — | | 3 | `completePhase` + `phase.cts:1770` | #1784 | — | | 4 | `plannedPhase` + `milestoneSwitch` | #1786 | — | -| 5 | `milestoneComplete` + `milestone.cts:352` | TBD | — | +| 5 | `milestoneComplete` + `milestone.cts:352` | #1789 | — | | 6 | `patch` | TBD | #1743, #1695 | | 7 | `sync`, `prune`, `update` | TBD | #1760, #1761 | diff --git a/gsd-core/bin/lib/state-transition.cjs b/gsd-core/bin/lib/state-transition.cjs index 704d7bc4a..4569d1fc9 100644 --- a/gsd-core/bin/lib/state-transition.cjs +++ b/gsd-core/bin/lib/state-transition.cjs @@ -123,6 +123,8 @@ function transitionCore(content, intent, deps) { return plannedPhaseCore(content, intent, deps); case 'milestoneSwitch': return milestoneSwitchCore(content, intent, deps); + case 'milestoneComplete': + return milestoneCompleteCore(content, intent, deps); } } // ---------------------------------------------------------------------------- @@ -814,3 +816,91 @@ function milestoneSwitchCore(content, intent, deps) { const assembled = `---\n${yamlStr}\n---\n\n${newBody.replace(/^\n+/, '')}`; return { content: assembled, updated }; } +// ---------------------------------------------------------------------------- +// milestoneComplete — intent implementation (Phase 5) +// ---------------------------------------------------------------------------- +/** + * Apply a `milestoneComplete` transition to STATE.md content. + * + * Migrates the STATE.md write path inside `cmdMilestoneComplete` (milestone.cts) + * onto the substrate. Owns the closure write: Status (` milestone + * complete`), Last Activity, Last Activity Description, a ## Current Position + * reset to the "Awaiting next milestone" state, and a ## Operator Next Steps + * reset pointing at the next-milestone command. + * + * The adapter (`cmdMilestoneComplete`) retains `writeStateMd` (the writer that + * owns the lock + steady-state syncStateFrontmatter post-sync) and resolves the + * runtime-specific next-milestone slash command, injecting it via + * `intent.nextMilestoneCommand` so the core stays pure. + * + * The two section resets use raw regex (with the pre-seam `allow-adhoc-markdown` + * waivers carried from milestone.cts) rather than tokenizeHeadings because the + * `## Operator Next Steps` section is non-canonical (not in STATE_MD_SECTIONS) + * and the existing behavior + its tests pin the exact regex semantics. A future + * collectSection migration (#1372) can swap both to section primitives. + * + * Behavior is byte-for-byte with the pre-migration milestone.cts:314-353 block. + */ +function milestoneCompleteCore(content, intent, deps) { + const updated = []; + const today = deps.clock.today(); + const version = intent.version; + for (const fmKey of ['status', 'last_activity', 'last_activity_desc']) { + const cls = getFieldClassification(fmKey); + if (cls === null) { + throw new Error(`transitionCore milestoneComplete: frontmatter key ${JSON.stringify(fmKey)} is not in FIELD_CLASSIFICATION; ` + + `add a row per ADR-1769 §4 before touching it.`); + } + } + // #1255: body-field replacements operate on body only. + const existingFm = extractFrontmatter(content); + const hasFrontmatter = Object.keys(existingFm).length > 0; + let body = stripFrontmatter(content); + const reassemble = (b) => hasFrontmatter + ? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${b}` + : b; + // Status — ` milestone complete`. + const statusAfter = (0, state_document_cjs_1.stateReplaceFieldWithFallback)(body, 'Status', null, `${version} milestone complete`); + if (statusAfter !== body) { + body = statusAfter; + updated.push('Status'); + } + // Last Activity. + const lastActivityAfter = (0, state_document_cjs_1.stateReplaceFieldWithFallback)(body, 'Last Activity', 'Last activity', today); + if (lastActivityAfter !== body) { + body = lastActivityAfter; + updated.push('Last Activity'); + } + // Last Activity Description. + const ladAfter = (0, state_document_cjs_1.stateReplaceFieldWithFallback)(body, 'Last Activity Description', null, `${version} milestone completed and archived`); + if (ladAfter !== body) { + body = ladAfter; + updated.push('Last Activity Description'); + } + // ## Current Position reset — stop resume/progress flows pointing at closed + // execution instructions. allow-adhoc-markdown: pre-seam section write-modify; + // pending collectSection migration #1372. + 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(body)) { + body = body.replace(positionPattern, (_m, header) => `${header}${closedPositionBody}`); + } + else { + body = `${body.trimEnd()}\n\n## Current Position\n${closedPositionBody}`; + } + updated.push('Current Position'); + // ## Operator Next Steps — normalize stale tails that can persist after close. + // allow-adhoc-markdown: pre-seam section write-modify; pending collectSection migration #1372. + const operatorPattern = /(##\s*Operator Next Steps\s*\n)([\s\S]*?)(?=\n##|$)/i; + if (operatorPattern.test(body)) { + body = body.replace(operatorPattern, `$1\n- Start the next milestone with ${intent.nextMilestoneCommand}\n\n`); + } + else { + body = `${body.trimEnd()}\n\n## Operator Next Steps\n\n- Start the next milestone with ${intent.nextMilestoneCommand}\n`; + } + updated.push('Operator Next Steps'); + return { content: reassemble(body), updated }; +} diff --git a/src/milestone.cts b/src/milestone.cts index 2b8e83f9e..7dcfb6fdf 100644 --- a/src/milestone.cts +++ b/src/milestone.cts @@ -16,6 +16,8 @@ import frontmatterMod = require('./frontmatter.cjs'); import stateMod = require('./state.cjs'); import { platformWriteSync, platformEnsureDir, execGit, retryRenameSync } from './shell-command-projection.cjs'; import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs'; +import { realClock } from './clock.cjs'; +import { transitionCore } from './state-transition.cjs'; // eslint-disable-next-line @typescript-eslint/no-require-imports import ioMod = require('./io.cjs'); const { output, error } = ioMod; @@ -30,7 +32,7 @@ import coreUtilsMod = require('./core-utils.cjs'); const { extractOneLinerFromBody } = coreUtilsMod; const { planningPaths } = planningWorkspace; const { extractFrontmatter } = frontmatterMod; -const { writeStateMd, stateReplaceFieldWithFallback } = stateMod; +const { writeStateMd } = stateMod; interface MilestoneCompleteOptions { name?: string; @@ -311,45 +313,25 @@ function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCo platformWriteSync(milestonesPath, `# Milestones\n\n${milestoneEntry}`); } - // Update STATE.md — keep frontmatter/body semantically aligned after closure + // Update STATE.md — keep frontmatter/body semantically aligned after closure. + // ADR-1769 Phase 5: dispatches to the STATE.md Transition Module. The closure + // write (Status, Last Activity, Last Activity Description, Current Position + // reset, Operator Next Steps reset) is the pure `milestoneCompleteCore` in + // src/state-transition.cts, backed by the field-classification table. The + // runtime-specific next-milestone slash command is resolved here and injected + // via the intent so the core stays pure. writeStateMd still owns the lock and + // the steady-state syncStateFrontmatter post-sync. if (fs.existsSync(statePath)) { - let stateContent = fs.readFileSync(statePath, 'utf-8'); - - stateContent = stateReplaceFieldWithFallback(stateContent, 'Status', null, `${version} milestone complete`); - stateContent = stateReplaceFieldWithFallback(stateContent, 'Last Activity', 'Last activity', today); - stateContent = stateReplaceFieldWithFallback( - stateContent, - 'Last Activity Description', - null, - `${version} milestone completed and archived`, + const result = transitionCore( + fs.readFileSync(statePath, 'utf-8'), + { + kind: 'milestoneComplete', + version, + nextMilestoneCommand: formatGsdSlash('new-milestone', resolveRuntime(cwd)) as string, + }, + { clock: realClock, progressProvider: () => null }, ); - - // 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; // allow-adhoc-markdown: pre-seam section write-modify in milestone.cts; pending collectSection migration #1372 - 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: string) => `${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; // allow-adhoc-markdown: pre-seam section write-modify in milestone.cts; pending collectSection migration #1372 - if (operatorPattern.test(stateContent)) { - stateContent = stateContent.replace( - operatorPattern, - `$1\n- Start the next milestone with ${formatGsdSlash('new-milestone', resolveRuntime(cwd)) as string}\n\n`, - ); - } else { - stateContent = `${stateContent.trimEnd()}\n\n## Operator Next Steps\n\n- Start the next milestone with ${formatGsdSlash('new-milestone', resolveRuntime(cwd)) as string}\n`; - } - - writeStateMd(statePath, stateContent, cwd); + writeStateMd(statePath, result.content, cwd); } // Archive phase directories if requested diff --git a/src/state-transition.cts b/src/state-transition.cts index 6121f53da..babbcf36f 100644 --- a/src/state-transition.cts +++ b/src/state-transition.cts @@ -173,8 +173,14 @@ export type StateTransitionIntent = summaryCount: number; } | { kind: 'plannedPhase'; phaseNumber: string | number; planCount: number | null } - | { kind: 'milestoneSwitch'; version: string; name: string }; -// Phases 5–7 add the remaining intent kinds to this discriminated union. + | { kind: 'milestoneSwitch'; version: string; name: string } + | { + kind: 'milestoneComplete'; + version: string; + /** Resolved runtime slash command for the Operator Next Steps hint (e.g. '/gsd:new-milestone'). */ + nextMilestoneCommand: string; + }; +// Phases 6–7 add the remaining intent kinds to this discriminated union. export type StateTransitionResult = { content: string; @@ -213,6 +219,8 @@ export function transitionCore( return plannedPhaseCore(content, intent, deps); case 'milestoneSwitch': return milestoneSwitchCore(content, intent, deps); + case 'milestoneComplete': + return milestoneCompleteCore(content, intent, deps); } } @@ -1010,3 +1018,113 @@ function milestoneSwitchCore( const assembled = `---\n${yamlStr}\n---\n\n${newBody.replace(/^\n+/, '')}`; return { content: assembled, updated }; } + +// ---------------------------------------------------------------------------- +// milestoneComplete — intent implementation (Phase 5) +// ---------------------------------------------------------------------------- + +/** + * Apply a `milestoneComplete` transition to STATE.md content. + * + * Migrates the STATE.md write path inside `cmdMilestoneComplete` (milestone.cts) + * onto the substrate. Owns the closure write: Status (` milestone + * complete`), Last Activity, Last Activity Description, a ## Current Position + * reset to the "Awaiting next milestone" state, and a ## Operator Next Steps + * reset pointing at the next-milestone command. + * + * The adapter (`cmdMilestoneComplete`) retains `writeStateMd` (the writer that + * owns the lock + steady-state syncStateFrontmatter post-sync) and resolves the + * runtime-specific next-milestone slash command, injecting it via + * `intent.nextMilestoneCommand` so the core stays pure. + * + * The two section resets use raw regex (with the pre-seam `allow-adhoc-markdown` + * waivers carried from milestone.cts) rather than tokenizeHeadings because the + * `## Operator Next Steps` section is non-canonical (not in STATE_MD_SECTIONS) + * and the existing behavior + its tests pin the exact regex semantics. A future + * collectSection migration (#1372) can swap both to section primitives. + * + * Behavior is byte-for-byte with the pre-migration milestone.cts:314-353 block. + */ +function milestoneCompleteCore( + content: string, + intent: { kind: 'milestoneComplete'; version: string; nextMilestoneCommand: string }, + deps: StateTransitionDeps, +): StateTransitionResult { + const updated: string[] = []; + const today = deps.clock.today(); + const version = intent.version; + + for (const fmKey of ['status', 'last_activity', 'last_activity_desc']) { + const cls = getFieldClassification(fmKey); + if (cls === null) { + throw new Error( + `transitionCore milestoneComplete: frontmatter key ${JSON.stringify(fmKey)} is not in FIELD_CLASSIFICATION; ` + + `add a row per ADR-1769 §4 before touching it.`, + ); + } + } + + // #1255: body-field replacements operate on body only. + const existingFm = extractFrontmatter(content) as Record; + const hasFrontmatter = Object.keys(existingFm).length > 0; + let body = stripFrontmatter(content); + const reassemble = (b: string): string => + hasFrontmatter + ? `---\n${reconstructFrontmatter(existingFm as unknown as Frontmatter)}\n---\n\n${b}` + : b; + + // Status — ` milestone complete`. + const statusAfter = stateReplaceFieldWithFallback(body, 'Status', null, `${version} milestone complete`); + if (statusAfter !== body) { + body = statusAfter; + updated.push('Status'); + } + + // Last Activity. + const lastActivityAfter = stateReplaceFieldWithFallback(body, 'Last Activity', 'Last activity', today); + if (lastActivityAfter !== body) { + body = lastActivityAfter; + updated.push('Last Activity'); + } + + // Last Activity Description. + const ladAfter = stateReplaceFieldWithFallback( + body, + 'Last Activity Description', + null, + `${version} milestone completed and archived`, + ); + if (ladAfter !== body) { + body = ladAfter; + updated.push('Last Activity Description'); + } + + // ## Current Position reset — stop resume/progress flows pointing at closed + // execution instructions. + const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i; // allow-adhoc-markdown: pre-seam section write-modify carried from milestone.cts; pending collectSection migration #1372 + 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(body)) { + body = body.replace(positionPattern, (_m, header: string) => `${header}${closedPositionBody}`); + } else { + body = `${body.trimEnd()}\n\n## Current Position\n${closedPositionBody}`; + } + updated.push('Current Position'); + + // ## Operator Next Steps — normalize stale tails that can persist after close. + const operatorPattern = /(##\s*Operator Next Steps\s*\n)([\s\S]*?)(?=\n##|$)/i; // allow-adhoc-markdown: pre-seam section write-modify carried from milestone.cts; pending collectSection migration #1372 + if (operatorPattern.test(body)) { + body = body.replace( + operatorPattern, + `$1\n- Start the next milestone with ${intent.nextMilestoneCommand}\n\n`, + ); + } else { + body = `${body.trimEnd()}\n\n## Operator Next Steps\n\n- Start the next milestone with ${intent.nextMilestoneCommand}\n`; + } + updated.push('Operator Next Steps'); + + return { content: reassemble(body), updated }; +} diff --git a/tests/state-transition.test.cjs b/tests/state-transition.test.cjs index 5f2847440..0823b5c28 100644 --- a/tests/state-transition.test.cjs +++ b/tests/state-transition.test.cjs @@ -915,3 +915,120 @@ describe('ADR-1769 Phase 4: milestoneSwitch transition — milestone reset', () ); }); }); + +// ADR-1769 Phase 5: milestoneComplete + +describe('ADR-1769 Phase 5: milestoneComplete transition — closure write', () => { + const deps = { clock: fixedClock, progressProvider: noProgress }; + const intent = { kind: 'milestoneComplete', version: 'v1.0', nextMilestoneCommand: '/gsd:new-milestone' }; + + function preCloseBody() { + return [ + '# Project State', + '', + '**Status:** Executing Phase 5', + '**Last Activity:** 2026-06-20', + '**Last Activity Description:** mid-flight', + '', + '## Current Position', + '', + 'Phase: 5 — EXECUTING', + 'Plan: 2 of 3', + 'Status: Executing Phase 5', + 'Last activity: 2026-06-20 — running', + '', + '## Operator Next Steps', + '', + '- Re-run /gsd:complete-milestone v1.0', + '', + ].join('\n'); + } + + test('Status becomes " milestone complete"', () => { + const result = transitionCore(preCloseBody(), intent, deps); + assert.strictEqual(stateExtractField(result.content, 'Status'), 'v1.0 milestone complete'); + assert.ok(result.updated.includes('Status')); + }); + + test('Last Activity is refreshed to clock.today()', () => { + const result = transitionCore(preCloseBody(), intent, deps); + assert.strictEqual(stateExtractField(result.content, 'Last Activity'), '2026-06-27'); + }); + + test('Last Activity Description carries the archived narrative', () => { + const result = transitionCore(preCloseBody(), intent, deps); + assert.strictEqual( + stateExtractField(result.content, 'Last Activity Description'), + 'v1.0 milestone completed and archived', + ); + }); + + test('Current Position resets to "Awaiting next milestone" with archived narrative', () => { + const result = transitionCore(preCloseBody(), intent, deps); + assert.ok(/Phase: Milestone v1\.0 complete/.test(result.content)); + assert.ok(/Status: Awaiting next milestone/.test(result.content)); + assert.ok(/Last activity: 2026-06-27 — Milestone v1\.0 completed and archived/.test(result.content)); + assert.ok(result.updated.includes('Current Position')); + }); + + test('Operator Next Steps is rewritten to point at the next-milestone command', () => { + const result = transitionCore(preCloseBody(), intent, deps); + assert.ok(/## Operator Next Steps/.test(result.content)); + assert.ok(/- Start the next milestone with \/gsd:new-milestone/.test(result.content)); + // The stale prior instruction must be gone. + assert.ok(!/Re-run \/gsd:complete-milestone/.test(result.content), + 'stale Operator Next Steps tail must be replaced'); + }); + + test('Operator Next Steps section is inserted when absent', () => { + const input = [ + '# Project State', + '', + '**Status:** Executing Phase 5', + '**Last Activity:** 2026-06-20', + '**Last Activity Description:** mid', + '', + '## Current Position', + '', + 'Phase: 5 — EXECUTING', + 'Status: Executing Phase 5', + '', + ].join('\n'); + const result = transitionCore(input, intent, deps); + assert.ok(/## Operator Next Steps/.test(result.content)); + assert.ok(/- Start the next milestone with \/gsd:new-milestone/.test(result.content)); + }); + + test('Current Position section is inserted when absent', () => { + const input = '# Project State\n\n**Status:** Executing\n**Last Activity:** 2026-06-20\n'; + const result = transitionCore(input, intent, deps); + assert.ok(/## Current Position/.test(result.content)); + assert.ok(/Status: Awaiting next milestone/.test(result.content)); + }); + + test('frontmatter is preserved across the closure write (#1255)', () => { + const input = [ + '---', + 'status: executing', + 'milestone: v1.0', + '---', + '', + '# Project State', + '', + '**Status:** Executing Phase 5', + '**Last Activity:** 2026-06-20', + '**Last Activity Description:** mid', + '', + '## Current Position', + '', + 'Phase: 5 — EXECUTING', + 'Status: Executing Phase 5', + '', + ].join('\n'); + const result = transitionCore(input, intent, deps); + // Body Status must be the closure value, not the YAML status key. + assert.strictEqual(stateExtractField(result.content, 'Status'), 'v1.0 milestone complete'); + assert.ok(/^---\r?\n[\s\S]*?\r?\n---/.test(result.content), 'frontmatter block preserved'); + assert.ok(/^milestone: v1\.0/m.test(result.content), 'frontmatter milestone preserved'); + }); +});