refactor(#1789): ADR-1769 Phase 5 — milestoneComplete migration (#1790)

Migrate the STATE.md write path inside cmdMilestoneComplete (milestone.cts)
onto the STATE.md Transition Module substrate (ADR-1769, epic #1769).

- Add {kind: 'milestoneComplete'} to StateTransitionIntent, with
  milestoneCompleteCore in src/state-transition.cts (consulting the
  field-classification table). Owns the closure write: Status
  ('<version> milestone complete'), Last Activity, Last Activity Description,
  a Current Position reset to 'Awaiting next milestone', and an Operator Next
  Steps reset pointing at the next-milestone command.
- Collapse the inline STATE.md transform in cmdMilestoneComplete to a
  transitionCore dispatch. The adapter retains writeStateMd (lock + steady-state
  syncStateFrontmatter post-sync) and resolves the runtime-specific
  next-milestone slash command, injecting it via intent.nextMilestoneCommand.
- The two section resets carry their pre-seam allow-adhoc-markdown waivers
  (regex semantics pinned by existing tests; pending collectSection #1372).
- realClock.today() is byte-identical to milestone.cts's local today
  (both new Date().toISOString().split('T')[0]).
- Characterization tests pin field updates, both section resets (replace +
  insert paths), and frontmatter #1255 parity.

All 66 transition + milestone + state tests pass.

Closes #1789
This commit is contained in:
Tom Boucher
2026-06-27 15:46:54 -04:00
committed by GitHub
parent 91704c9fcf
commit 3ceb83329d
5 changed files with 348 additions and 41 deletions

View File

@@ -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 (`<version> 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<string, unknown>;
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 — `<version> 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 };
}