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:
112
src/phase.cts
112
src/phase.cts
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user