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,

View File

@@ -16,9 +16,10 @@
// eslint-disable-next-line @typescript-eslint/no-require-imports
import frontmatter = require('./frontmatter.cjs');
import { stateReplaceField, stateExtractField, stateReplaceFieldIfTemplate } from './state-document.cjs';
import { stateReplaceField, stateExtractField, stateReplaceFieldIfTemplate, stateReplaceFieldWithFallback } from './state-document.cjs';
import { KNOWN_TEMPLATE_DEFAULTS } from './state-document.cjs';
import { tokenizeHeadings } from './markdown-sectionizer.cjs';
import { deriveProgressFromRoadmap, clampPercent } from './phase-lifecycle.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseIdMod = require('./phase-id.cjs');
@@ -147,12 +148,31 @@ export type ProgressRecord = Record<string, unknown>;
export type StateTransitionDeps = {
progressProvider: () => ProgressRecord | null;
clock: { today: () => string; nowIso: () => string };
/**
* Roadmap content provider for transitions that re-derive milestone-wide
* progress from ROADMAP.md (completePhase). Optional: transitions that don't
* consult the roadmap ignore it. Injected (not imported) so the core stays
* pure and testable without disk I/O.
*/
roadmapProvider?: () => string | null;
};
export type StateTransitionIntent =
| { kind: 'beginPhase'; phaseNumber: string | number; phaseName: string | null; planCount: number | null }
| { kind: 'advancePlan' };
// Phases 3–7 add the remaining 8 intent kinds to this discriminated union.
| { kind: 'advancePlan' }
| {
kind: 'completePhase';
phaseNum: string;
/** Next phase number, or null when completing the final phase. */
nextPhaseNum: string | null;
/** Display name for the next phase (may be null when unknown). */
nextPhaseName: string | null;
/** True when this is the final phase in the milestone. */
isLastPhase: boolean;
planCount: number;
summaryCount: number;
};
// Phases 4–7 add the remaining intent kinds to this discriminated union.
export type StateTransitionResult = {
content: string;
@@ -185,6 +205,8 @@ export function transitionCore(
return beginPhaseCore(content, intent, deps);
case 'advancePlan':
return advancePlanCore(content, deps);
case 'completePhase':
return completePhaseCore(content, intent, deps);
}
}
@@ -602,3 +624,188 @@ function advancePlanCore(content: string, deps: StateTransitionDeps): StateTrans
data: { advanced: true, previous_plan: currentPlan, current_plan: newPlan, total_plans: totalPlans },
};
}
// ----------------------------------------------------------------------------
// completePhase — intent implementation (Phase 3)
// ----------------------------------------------------------------------------
/**
* Apply a `completePhase` transition to STATE.md content.
*
* Migrates the inline STATE.md transform that lived inside `cmdPhaseComplete`
* (phase.cts) onto the substrate. Owns the field-classification-governed body
* mutations: Current Phase (preserving the `of total` shape and phase name),
* Current Phase Name, Status (`Milestone complete` on the last phase, else
* `Ready to plan`), Current Plan (`Not started`), Last Activity + Description,
* and the Completed/Total Phases + Progress percent block (re-derived from the
* roadmap via the injected `roadmapProvider`).
*
* The adapter (`cmdPhaseComplete`) retains two concerns that are NOT pure field
* updates: `updatePerformanceMetricsSection` (a section table upsert) and
* `syncStateFrontmatter` (the disk-scan post-sync). It also retains the
* multi-file atomic transaction (`writePlanningFileSet`) that writes ROADMAP,
* REQUIREMENTS, and STATE together — `readModifyWriteStateMd` is not used here
* because STATE.md is committed atomically with the other two files.
*
* Behavior is byte-for-byte with the pre-migration `phase.cts:1671-1772` block
* (verified by characterization tests in tests/state-transition.test.cjs).
*/
function completePhaseCore(
content: string,
intent: {
kind: 'completePhase';
phaseNum: string;
nextPhaseNum: string | null;
nextPhaseName: string | null;
isLastPhase: boolean;
planCount: number;
summaryCount: number;
},
deps: StateTransitionDeps,
): StateTransitionResult {
const updated: string[] = [];
const today = deps.clock.today();
// Consult the field-classification table for the frontmatter keys this
// transition touches (same guard beginPhaseCore applies). A missing row is a
// substrate defect — fail loudly rather than silently re-encoding policy.
for (const fmKey of [
'current_phase',
'current_phase_name',
'status',
'current_plan',
'last_activity',
'last_activity_desc',
'progress',
]) {
const cls = getFieldClassification(fmKey);
if (cls === null) {
throw new Error(
`transitionCore completePhase: 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 (frontmatter stripped),
// so the YAML `status:` / `current_phase:` keys cannot shadow the body fields.
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;
// Current Phase — preserve the existing `of <total>` shape and the phase name
// in parens (mirrors phase.cts:1675-1697 byte-for-behaviour).
const phaseValue = intent.nextPhaseNum || intent.phaseNum;
const nextPhaseDisplayName = intent.nextPhaseName;
const existingPhaseField =
stateExtractField(body, 'Current Phase') || stateExtractField(body, '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}`;
}
}
const phaseAfter = stateReplaceFieldWithFallback(body, 'Current Phase', 'Phase', newPhaseValue);
if (phaseAfter !== body) {
body = phaseAfter;
updated.push('Current Phase');
}
// Current Phase Name — only written when a next-phase display name is known
// (#1743/#1695: classified curated/preserve-always, so an absent name does
// NOT clear an existing curated value).
if (nextPhaseDisplayName) {
const after = stateReplaceField(body, 'Current Phase Name', nextPhaseDisplayName);
if (after) {
body = after;
updated.push('Current Phase Name');
}
}
// Status — `Milestone complete` on the final phase, otherwise `Ready to plan`.
const statusValue = intent.isLastPhase ? 'Milestone complete' : 'Ready to plan';
const statusAfter = stateReplaceFieldWithFallback(body, 'Status', null, statusValue);
if (statusAfter !== body) {
body = statusAfter;
updated.push('Status');
}
// Current Plan — reset for the next phase.
const planAfter = stateReplaceFieldWithFallback(body, 'Current Plan', 'Plan', 'Not started');
if (planAfter !== body) {
body = planAfter;
updated.push('Current Plan');
}
// Last Activity — prefer the prose `Last activity:` line (date + narrative)
// when present, else the bold `Last Activity:` date field.
const lastActivityDescription = `Phase ${intent.phaseNum} complete${intent.nextPhaseNum ? `, transitioned to Phase ${intent.nextPhaseNum}` : ''}`;
if (/^Last activity:/m.test(body)) {
const after = stateReplaceField(body, 'Last activity', `${today} — ${lastActivityDescription}`);
if (after) {
body = after;
updated.push('Last Activity');
}
} else {
const after = stateReplaceField(body, 'Last Activity', today);
if (after) {
body = after;
updated.push('Last Activity');
}
}
const ladAfter = stateReplaceField(body, 'Last Activity Description', lastActivityDescription);
if (ladAfter) {
body = ladAfter;
updated.push('Last Activity Description');
}
// Progress block — re-derive completed/total phases from the roadmap when
// available (milestone-wide source of truth), then recompute the percent.
// Only runs when a Completed Phases field exists (the existing guard).
const completedRaw = stateExtractField(body, 'Completed Phases');
if (completedRaw !== null) {
let newCompleted = parseInt(completedRaw, 10);
let derivedTotalPhases: number | null = null;
const roadmapContent = deps.roadmapProvider ? deps.roadmapProvider() : null;
if (roadmapContent) {
const derived = deriveProgressFromRoadmap(roadmapContent);
if (derived.completedPhases !== null) newCompleted = derived.completedPhases;
if (derived.totalPhases !== null) derivedTotalPhases = derived.totalPhases;
}
const completedAfter = stateReplaceField(body, 'Completed Phases', String(newCompleted));
if (completedAfter) {
body = completedAfter;
updated.push('Completed Phases');
}
const totalRaw = stateExtractField(body, 'Total Phases');
const totalPhases = derivedTotalPhases || (totalRaw ? parseInt(totalRaw, 10) : null);
if (totalPhases && totalPhases > 0) {
const newPercent = clampPercent(newCompleted, totalPhases);
const progAfter = stateReplaceField(body, 'Progress', `${newPercent}%`);
if (progAfter) {
body = progAfter;
updated.push('Progress');
}
// Inline `percent:` token (frontmatter / progress sub-block).
body = body.replace(/(percent:\s*)\d+/, `$1${newPercent}`);
}
}
return { content: reassemble(body), updated };
}