refactor(#1782): ADR-1769 Phase 2 — advancePlan migration onto Transition Module (#1783)

Migrates cmdStateAdvancePlan (~80-line RMW callback) onto the
transitionCore dispatch established in Phase 1 (#1775):

- src/state-transition.cts:
  - Add {kind: 'advancePlan'} to StateTransitionIntent union
  - Extend StateTransitionResult with optional data field for
    intent-specific output (advanced, currentPlan, totalPlans)
  - advancePlanCore: parses legacy + compound plan formats, handles
    advance vs phase-complete branching, strips frontmatter before
    body mutation (#1255 pattern — codex Phase 2 HIGH finding),
    uses stateReplaceFieldIfTemplate for template-default-aware
    field replacement (Knuth invariant), mutates ## Current Position
    section via mutateCurrentPositionForAdvance
  - mutateCurrentPositionForAdvance: inlined section mutation (avoids
    circular dep with state.cjs's updateCurrentPositionFields)

- src/state.cts:cmdStateAdvancePlan: collapsed to 30-line dispatch

- tests/state-transition.test.cjs: 5 characterization tests
  (advance, phase-complete, error, compound format, frontmatter #1255)

Codex gpt-5.5/high review: 1 HIGH blocking (frontmatter strip — fixed),
1 medium follow-up (StateTransitionResult.data shape discrimination —
noted for Phases 3-7).

gsd-test: 21893/21893 PASS (Linux docker).

Closes #1782
This commit is contained in:
Tom Boucher
2026-06-27 14:22:14 -04:00
committed by GitHub
parent 744bb7aaee
commit 1512e6f415
4 changed files with 442 additions and 69 deletions

View File

@@ -591,80 +591,33 @@ function cmdStateAdvancePlan(cwd: string, raw: boolean): void {
const statePath = planningPaths(cwd).state;
if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw, undefined); return; }
const today = realClock.today();
let result: Record<string, unknown> | null = null;
// ADR-1769 Phase 2: dispatches to the STATE.md Transition Module. The
// ~80-line RMW callback that used to live here (plan parsing, advance vs
// phase-complete branching, template-default-aware field replacement,
// Current Position section mutation) is now the pure `advancePlanCore`
// function in src/state-transition.cts.
const intent: StateTransitionIntent = { kind: 'advancePlan' };
const deps: StateTransitionDeps = {
clock: realClock,
progressProvider: () => null,
};
let resultData: Record<string, unknown> | undefined;
readModifyWriteStateMd(statePath, (content) => {
// Try legacy separate fields first, then compound "Plan: X of Y" format
const legacyPlan = stateExtractField(content, 'Current Plan');
const legacyTotal = stateExtractField(content, 'Total Plans in Phase');
const planField = stateExtractField(content, 'Plan');
let currentPlan: number, totalPlans: number;
let useCompoundFormat = false;
if (legacyPlan && legacyTotal) {
currentPlan = parseInt(legacyPlan, 10);
totalPlans = parseInt(legacyTotal, 10);
} else if (planField) {
// Compound format: "2 of 6 in current phase" or "2 of 6"
currentPlan = parseInt(planField, 10);
const ofMatch = planField.match(/of\s+(\d+)/);
totalPlans = ofMatch ? parseInt(ofMatch[1], 10) : NaN;
useCompoundFormat = true;
} else {
currentPlan = NaN;
totalPlans = NaN;
}
if (isNaN(currentPlan) || isNaN(totalPlans)) {
result = { error: true };
return content;
}
const statusDefaults = KNOWN_TEMPLATE_DEFAULTS['Status'];
const lastActivityDefaults = KNOWN_TEMPLATE_DEFAULTS['Last Activity'];
if (currentPlan >= totalPlans) {
// Phase-complete branch — only replace Status/Last Activity when the existing
// value is a known template default (Knuth invariant: preserve executor-authored).
content = stateReplaceFieldIfTemplate(content, 'Status', statusDefaults, 'Phase complete — ready for verification');
content = stateReplaceFieldIfTemplate(content, 'Last Activity', lastActivityDefaults, today);
// stateReplaceFieldWithFallback tries 'Last activity' alias too
content = stateReplaceFieldIfTemplate(content, 'Last activity', lastActivityDefaults, today);
content = updateCurrentPositionFields(content, { status: 'Phase complete — ready for verification', lastActivity: today });
result = { advanced: false, reason: 'last_plan', current_plan: currentPlan, total_plans: totalPlans, status: 'ready_for_verification' };
} else {
const newPlan = currentPlan + 1;
let planDisplayValue: string;
if (useCompoundFormat) {
// Preserve compound format: "X of Y in current phase" → replace X only
planDisplayValue = (planField as string).replace(/^\d+/, String(newPlan));
content = stateReplaceField(content, 'Plan', planDisplayValue) || content;
} else {
planDisplayValue = `${newPlan} of ${totalPlans}`;
content = stateReplaceField(content, 'Current Plan', String(newPlan)) || content;
}
// Normal advance — only replace Status/Last Activity when the existing value is
// a known template default (Knuth invariant: preserve executor-authored).
content = stateReplaceFieldIfTemplate(content, 'Status', statusDefaults, 'Ready to execute');
content = stateReplaceFieldIfTemplate(content, 'Last Activity', lastActivityDefaults, today);
content = stateReplaceFieldIfTemplate(content, 'Last activity', lastActivityDefaults, today);
content = updateCurrentPositionFields(content, { status: 'Ready to execute', lastActivity: today, plan: planDisplayValue });
result = { advanced: true, previous_plan: currentPlan, current_plan: newPlan, total_plans: totalPlans };
}
return content;
const result = transitionCore(content, intent, deps);
resultData = result.data;
return result.content;
}, cwd);
if (!result || (result as Record<string, unknown>)['error']) {
if (!resultData || resultData['error']) {
output({ error: 'Cannot parse Current Plan or Total Plans in Phase from STATE.md' }, raw, undefined);
return;
}
if ((result as Record<string, unknown>)['advanced'] === false) {
output(result, raw, 'false');
if (resultData['advanced'] === false) {
output(resultData, raw, 'false');
} else {
output(result, raw, 'true');
output(resultData, raw, 'true');
}
}