refactor(#1784): ADR-1769 Phase 3 — completePhase migration onto Transition Module (#1785)

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

- Add {kind: 'completePhase'} to StateTransitionIntent + completePhaseCore in
  src/state-transition.cts. Pure body field mutations (Current Phase shape/name,
  Status, Current Plan, Last Activity + Description, Completed/Total Phases +
  Progress percent), consulting the field-classification table for touched keys.
  Roadmap progress injected via a new optional deps.roadmapProvider.
- Collapse the ~90-line inline STATE.md transform in cmdPhaseComplete to a
  transitionCore dispatch. The adapter retains updatePerformanceMetricsSection
  (section table) + syncStateFrontmatter (disk-scan post-sync) and the
  multi-file atomic transaction (STATE is committed with ROADMAP/REQUIREMENTS,
  so readModifyWriteStateMd is not used here).
- deriveProgressFromRoadmap/clampPercent/stateReplaceFieldWithFallback move from
  the phase.cts call site into the pure core (no circular dep: phase-lifecycle
  and state-document don't import state.cts).
- Characterization tests in tests/state-transition.test.cjs pin the field
  updates, roadmap progress derivation, #1255 frontmatter parity, and the
  'Phase:' fallback. All 44 transition + 193 phase tests pass.

No user-visible behavior change. cmdPhaseComplete CLI output and 'phase complete'
behavior unchanged.

Closes #1784
This commit is contained in:
Tom Boucher
2026-06-27 14:56:51 -04:00
committed by GitHub
parent 1512e6f415
commit 6f80524be1
5 changed files with 635 additions and 90 deletions

View File

@@ -51,8 +51,8 @@ import frontmatterMod = require('./frontmatter.cjs');
import stateMod = require('./state.cjs');
import { platformWriteSync, platformReadSync, platformEnsureDir, retryRenameSync } from './shell-command-projection.cjs';
import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
import { deriveProgressFromRoadmap, clampPercent } from './phase-lifecycle.cjs';
import { realClock } from './clock.cjs';
import { transitionCore } from './state-transition.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- uat-predicate.cjs is an export= CommonJS module
import uatPredicate = require('./uat-predicate.cjs');
const { evaluateUatPassed } = uatPredicate;
@@ -66,7 +66,6 @@ const {
readModifyWriteStateMd,
stateExtractField,
stateReplaceField,
stateReplaceFieldWithFallback,
syncStateFrontmatter,
withStateLock,
updatePerformanceMetricsSection,
@@ -1672,93 +1671,38 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
const originalStateContent = platformReadSync(statePath) || '';
let stateContent = originalStateContent;
const phaseValue = nextPhaseNum || phaseNum;
// ADR-1769 Phase 3: the STATE.md field-update policy (Current Phase
// shape/name, Status, Current Plan, Last Activity + Description, and
// the Completed/Total Phases + Progress percent block) now dispatches
// to the STATE.md Transition Module. The ~90-line inline RMW callback
// that lived here is the pure `completePhaseCore` in
// src/state-transition.cts, backed by the field-classification table.
// `updatePerformanceMetricsSection` + `syncStateFrontmatter` stay in
// this adapter: they are section-table / disk-scan concerns, not
// classified fields, and `syncStateFrontmatter` is the post-sync this
// transaction needs (it does NOT go through readModifyWriteStateMd
// because STATE.md is committed atomically with ROADMAP/REQUIREMENTS).
const nextPhaseDisplayName =
phaseDisplayNameFromRoadmap(roadmapContent, nextPhaseNum) ??
phaseDisplayNameFromSlug(nextPhaseName);
const existingPhaseField =
stateExtractField(stateContent, 'Current Phase') ||
stateExtractField(stateContent, 'Phase');
let newPhaseValue = String(phaseValue);
if (existingPhaseField) {
const totalMatch = existingPhaseField.match(/of\s+(\d+)/);
const nameMatch = existingPhaseField.match(/\(([^)]+)\)/);
if (totalMatch) {
const total = totalMatch[1];
const nameStr = nextPhaseDisplayName
? ` (${nextPhaseDisplayName})`
: nameMatch
? ` (${nameMatch[1]})`
: '';
newPhaseValue = `${phaseValue} of ${total}${nameStr}`;
} else if (nextPhaseDisplayName) {
newPhaseValue = `${phaseValue} — ${nextPhaseDisplayName}`;
}
}
stateContent = stateReplaceFieldWithFallback(
const completeResult = transitionCore(
stateContent,
'Current Phase',
'Phase',
newPhaseValue,
{
kind: 'completePhase',
phaseNum,
nextPhaseNum,
nextPhaseName: nextPhaseDisplayName,
isLastPhase,
planCount,
summaryCount,
},
{
clock: realClock,
progressProvider: () => null, // completePhase derives progress from the roadmap, not disk
roadmapProvider: () => roadmapContent,
},
);
if (nextPhaseDisplayName) {
stateContent =
stateReplaceField(stateContent, 'Current Phase Name', nextPhaseDisplayName) ||
stateContent;
}
stateContent = stateReplaceFieldWithFallback(
stateContent,
'Status',
null,
isLastPhase ? 'Milestone complete' : 'Ready to plan',
);
stateContent = stateReplaceFieldWithFallback(
stateContent,
'Current Plan',
'Plan',
'Not started',
);
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 =
stateReplaceField(stateContent, 'Last Activity Description', lastActivityDescription) ||
stateContent;
const completedRaw = stateExtractField(stateContent, 'Completed Phases');
if (completedRaw !== null) {
let newCompleted = parseInt(completedRaw, 10);
let derivedTotalPhases: number | null = null;
if (roadmapContent !== null) {
const derived = deriveProgressFromRoadmap(roadmapContent);
if (derived.completedPhases !== null) newCompleted = derived.completedPhases;
if (derived.totalPhases !== null) derivedTotalPhases = derived.totalPhases;
}
stateContent =
stateReplaceField(stateContent, 'Completed Phases', String(newCompleted)) ||
stateContent;
const totalRaw = stateExtractField(stateContent, 'Total Phases');
const totalPhases = derivedTotalPhases || (totalRaw ? parseInt(totalRaw, 10) : null);
if (totalPhases && totalPhases > 0) {
const newPercent = clampPercent(newCompleted, totalPhases);
stateContent =
stateReplaceField(stateContent, 'Progress', `${newPercent}%`) || stateContent;
stateContent = stateContent.replace(/(percent:\s*)\d+/, `$1${newPercent}`);
}
}
stateContent = completeResult.content;
stateContent = updatePerformanceMetricsSection(
stateContent,