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:
@@ -21,6 +21,7 @@ exports.transitionCore = transitionCore;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const frontmatter = require("./frontmatter.cjs");
|
||||
const state_document_cjs_1 = require("./state-document.cjs");
|
||||
const state_document_cjs_2 = require("./state-document.cjs");
|
||||
const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const phaseIdMod = require("./phase-id.cjs");
|
||||
@@ -113,6 +114,8 @@ function transitionCore(content, intent, deps) {
|
||||
switch (intent.kind) {
|
||||
case 'beginPhase':
|
||||
return beginPhaseCore(content, intent, deps);
|
||||
case 'advancePlan':
|
||||
return advancePlanCore(content, deps);
|
||||
}
|
||||
}
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -337,3 +340,152 @@ function stripFrontmatter(content) {
|
||||
}
|
||||
return result;
|
||||
}
|
||||
/**
|
||||
* Update fields within the ## Current Position section for advancePlan.
|
||||
* Mirrors `updateCurrentPositionFields` (state.cts:496) byte-for-behaviour:
|
||||
* only replaces Status / Last Activity when the existing value is a known
|
||||
* template default (Knuth invariant: preserve executor-authored values).
|
||||
* Plan is always replaced (system-derived, never executor-authored).
|
||||
*
|
||||
* Cannot import `updateCurrentPositionFields` from state.cjs directly (circular
|
||||
* dep: state.cjs → state-transition.cjs → state.cjs), so the mutation is
|
||||
* inlined here using the same primitives.
|
||||
*/
|
||||
function mutateCurrentPositionForAdvance(content, fields, statusDefaults, lastActivityDefaults) {
|
||||
const span = locateCurrentPosition(content);
|
||||
if (span === null)
|
||||
return content;
|
||||
let sectionBody = content.slice(span.start, span.end);
|
||||
let mutated = false;
|
||||
if (fields.status) {
|
||||
const replaced = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(sectionBody, 'Status', statusDefaults, fields.status);
|
||||
if (replaced !== null && replaced !== sectionBody) {
|
||||
sectionBody = replaced;
|
||||
mutated = true;
|
||||
}
|
||||
}
|
||||
if (fields.lastActivity) {
|
||||
const replaced = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(sectionBody, 'Last Activity', lastActivityDefaults, fields.lastActivity) ??
|
||||
(0, state_document_cjs_1.stateReplaceFieldIfTemplate)(sectionBody, 'Last activity', lastActivityDefaults, fields.lastActivity);
|
||||
if (replaced !== null && replaced !== sectionBody) {
|
||||
sectionBody = replaced;
|
||||
mutated = true;
|
||||
}
|
||||
}
|
||||
if (fields.plan) {
|
||||
// Plan is always replaced — system-derived, not executor-authored.
|
||||
if (/^Plan:/m.test(sectionBody)) {
|
||||
sectionBody = sectionBody.replace(/^Plan:.*$/m, `Plan: ${fields.plan}`);
|
||||
mutated = true;
|
||||
}
|
||||
else {
|
||||
const replaced = (0, state_document_cjs_1.stateReplaceField)(sectionBody, 'Plan', fields.plan);
|
||||
if (replaced !== null) {
|
||||
sectionBody = replaced;
|
||||
mutated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!mutated)
|
||||
return content;
|
||||
return content.slice(0, span.start) + sectionBody + content.slice(span.end);
|
||||
}
|
||||
// ----------------------------------------------------------------------------
|
||||
// advancePlan — intent implementation (Phase 2)
|
||||
// ----------------------------------------------------------------------------
|
||||
/**
|
||||
* Apply an `advancePlan` transition to STATE.md content.
|
||||
*
|
||||
* Parses Current Plan / Total Plans (legacy separate fields or compound
|
||||
* "Plan: X of Y" format), increments the plan number, updates body fields
|
||||
* and the ## Current Position section. When currentPlan >= totalPlans,
|
||||
* takes the phase-complete branch (sets Status to "Phase complete — ready
|
||||
* for verification") instead of advancing.
|
||||
*
|
||||
* Uses `stateReplaceFieldIfTemplate` (template-default-aware) to preserve
|
||||
* executor-authored field values (Knuth invariant from cmdStateAdvancePlan).
|
||||
*
|
||||
* Returns `data.advanced` / `data.currentPlan` / `data.totalPlans` for the
|
||||
* adapter to construct CLI output.
|
||||
*/
|
||||
function advancePlanCore(content, deps) {
|
||||
const today = deps.clock.today();
|
||||
// #1255: body-field replacements operate on body only (frontmatter stripped),
|
||||
// not on the full content. The YAML `status:` key matches `^Status:\s*`
|
||||
// before the body field if full content is passed (codex Phase 2 review:
|
||||
// HIGH blocking finding — same pattern beginPhaseCore already handles).
|
||||
const existingFm = extractFrontmatter(content);
|
||||
const hasFrontmatter = Object.keys(existingFm).length > 0;
|
||||
let body = stripFrontmatter(content);
|
||||
const reassemble = (b) => hasFrontmatter
|
||||
? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${b}`
|
||||
: b;
|
||||
// Parse plan number — legacy first, then compound.
|
||||
const legacyPlan = (0, state_document_cjs_1.stateExtractField)(content, 'Current Plan');
|
||||
const legacyTotal = (0, state_document_cjs_1.stateExtractField)(content, 'Total Plans in Phase');
|
||||
const planField = (0, state_document_cjs_1.stateExtractField)(content, 'Plan');
|
||||
let currentPlan;
|
||||
let totalPlans;
|
||||
let useCompoundFormat = false;
|
||||
if (legacyPlan && legacyTotal) {
|
||||
currentPlan = parseInt(legacyPlan, 10);
|
||||
totalPlans = parseInt(legacyTotal, 10);
|
||||
}
|
||||
else if (planField) {
|
||||
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)) {
|
||||
return { content: reassemble(body), updated: [], data: { error: true } };
|
||||
}
|
||||
const updated = [];
|
||||
const statusDefaults = state_document_cjs_2.KNOWN_TEMPLATE_DEFAULTS['Status'];
|
||||
const lastActivityDefaults = state_document_cjs_2.KNOWN_TEMPLATE_DEFAULTS['Last Activity'];
|
||||
if (currentPlan >= totalPlans) {
|
||||
// Phase-complete branch.
|
||||
body = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(body, 'Status', statusDefaults, 'Phase complete — ready for verification') || body;
|
||||
body = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(body, 'Last Activity', lastActivityDefaults, today) || body;
|
||||
body = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(body, 'Last activity', lastActivityDefaults, today) || body;
|
||||
body = mutateCurrentPositionForAdvance(body, {
|
||||
status: 'Phase complete — ready for verification',
|
||||
lastActivity: today,
|
||||
}, statusDefaults, lastActivityDefaults);
|
||||
updated.push('Status', 'Last Activity', 'Current Position');
|
||||
return {
|
||||
content: reassemble(body),
|
||||
updated,
|
||||
data: { advanced: false, reason: 'last_plan', current_plan: currentPlan, total_plans: totalPlans, status: 'ready_for_verification' },
|
||||
};
|
||||
}
|
||||
// Normal advance branch.
|
||||
const newPlan = currentPlan + 1;
|
||||
let planDisplayValue;
|
||||
if (useCompoundFormat) {
|
||||
planDisplayValue = planField.replace(/^\d+/, String(newPlan));
|
||||
body = (0, state_document_cjs_1.stateReplaceField)(body, 'Plan', planDisplayValue) || body;
|
||||
}
|
||||
else {
|
||||
planDisplayValue = `${newPlan} of ${totalPlans}`;
|
||||
body = (0, state_document_cjs_1.stateReplaceField)(body, 'Current Plan', String(newPlan)) || body;
|
||||
}
|
||||
body = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(body, 'Status', statusDefaults, 'Ready to execute') || body;
|
||||
body = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(body, 'Last Activity', lastActivityDefaults, today) || body;
|
||||
body = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(body, 'Last activity', lastActivityDefaults, today) || body;
|
||||
body = mutateCurrentPositionForAdvance(body, {
|
||||
status: 'Ready to execute',
|
||||
lastActivity: today,
|
||||
plan: planDisplayValue,
|
||||
}, statusDefaults, lastActivityDefaults);
|
||||
updated.push('Current Plan', 'Status', 'Last Activity', 'Current Position');
|
||||
return {
|
||||
content: reassemble(body),
|
||||
updated,
|
||||
data: { advanced: true, previous_plan: currentPlan, current_plan: newPlan, total_plans: totalPlans },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import frontmatter = require('./frontmatter.cjs');
|
||||
import { stateReplaceField, stateExtractField } from './state-document.cjs';
|
||||
import { stateReplaceField, stateExtractField, stateReplaceFieldIfTemplate } from './state-document.cjs';
|
||||
import { KNOWN_TEMPLATE_DEFAULTS } from './state-document.cjs';
|
||||
import { tokenizeHeadings } from './markdown-sectionizer.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import phaseIdMod = require('./phase-id.cjs');
|
||||
@@ -149,10 +150,17 @@ export type StateTransitionDeps = {
|
||||
};
|
||||
|
||||
export type StateTransitionIntent =
|
||||
| { kind: 'beginPhase'; phaseNumber: string | number; phaseName: string | null; planCount: number | null };
|
||||
// Phases 2–7 add the remaining 9 intent kinds to this discriminated union.
|
||||
| { 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.
|
||||
|
||||
export type StateTransitionResult = { content: string; updated: string[] };
|
||||
export type StateTransitionResult = {
|
||||
content: string;
|
||||
updated: string[];
|
||||
/** Intent-specific output (e.g. advancePlan returns {advanced, currentPlan, totalPlans}).
|
||||
* Adapters read this to construct the CLI output shape. */
|
||||
data?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// transitionCore — pure dispatch (ADR-1769 §3)
|
||||
@@ -175,6 +183,8 @@ export function transitionCore(
|
||||
switch (intent.kind) {
|
||||
case 'beginPhase':
|
||||
return beginPhaseCore(content, intent, deps);
|
||||
case 'advancePlan':
|
||||
return advancePlanCore(content, deps);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -434,3 +444,161 @@ function stripFrontmatter(content: string): string {
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update fields within the ## Current Position section for advancePlan.
|
||||
* Mirrors `updateCurrentPositionFields` (state.cts:496) byte-for-behaviour:
|
||||
* only replaces Status / Last Activity when the existing value is a known
|
||||
* template default (Knuth invariant: preserve executor-authored values).
|
||||
* Plan is always replaced (system-derived, never executor-authored).
|
||||
*
|
||||
* Cannot import `updateCurrentPositionFields` from state.cjs directly (circular
|
||||
* dep: state.cjs → state-transition.cjs → state.cjs), so the mutation is
|
||||
* inlined here using the same primitives.
|
||||
*/
|
||||
function mutateCurrentPositionForAdvance(
|
||||
content: string,
|
||||
fields: { status?: string; lastActivity?: string; plan?: string },
|
||||
statusDefaults: string[] | null | undefined,
|
||||
lastActivityDefaults: string[] | null | undefined,
|
||||
): string {
|
||||
const span = locateCurrentPosition(content);
|
||||
if (span === null) return content;
|
||||
let sectionBody = content.slice(span.start, span.end);
|
||||
let mutated = false;
|
||||
|
||||
if (fields.status) {
|
||||
const replaced = stateReplaceFieldIfTemplate(sectionBody, 'Status', statusDefaults, fields.status);
|
||||
if (replaced !== null && replaced !== sectionBody) { sectionBody = replaced; mutated = true; }
|
||||
}
|
||||
|
||||
if (fields.lastActivity) {
|
||||
const replaced =
|
||||
stateReplaceFieldIfTemplate(sectionBody, 'Last Activity', lastActivityDefaults, fields.lastActivity) ??
|
||||
stateReplaceFieldIfTemplate(sectionBody, 'Last activity', lastActivityDefaults, fields.lastActivity);
|
||||
if (replaced !== null && replaced !== sectionBody) { sectionBody = replaced; mutated = true; }
|
||||
}
|
||||
|
||||
if (fields.plan) {
|
||||
// Plan is always replaced — system-derived, not executor-authored.
|
||||
if (/^Plan:/m.test(sectionBody)) {
|
||||
sectionBody = sectionBody.replace(/^Plan:.*$/m, `Plan: ${fields.plan}`);
|
||||
mutated = true;
|
||||
} else {
|
||||
const replaced = stateReplaceField(sectionBody, 'Plan', fields.plan);
|
||||
if (replaced !== null) { sectionBody = replaced; mutated = true; }
|
||||
}
|
||||
}
|
||||
|
||||
if (!mutated) return content;
|
||||
return content.slice(0, span.start) + sectionBody + content.slice(span.end);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// advancePlan — intent implementation (Phase 2)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Apply an `advancePlan` transition to STATE.md content.
|
||||
*
|
||||
* Parses Current Plan / Total Plans (legacy separate fields or compound
|
||||
* "Plan: X of Y" format), increments the plan number, updates body fields
|
||||
* and the ## Current Position section. When currentPlan >= totalPlans,
|
||||
* takes the phase-complete branch (sets Status to "Phase complete — ready
|
||||
* for verification") instead of advancing.
|
||||
*
|
||||
* Uses `stateReplaceFieldIfTemplate` (template-default-aware) to preserve
|
||||
* executor-authored field values (Knuth invariant from cmdStateAdvancePlan).
|
||||
*
|
||||
* Returns `data.advanced` / `data.currentPlan` / `data.totalPlans` for the
|
||||
* adapter to construct CLI output.
|
||||
*/
|
||||
function advancePlanCore(content: string, deps: StateTransitionDeps): StateTransitionResult {
|
||||
const today = deps.clock.today();
|
||||
|
||||
// #1255: body-field replacements operate on body only (frontmatter stripped),
|
||||
// not on the full content. The YAML `status:` key matches `^Status:\s*`
|
||||
// before the body field if full content is passed (codex Phase 2 review:
|
||||
// HIGH blocking finding — same pattern beginPhaseCore already handles).
|
||||
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;
|
||||
|
||||
// Parse plan number — legacy first, then compound.
|
||||
const legacyPlan = stateExtractField(content, 'Current Plan');
|
||||
const legacyTotal = stateExtractField(content, 'Total Plans in Phase');
|
||||
const planField = stateExtractField(content, 'Plan');
|
||||
|
||||
let currentPlan: number;
|
||||
let totalPlans: number;
|
||||
let useCompoundFormat = false;
|
||||
|
||||
if (legacyPlan && legacyTotal) {
|
||||
currentPlan = parseInt(legacyPlan, 10);
|
||||
totalPlans = parseInt(legacyTotal, 10);
|
||||
} else if (planField) {
|
||||
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)) {
|
||||
return { content: reassemble(body), updated: [], data: { error: true } };
|
||||
}
|
||||
|
||||
const updated: string[] = [];
|
||||
|
||||
const statusDefaults = KNOWN_TEMPLATE_DEFAULTS['Status'];
|
||||
const lastActivityDefaults = KNOWN_TEMPLATE_DEFAULTS['Last Activity'];
|
||||
|
||||
if (currentPlan >= totalPlans) {
|
||||
// Phase-complete branch.
|
||||
body = stateReplaceFieldIfTemplate(body, 'Status', statusDefaults, 'Phase complete — ready for verification') || body;
|
||||
body = stateReplaceFieldIfTemplate(body, 'Last Activity', lastActivityDefaults, today) || body;
|
||||
body = stateReplaceFieldIfTemplate(body, 'Last activity', lastActivityDefaults, today) || body;
|
||||
body = mutateCurrentPositionForAdvance(body, {
|
||||
status: 'Phase complete — ready for verification',
|
||||
lastActivity: today,
|
||||
}, statusDefaults, lastActivityDefaults);
|
||||
updated.push('Status', 'Last Activity', 'Current Position');
|
||||
return {
|
||||
content: reassemble(body),
|
||||
updated,
|
||||
data: { advanced: false, reason: 'last_plan', current_plan: currentPlan, total_plans: totalPlans, status: 'ready_for_verification' },
|
||||
};
|
||||
}
|
||||
|
||||
// Normal advance branch.
|
||||
const newPlan = currentPlan + 1;
|
||||
let planDisplayValue: string;
|
||||
if (useCompoundFormat) {
|
||||
planDisplayValue = (planField as string).replace(/^\d+/, String(newPlan));
|
||||
body = stateReplaceField(body, 'Plan', planDisplayValue) || body;
|
||||
} else {
|
||||
planDisplayValue = `${newPlan} of ${totalPlans}`;
|
||||
body = stateReplaceField(body, 'Current Plan', String(newPlan)) || body;
|
||||
}
|
||||
body = stateReplaceFieldIfTemplate(body, 'Status', statusDefaults, 'Ready to execute') || body;
|
||||
body = stateReplaceFieldIfTemplate(body, 'Last Activity', lastActivityDefaults, today) || body;
|
||||
body = stateReplaceFieldIfTemplate(body, 'Last activity', lastActivityDefaults, today) || body;
|
||||
body = mutateCurrentPositionForAdvance(body, {
|
||||
status: 'Ready to execute',
|
||||
lastActivity: today,
|
||||
plan: planDisplayValue,
|
||||
}, statusDefaults, lastActivityDefaults);
|
||||
updated.push('Current Plan', 'Status', 'Last Activity', 'Current Position');
|
||||
|
||||
return {
|
||||
content: reassemble(body),
|
||||
updated,
|
||||
data: { advanced: true, previous_plan: currentPlan, current_plan: newPlan, total_plans: totalPlans },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -415,3 +415,103 @@ describe('ADR-1769 Phase 1: property tests (RULESET.TESTS.property-based-testing
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ADR-1769 Phase 2: advancePlan transition', () => {
|
||||
const deps = { clock: fixedClock, progressProvider: noProgress };
|
||||
|
||||
test('advances Current Plan from N to N+1 (legacy format)', () => {
|
||||
const input = [
|
||||
'# Project State',
|
||||
'',
|
||||
'**Current Plan:** 02',
|
||||
'**Total Plans in Phase:** 05',
|
||||
'**Status:** Executing Phase 3',
|
||||
'**Last Activity:** 2026-06-26',
|
||||
'',
|
||||
'## Current Position',
|
||||
'',
|
||||
'Plan: 2 of 5',
|
||||
'Status: Executing Phase 3',
|
||||
'',
|
||||
].join('\n');
|
||||
const result = transitionCore(input, { kind: 'advancePlan' }, deps);
|
||||
assert.strictEqual(stateExtractField(result.content, 'Current Plan'), '3');
|
||||
assert.strictEqual(result.data && result.data.advanced, true);
|
||||
assert.strictEqual(result.data && result.data.current_plan, 3);
|
||||
assert.strictEqual(result.data && result.data.total_plans, 5);
|
||||
});
|
||||
|
||||
test('phase-complete branch when currentPlan >= totalPlans', () => {
|
||||
const input = [
|
||||
'# Project State',
|
||||
'',
|
||||
'**Current Plan:** 05',
|
||||
'**Total Plans in Phase:** 05',
|
||||
'**Status:** Executing Phase 3',
|
||||
'**Last Activity:** 2026-06-26',
|
||||
'',
|
||||
].join('\n');
|
||||
const result = transitionCore(input, { kind: 'advancePlan' }, deps);
|
||||
assert.strictEqual(result.data && result.data.advanced, false);
|
||||
assert.strictEqual(result.data && result.data.reason, 'last_plan');
|
||||
assert.strictEqual(result.data && result.data.status, 'ready_for_verification');
|
||||
});
|
||||
|
||||
test('error when plan fields are unparseable', () => {
|
||||
const input = '# Project State\n\nNo plan fields here.\n';
|
||||
const result = transitionCore(input, { kind: 'advancePlan' }, deps);
|
||||
assert.strictEqual(result.data && result.data.error, true);
|
||||
assert.deepStrictEqual(result.updated, []);
|
||||
});
|
||||
|
||||
test('compound format: "Plan: 2 of 6" preserves compound shape', () => {
|
||||
const input = [
|
||||
'# Project State',
|
||||
'',
|
||||
'**Plan:** 2 of 6',
|
||||
'**Status:** Executing Phase 3',
|
||||
'**Last Activity:** 2026-06-26',
|
||||
'',
|
||||
].join('\n');
|
||||
const result = transitionCore(input, { kind: 'advancePlan' }, deps);
|
||||
const plan = stateExtractField(result.content, 'Plan');
|
||||
assert.ok(/3 of 6/.test(plan || ''), `Plan should be "3 of 6"; got ${JSON.stringify(plan)}`);
|
||||
assert.strictEqual(result.data && result.data.advanced, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ADR-1769 Phase 2: advancePlan with frontmatter (#1255 pattern — codex review)', () => {
|
||||
const deps = { clock: fixedClock, progressProvider: noProgress };
|
||||
|
||||
test('advances plan correctly when STATE.md has YAML frontmatter (body Status not YAML status)', () => {
|
||||
const input = [
|
||||
'---',
|
||||
'status: Executing Phase 3',
|
||||
'current_phase: "03"',
|
||||
'---',
|
||||
'',
|
||||
'# Project State',
|
||||
'',
|
||||
'**Current Plan:** 02',
|
||||
'**Total Plans in Phase:** 05',
|
||||
'**Status:** Executing Phase 3',
|
||||
'**Last Activity:** 2026-06-26',
|
||||
'',
|
||||
'## Current Position',
|
||||
'',
|
||||
'Plan: 2 of 5',
|
||||
'Status: Executing Phase 3',
|
||||
'',
|
||||
].join('\n');
|
||||
const result = transitionCore(input, { kind: 'advancePlan' }, deps);
|
||||
// Body Current Plan must advance to 3.
|
||||
assert.strictEqual(stateExtractField(result.content, 'Current Plan'), '3');
|
||||
// Body Status must be updated (not the YAML status key).
|
||||
const bodyStatus = stateExtractField(result.content, 'Status');
|
||||
assert.ok(
|
||||
/Ready to execute/.test(bodyStatus || ''),
|
||||
`body Status should be "Ready to execute"; got ${JSON.stringify(bodyStatus)}`,
|
||||
);
|
||||
assert.strictEqual(result.data && result.data.advanced, true);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user