Migrate cmdStatePlannedPhase and cmdStateMilestoneSwitch (state.cts) onto the STATE.md Transition Module substrate (ADR-1769, epic #1769). - Add {kind: 'plannedPhase'} and {kind: 'milestoneSwitch'} to StateTransitionIntent, with plannedPhaseCore and milestoneSwitchCore in src/state-transition.cts (consulting the field-classification table). - plannedPhaseCore owns the template-aware Status/Last Activity updates, Total Plans in Phase, Last Activity Description, and the Current Position section (via the inlined mutateCurrentPositionForAdvance twin). The adapter keeps the resync:false readModifyWriteStateMd wrapper (#500 RC1). - milestoneSwitchCore owns the new-milestone reset: rebuilt frontmatter (milestone/name/status='planning'/zeroed progress) + Current Position body reset. The adapter keeps acquireStateLock + platformWriteSync (NOT readModifyWriteStateMd — milestoneSwitch rebuilds frontmatter directly). - Collapse both callbacks to transitionCore dispatches. The now-dead updateCurrentPositionFields helper and KNOWN_STATUS_PATTERNS import are removed (their behavior lives in the transition module's mutateCurrentPositionForAdvance). - Characterization tests pin the template-aware preserve-authored invariant, Total Plans, Last Activity narrative, Current Position update, and the full milestone reset (frontmatter + position body + gsd_state_version preserve + Accumulated Context preserve). All 58 transition + 177 state + phase + bug-2630/bug-905 regression tests pass. Closes #1786
This commit is contained in:
@@ -211,7 +211,7 @@ alongside, leave callbacks — parallel worlds don't converge (ADR-857's failure
|
||||
| 1 | Substrate + `beginPhase` | #1771 | #1255, #1257, #3242 |
|
||||
| 2 | `advancePlan` | #1782 | — |
|
||||
| 3 | `completePhase` + `phase.cts:1770` | #1784 | — |
|
||||
| 4 | `plannedPhase` + `milestoneSwitch` | TBD | — |
|
||||
| 4 | `plannedPhase` + `milestoneSwitch` | #1786 | — |
|
||||
| 5 | `milestoneComplete` + `milestone.cts:352` | TBD | — |
|
||||
| 6 | `patch` | TBD | #1743, #1695 |
|
||||
| 7 | `sync`, `prune`, `update` | TBD | #1760, #1761 |
|
||||
|
||||
@@ -119,6 +119,10 @@ function transitionCore(content, intent, deps) {
|
||||
return advancePlanCore(content, deps);
|
||||
case 'completePhase':
|
||||
return completePhaseCore(content, intent, deps);
|
||||
case 'plannedPhase':
|
||||
return plannedPhaseCore(content, intent, deps);
|
||||
case 'milestoneSwitch':
|
||||
return milestoneSwitchCore(content, intent, deps);
|
||||
}
|
||||
}
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -652,3 +656,161 @@ function completePhaseCore(content, intent, deps) {
|
||||
}
|
||||
return { content: reassemble(body), updated };
|
||||
}
|
||||
// ----------------------------------------------------------------------------
|
||||
// plannedPhase — intent implementation (Phase 4)
|
||||
// ----------------------------------------------------------------------------
|
||||
/**
|
||||
* Apply a `plannedPhase` transition to STATE.md content.
|
||||
*
|
||||
* Migrates `cmdStatePlannedPhase` (state.cts) onto the substrate. Updates the
|
||||
* per-phase body fields after plan-phase runs: Status (template-aware — only
|
||||
* replaces handler-generated values, preserving executor-authored ones),
|
||||
* Total Plans in Phase, Last Activity (template-aware), Last Activity
|
||||
* Description, and the ## Current Position section. The adapter wraps this in
|
||||
* `readModifyWriteStateMd({ resync: false })` so the milestone-wide progress.*
|
||||
* frontmatter is NOT re-derived from a half-planned disk snapshot (#500 RC1).
|
||||
*
|
||||
* Uses `mutateCurrentPositionForAdvance` (the inlined twin of state.cts's
|
||||
* `updateCurrentPositionFields`) so the Knuth template-default invariant
|
||||
* applies inside the Current Position section too.
|
||||
*/
|
||||
function plannedPhaseCore(content, intent, deps) {
|
||||
const updated = [];
|
||||
const today = deps.clock.today();
|
||||
for (const fmKey of ['status', 'last_activity', 'last_activity_desc']) {
|
||||
const cls = getFieldClassification(fmKey);
|
||||
if (cls === null) {
|
||||
throw new Error(`transitionCore plannedPhase: 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.
|
||||
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;
|
||||
const statusDefaults = state_document_cjs_2.KNOWN_TEMPLATE_DEFAULTS['Status'];
|
||||
const lastActivityDefaults = state_document_cjs_2.KNOWN_TEMPLATE_DEFAULTS['Last Activity'];
|
||||
// Status — template-aware (preserve executor-authored values).
|
||||
const statusAfter = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(body, 'Status', statusDefaults, 'Ready to execute');
|
||||
if (statusAfter !== null && statusAfter !== body) {
|
||||
body = statusAfter;
|
||||
updated.push('Status');
|
||||
}
|
||||
// Total Plans in Phase — system-derived; always replaced when a count is given.
|
||||
if (intent.planCount !== null && intent.planCount !== undefined) {
|
||||
const result = (0, state_document_cjs_1.stateReplaceField)(body, 'Total Plans in Phase', String(intent.planCount));
|
||||
if (result) {
|
||||
body = result;
|
||||
updated.push('Total Plans in Phase');
|
||||
}
|
||||
}
|
||||
// Last Activity — template-aware.
|
||||
const lastActivityAfter = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(body, 'Last Activity', lastActivityDefaults, today);
|
||||
if (lastActivityAfter !== null && lastActivityAfter !== body) {
|
||||
body = lastActivityAfter;
|
||||
updated.push('Last Activity');
|
||||
}
|
||||
// Last Activity Description.
|
||||
const ladResult = (0, state_document_cjs_1.stateReplaceField)(body, 'Last Activity Description', `Phase ${intent.phaseNumber} planning complete — ${intent.planCount || '?'} plans ready`);
|
||||
if (ladResult) {
|
||||
body = ladResult;
|
||||
updated.push('Last Activity Description');
|
||||
}
|
||||
// ## Current Position section — Status + Last activity (template-aware).
|
||||
const beforePos = body;
|
||||
body = mutateCurrentPositionForAdvance(body, {
|
||||
status: 'Ready to execute',
|
||||
lastActivity: `${today} — Phase ${intent.phaseNumber} planning complete`,
|
||||
}, statusDefaults, lastActivityDefaults);
|
||||
if (body !== beforePos)
|
||||
updated.push('Current Position');
|
||||
return { content: reassemble(body), updated };
|
||||
}
|
||||
// ----------------------------------------------------------------------------
|
||||
// milestoneSwitch — intent implementation (Phase 4)
|
||||
// ----------------------------------------------------------------------------
|
||||
/**
|
||||
* Apply a `milestoneSwitch` transition to STATE.md content.
|
||||
*
|
||||
* Migrates `cmdStateMilestoneSwitch` (state.cts) onto the substrate. Resets
|
||||
* STATE.md for a new milestone cycle: rewrites the frontmatter (milestone,
|
||||
* milestone_name, status='planning', last_updated, last_activity, and the
|
||||
* progress block zeroed) and rewrites the ## Current Position body to the
|
||||
* "defining requirements" starting state. `gsd_state_version` is preserved.
|
||||
* Body content OUTSIDE Current Position (e.g. Accumulated Context) is
|
||||
* preserved.
|
||||
*
|
||||
* This is a destructive reset intent: it intentionally overwrites the curated
|
||||
* `progress` / `current_phase_name` fields (classified preserve-always) because
|
||||
* a new milestone starts from zero. That is the intent's contract, not a
|
||||
* violation of the field-classification table — the table governs the steady-
|
||||
* state RMW transitions; a milestone boundary is an explicit reset.
|
||||
*
|
||||
* The adapter wraps this in `acquireStateLock` + `platformWriteSync` (NOT
|
||||
* `readModifyWriteStateMd`) because milestoneSwitch rebuilds frontmatter
|
||||
* directly and must not run the steady-state `syncStateFrontmatter` post-sync.
|
||||
*/
|
||||
function milestoneSwitchCore(content, intent, deps) {
|
||||
const today = deps.clock.today();
|
||||
const updated = [
|
||||
'milestone',
|
||||
'milestone_name',
|
||||
'status',
|
||||
'last_updated',
|
||||
'last_activity',
|
||||
'progress',
|
||||
'Current Position',
|
||||
];
|
||||
const existingFm = extractFrontmatter(content);
|
||||
const body = stripFrontmatter(content);
|
||||
const resolvedName = (intent.name && intent.name.trim()) || 'milestone';
|
||||
// ## Current Position reset body (mirrors state.cts:2371-2375).
|
||||
const resetPositionBody = `\nPhase: Not started (defining requirements)\n` +
|
||||
`Plan: —\n` +
|
||||
`Status: Defining requirements\n` +
|
||||
`Last activity: ${today} — Milestone ${intent.version} started\n\n`;
|
||||
let newBody;
|
||||
const hs = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(body);
|
||||
const posIdx = hs.findIndex((h) => h.level === 2 && /^current\s+position$/i.test(h.text));
|
||||
if (posIdx !== -1) {
|
||||
const h = hs[posIdx];
|
||||
const lines = body.split('\n');
|
||||
const hl = lines[h.line - 1];
|
||||
const bodyStart = h.offset + hl.length + 1;
|
||||
let bodyEnd = body.length;
|
||||
for (let j = posIdx + 1; j < hs.length; j++) {
|
||||
if (STOP_H2_PLUS(hs[j].level)) {
|
||||
bodyEnd = hs[j].offset - 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
newBody = body.slice(0, bodyStart) + resetPositionBody + body.slice(bodyEnd);
|
||||
}
|
||||
else {
|
||||
const preface = body.trim().length > 0 ? body : '# Project State\n';
|
||||
newBody = `${preface.trimEnd()}\n\n## Current Position\n${resetPositionBody}`;
|
||||
}
|
||||
// Rebuilt frontmatter — curated fields are intentionally reset (milestone
|
||||
// boundary). gsd_state_version is preserved.
|
||||
const fm = {
|
||||
gsd_state_version: existingFm['gsd_state_version'] || '1.0',
|
||||
milestone: intent.version,
|
||||
milestone_name: resolvedName,
|
||||
status: 'planning',
|
||||
last_updated: deps.clock.nowIso(),
|
||||
last_activity: today,
|
||||
progress: {
|
||||
total_phases: 0,
|
||||
completed_phases: 0,
|
||||
total_plans: 0,
|
||||
completed_plans: 0,
|
||||
percent: 0,
|
||||
},
|
||||
};
|
||||
const yamlStr = reconstructFrontmatter(fm);
|
||||
const assembled = `---\n${yamlStr}\n---\n\n${newBody.replace(/^\n+/, '')}`;
|
||||
return { content: assembled, updated };
|
||||
}
|
||||
|
||||
@@ -171,8 +171,10 @@ export type StateTransitionIntent =
|
||||
isLastPhase: boolean;
|
||||
planCount: number;
|
||||
summaryCount: number;
|
||||
};
|
||||
// Phases 4–7 add the remaining intent kinds to this discriminated union.
|
||||
}
|
||||
| { kind: 'plannedPhase'; phaseNumber: string | number; planCount: number | null }
|
||||
| { kind: 'milestoneSwitch'; version: string; name: string };
|
||||
// Phases 5–7 add the remaining intent kinds to this discriminated union.
|
||||
|
||||
export type StateTransitionResult = {
|
||||
content: string;
|
||||
@@ -207,6 +209,10 @@ export function transitionCore(
|
||||
return advancePlanCore(content, deps);
|
||||
case 'completePhase':
|
||||
return completePhaseCore(content, intent, deps);
|
||||
case 'plannedPhase':
|
||||
return plannedPhaseCore(content, intent, deps);
|
||||
case 'milestoneSwitch':
|
||||
return milestoneSwitchCore(content, intent, deps);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -809,3 +815,198 @@ function completePhaseCore(
|
||||
|
||||
return { content: reassemble(body), updated };
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// plannedPhase — intent implementation (Phase 4)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Apply a `plannedPhase` transition to STATE.md content.
|
||||
*
|
||||
* Migrates `cmdStatePlannedPhase` (state.cts) onto the substrate. Updates the
|
||||
* per-phase body fields after plan-phase runs: Status (template-aware — only
|
||||
* replaces handler-generated values, preserving executor-authored ones),
|
||||
* Total Plans in Phase, Last Activity (template-aware), Last Activity
|
||||
* Description, and the ## Current Position section. The adapter wraps this in
|
||||
* `readModifyWriteStateMd({ resync: false })` so the milestone-wide progress.*
|
||||
* frontmatter is NOT re-derived from a half-planned disk snapshot (#500 RC1).
|
||||
*
|
||||
* Uses `mutateCurrentPositionForAdvance` (the inlined twin of state.cts's
|
||||
* `updateCurrentPositionFields`) so the Knuth template-default invariant
|
||||
* applies inside the Current Position section too.
|
||||
*/
|
||||
function plannedPhaseCore(
|
||||
content: string,
|
||||
intent: { kind: 'plannedPhase'; phaseNumber: string | number; planCount: number | null },
|
||||
deps: StateTransitionDeps,
|
||||
): StateTransitionResult {
|
||||
const updated: string[] = [];
|
||||
const today = deps.clock.today();
|
||||
|
||||
for (const fmKey of ['status', 'last_activity', 'last_activity_desc']) {
|
||||
const cls = getFieldClassification(fmKey);
|
||||
if (cls === null) {
|
||||
throw new Error(
|
||||
`transitionCore plannedPhase: 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.
|
||||
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;
|
||||
|
||||
const statusDefaults = KNOWN_TEMPLATE_DEFAULTS['Status'];
|
||||
const lastActivityDefaults = KNOWN_TEMPLATE_DEFAULTS['Last Activity'];
|
||||
|
||||
// Status — template-aware (preserve executor-authored values).
|
||||
const statusAfter = stateReplaceFieldIfTemplate(body, 'Status', statusDefaults, 'Ready to execute');
|
||||
if (statusAfter !== null && statusAfter !== body) {
|
||||
body = statusAfter;
|
||||
updated.push('Status');
|
||||
}
|
||||
|
||||
// Total Plans in Phase — system-derived; always replaced when a count is given.
|
||||
if (intent.planCount !== null && intent.planCount !== undefined) {
|
||||
const result = stateReplaceField(body, 'Total Plans in Phase', String(intent.planCount));
|
||||
if (result) {
|
||||
body = result;
|
||||
updated.push('Total Plans in Phase');
|
||||
}
|
||||
}
|
||||
|
||||
// Last Activity — template-aware.
|
||||
const lastActivityAfter = stateReplaceFieldIfTemplate(body, 'Last Activity', lastActivityDefaults, today);
|
||||
if (lastActivityAfter !== null && lastActivityAfter !== body) {
|
||||
body = lastActivityAfter;
|
||||
updated.push('Last Activity');
|
||||
}
|
||||
|
||||
// Last Activity Description.
|
||||
const ladResult = stateReplaceField(
|
||||
body,
|
||||
'Last Activity Description',
|
||||
`Phase ${intent.phaseNumber} planning complete — ${intent.planCount || '?'} plans ready`,
|
||||
);
|
||||
if (ladResult) {
|
||||
body = ladResult;
|
||||
updated.push('Last Activity Description');
|
||||
}
|
||||
|
||||
// ## Current Position section — Status + Last activity (template-aware).
|
||||
const beforePos = body;
|
||||
body = mutateCurrentPositionForAdvance(
|
||||
body,
|
||||
{
|
||||
status: 'Ready to execute',
|
||||
lastActivity: `${today} — Phase ${intent.phaseNumber} planning complete`,
|
||||
},
|
||||
statusDefaults,
|
||||
lastActivityDefaults,
|
||||
);
|
||||
if (body !== beforePos) updated.push('Current Position');
|
||||
|
||||
return { content: reassemble(body), updated };
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// milestoneSwitch — intent implementation (Phase 4)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Apply a `milestoneSwitch` transition to STATE.md content.
|
||||
*
|
||||
* Migrates `cmdStateMilestoneSwitch` (state.cts) onto the substrate. Resets
|
||||
* STATE.md for a new milestone cycle: rewrites the frontmatter (milestone,
|
||||
* milestone_name, status='planning', last_updated, last_activity, and the
|
||||
* progress block zeroed) and rewrites the ## Current Position body to the
|
||||
* "defining requirements" starting state. `gsd_state_version` is preserved.
|
||||
* Body content OUTSIDE Current Position (e.g. Accumulated Context) is
|
||||
* preserved.
|
||||
*
|
||||
* This is a destructive reset intent: it intentionally overwrites the curated
|
||||
* `progress` / `current_phase_name` fields (classified preserve-always) because
|
||||
* a new milestone starts from zero. That is the intent's contract, not a
|
||||
* violation of the field-classification table — the table governs the steady-
|
||||
* state RMW transitions; a milestone boundary is an explicit reset.
|
||||
*
|
||||
* The adapter wraps this in `acquireStateLock` + `platformWriteSync` (NOT
|
||||
* `readModifyWriteStateMd`) because milestoneSwitch rebuilds frontmatter
|
||||
* directly and must not run the steady-state `syncStateFrontmatter` post-sync.
|
||||
*/
|
||||
function milestoneSwitchCore(
|
||||
content: string,
|
||||
intent: { kind: 'milestoneSwitch'; version: string; name: string },
|
||||
deps: StateTransitionDeps,
|
||||
): StateTransitionResult {
|
||||
const today = deps.clock.today();
|
||||
const updated: string[] = [
|
||||
'milestone',
|
||||
'milestone_name',
|
||||
'status',
|
||||
'last_updated',
|
||||
'last_activity',
|
||||
'progress',
|
||||
'Current Position',
|
||||
];
|
||||
|
||||
const existingFm = extractFrontmatter(content) as Record<string, unknown>;
|
||||
const body = stripFrontmatter(content);
|
||||
const resolvedName = (intent.name && intent.name.trim()) || 'milestone';
|
||||
|
||||
// ## Current Position reset body (mirrors state.cts:2371-2375).
|
||||
const resetPositionBody =
|
||||
`\nPhase: Not started (defining requirements)\n` +
|
||||
`Plan: —\n` +
|
||||
`Status: Defining requirements\n` +
|
||||
`Last activity: ${today} — Milestone ${intent.version} started\n\n`;
|
||||
|
||||
let newBody: string;
|
||||
const hs = tokenizeHeadings(body);
|
||||
const posIdx = hs.findIndex((h) => h.level === 2 && /^current\s+position$/i.test(h.text));
|
||||
if (posIdx !== -1) {
|
||||
const h = hs[posIdx];
|
||||
const lines = body.split('\n');
|
||||
const hl = lines[h.line - 1];
|
||||
const bodyStart = h.offset + hl.length + 1;
|
||||
let bodyEnd = body.length;
|
||||
for (let j = posIdx + 1; j < hs.length; j++) {
|
||||
if (STOP_H2_PLUS(hs[j].level)) {
|
||||
bodyEnd = hs[j].offset - 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
newBody = body.slice(0, bodyStart) + resetPositionBody + body.slice(bodyEnd);
|
||||
} else {
|
||||
const preface = body.trim().length > 0 ? body : '# Project State\n';
|
||||
newBody = `${preface.trimEnd()}\n\n## Current Position\n${resetPositionBody}`;
|
||||
}
|
||||
|
||||
// Rebuilt frontmatter — curated fields are intentionally reset (milestone
|
||||
// boundary). gsd_state_version is preserved.
|
||||
const fm: Record<string, unknown> = {
|
||||
gsd_state_version: existingFm['gsd_state_version'] || '1.0',
|
||||
milestone: intent.version,
|
||||
milestone_name: resolvedName,
|
||||
status: 'planning',
|
||||
last_updated: deps.clock.nowIso(),
|
||||
last_activity: today,
|
||||
progress: {
|
||||
total_phases: 0,
|
||||
completed_phases: 0,
|
||||
total_plans: 0,
|
||||
completed_plans: 0,
|
||||
percent: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const yamlStr = reconstructFrontmatter(fm as unknown as Frontmatter);
|
||||
const assembled = `---\n${yamlStr}\n---\n\n${newBody.replace(/^\n+/, '')}`;
|
||||
return { content: assembled, updated };
|
||||
}
|
||||
|
||||
233
src/state.cts
233
src/state.cts
@@ -43,7 +43,6 @@ import {
|
||||
stateExtractField,
|
||||
stateReplaceField,
|
||||
KNOWN_TEMPLATE_DEFAULTS,
|
||||
KNOWN_STATUS_PATTERNS,
|
||||
stateReplaceFieldIfTemplate,
|
||||
} from './state-document.cjs';
|
||||
import { tokenizeHeadings } from './markdown-sectionizer.cjs';
|
||||
@@ -487,106 +486,6 @@ function stateReplaceFieldWithFallback(content: string, primary: string, fallbac
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update fields within the ## Current Position section of STATE.md.
|
||||
* This keeps the Current Position body in sync with the bold frontmatter fields.
|
||||
* Only updates fields that already exist in the section; does not add new lines.
|
||||
* Fixes #1365: advance-plan could not update Status/Last activity after begin-phase.
|
||||
*/
|
||||
function updateCurrentPositionFields(content: string, fields: { status?: string; lastActivity?: string; plan?: string }): string {
|
||||
// ADR-1372 T6: locate ## Current Position using tokenizeHeadings, extract the
|
||||
// untrimmed body span, apply field edits, then splice the modified body back in.
|
||||
// Stop predicate mirrors (?=\n##|$): any heading with level ≥ 2.
|
||||
const headings = tokenizeHeadings(content);
|
||||
const posIdx = headings.findIndex(h => h.level === 2 && /^current\s+position$/i.test(h.text));
|
||||
if (posIdx === -1) return content;
|
||||
|
||||
const posHeading = headings[posIdx];
|
||||
const lines = content.split('\n');
|
||||
const posHeadingLine = lines[posHeading.line - 1];
|
||||
const posBodyStart = posHeading.offset + posHeadingLine.length + 1;
|
||||
let posBodyEnd = content.length;
|
||||
for (let j = posIdx + 1; j < headings.length; j++) {
|
||||
if (STOP_H2_PLUS(headings[j].level)) {
|
||||
posBodyEnd = headings[j].offset - 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let posBody = content.slice(posBodyStart, posBodyEnd);
|
||||
const statusDefaults = KNOWN_TEMPLATE_DEFAULTS['Status'];
|
||||
const lastActivityDefaults = KNOWN_TEMPLATE_DEFAULTS['Last Activity'];
|
||||
|
||||
if (fields.status) {
|
||||
if (/^Status:/m.test(posBody)) {
|
||||
// Inline format: Status: value — only replace when the existing value is a
|
||||
// known template default (Knuth invariant: preserve executor-authored values).
|
||||
const existingStatusMatch = posBody.match(/^Status:\s*(.+)$/m);
|
||||
const existingStatus = existingStatusMatch ? existingStatusMatch[1].trim() : null;
|
||||
const isInList = existingStatus && statusDefaults.some(d => d.toLowerCase() === existingStatus.toLowerCase());
|
||||
const matchesPattern = existingStatus && KNOWN_STATUS_PATTERNS.some(p => p.test(existingStatus));
|
||||
const isDefault = !existingStatus || isInList || matchesPattern;
|
||||
if (isDefault) {
|
||||
posBody = posBody.replace(/^Status:.*$/m, `Status: ${fields.status}`);
|
||||
}
|
||||
} else {
|
||||
// Table format: | Status | value | — apply the same preserve-authored guard
|
||||
// as the inline branch: only overwrite a known template default.
|
||||
// (Finding 2 code-review: the table branch was unconditional before this fix.)
|
||||
const existingStatus = stateExtractField(posBody, 'Status');
|
||||
const isInList = existingStatus && statusDefaults.some(d => d.toLowerCase() === existingStatus.toLowerCase());
|
||||
const matchesPattern = existingStatus && KNOWN_STATUS_PATTERNS.some(p => p.test(existingStatus));
|
||||
const isDefault = !existingStatus || isInList || matchesPattern;
|
||||
if (isDefault) {
|
||||
const replaced = stateReplaceField(posBody, 'Status', fields.status);
|
||||
if (replaced !== null) posBody = replaced;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (fields.lastActivity) {
|
||||
if (/^Last activity:/im.test(posBody)) {
|
||||
// Inline format — only replace when the existing value is a known template
|
||||
// default (a bare ISO date). Executor-authored narrative prose is preserved.
|
||||
const existingActivityMatch = posBody.match(/^Last activity:\s*(.+)$/im);
|
||||
const existingActivity = existingActivityMatch ? existingActivityMatch[1].trim() : null;
|
||||
// A bare ISO date (YYYY-MM-DD with nothing after) is handler-generated.
|
||||
// A date with a narrative suffix (e.g. "2026-02-15 -- blocked by infra...")
|
||||
// was authored by the executor and must be preserved.
|
||||
const isDateShape = existingActivity && /^\d{4}-\d{2}-\d{2}$/.test(existingActivity);
|
||||
const inList = existingActivity && lastActivityDefaults.some(d => d.toLowerCase() === existingActivity.toLowerCase());
|
||||
const isDefault = !existingActivity || isDateShape || inList;
|
||||
if (isDefault) {
|
||||
posBody = posBody.replace(/^Last activity:.*$/im, `Last activity: ${fields.lastActivity}`);
|
||||
}
|
||||
} else {
|
||||
// Table format — apply the same preserve-authored guard as the inline branch:
|
||||
// only overwrite a bare ISO date or a known default; preserve narrative prose.
|
||||
// (Finding 2 code-review: the table branch was unconditional before this fix.)
|
||||
const existingActivity = stateExtractField(posBody, 'Last Activity')
|
||||
?? stateExtractField(posBody, 'Last activity');
|
||||
const isDateShape = existingActivity && /^\d{4}-\d{2}-\d{2}$/.test(existingActivity);
|
||||
const inList = existingActivity && lastActivityDefaults.some(d => d.toLowerCase() === existingActivity.toLowerCase());
|
||||
const isDefault = !existingActivity || isDateShape || inList;
|
||||
if (isDefault) {
|
||||
const replaced = stateReplaceField(posBody, 'Last Activity', fields.lastActivity)
|
||||
?? stateReplaceField(posBody, 'Last activity', fields.lastActivity);
|
||||
if (replaced !== null) posBody = replaced;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (fields.plan) {
|
||||
if (/^Plan:/m.test(posBody)) {
|
||||
posBody = posBody.replace(/^Plan:.*$/m, `Plan: ${fields.plan}`);
|
||||
} else {
|
||||
const replaced = stateReplaceField(posBody, 'Plan', fields.plan);
|
||||
if (replaced !== null) posBody = replaced;
|
||||
}
|
||||
}
|
||||
|
||||
// Splice the modified body back in place of the original untrimmed span.
|
||||
return content.slice(0, posBodyStart) + posBody + content.slice(posBodyEnd);
|
||||
}
|
||||
|
||||
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; }
|
||||
@@ -2286,60 +2185,29 @@ function cmdStatePlannedPhase(cwd: string, phaseNumber: string | number, planCou
|
||||
return;
|
||||
}
|
||||
|
||||
const today = realClock.today();
|
||||
const updated: string[] = [];
|
||||
// ADR-1769 Phase 4: dispatches to the STATE.md Transition Module. The RMW
|
||||
// callback that lived here (body strip/reassemble, template-aware Status +
|
||||
// Last Activity, Total Plans in Phase, Last Activity Description, Current
|
||||
// Position section update) is the pure `plannedPhaseCore` in
|
||||
// src/state-transition.cts, backed by the field-classification table.
|
||||
// resync:false is preserved: plan-phase must NOT re-derive milestone-wide
|
||||
// progress.* from a half-planned disk snapshot (#500 RC1). readModifyWriteStateMd
|
||||
// still owns the lock, the #1230 preservation, and the no-op write guard.
|
||||
const intent: StateTransitionIntent = {
|
||||
kind: 'plannedPhase',
|
||||
phaseNumber,
|
||||
planCount: planCount ?? null,
|
||||
};
|
||||
const deps: StateTransitionDeps = {
|
||||
clock: realClock,
|
||||
progressProvider: () => null,
|
||||
};
|
||||
|
||||
const statusDefaults = KNOWN_TEMPLATE_DEFAULTS['Status'];
|
||||
const lastActivityDefaults = KNOWN_TEMPLATE_DEFAULTS['Last Activity'];
|
||||
|
||||
// plan-phase updates per-phase body fields only. It must NOT resync the
|
||||
// milestone-wide progress.* frontmatter from a half-planned disk snapshot —
|
||||
// doing so tramples curated/known-good counters. Route through the body-only
|
||||
// write contract (resync:false), the same guard state.update uses. (#500 RC1)
|
||||
let updated: string[] = [];
|
||||
readModifyWriteStateMd(statePath, (content) => {
|
||||
// Bug #1257: all body-field replacements must operate on the body only
|
||||
// (frontmatter stripped), not on the full content. When the full content is
|
||||
// passed to stateReplaceFieldIfTemplate the YAML `status: planning` key matches
|
||||
// the plain-text pattern (`^Status:\s*`) before the body pipe-table row, so the
|
||||
// pipe-table `| Status | Planning |` cell is never updated and syncStateFrontmatter
|
||||
// re-derives 'planning' from the unchanged body — the status never advances.
|
||||
// (Mirrors the begin/complete-phase fix from #1255/#1256.)
|
||||
const existingFm = extractFrontmatter(content) as Record<string, unknown>;
|
||||
const hasFrontmatter = Object.keys(existingFm).length > 0;
|
||||
let body = stripFrontmatter(content);
|
||||
const reassemble = (b: string) =>
|
||||
hasFrontmatter ? `---\n${reconstructFrontmatter(existingFm as unknown as Frontmatter)}\n---\n\n${b}` : b;
|
||||
|
||||
// Update Status — only when the existing value is a known template default
|
||||
// (Knuth invariant: preserve executor-authored values).
|
||||
const newBody = stateReplaceFieldIfTemplate(body, 'Status', statusDefaults, 'Ready to execute');
|
||||
if (newBody !== body) { body = newBody; updated.push('Status'); }
|
||||
|
||||
// Update Total Plans in Phase
|
||||
if (planCount !== null && planCount !== undefined) {
|
||||
const result = stateReplaceField(body, 'Total Plans in Phase', String(planCount));
|
||||
if (result) { body = result; updated.push('Total Plans in Phase'); }
|
||||
}
|
||||
|
||||
// Update Last Activity — only when the existing value is a known template default
|
||||
{
|
||||
const after = stateReplaceFieldIfTemplate(body, 'Last Activity', lastActivityDefaults, today);
|
||||
if (after !== body) { body = after; updated.push('Last Activity'); }
|
||||
}
|
||||
|
||||
// Update Last Activity Description
|
||||
{
|
||||
const result = stateReplaceField(body, 'Last Activity Description', `Phase ${phaseNumber} planning complete — ${planCount || '?'} plans ready`);
|
||||
if (result) { body = result; updated.push('Last Activity Description'); }
|
||||
}
|
||||
|
||||
// Update Current Position section
|
||||
body = updateCurrentPositionFields(body, {
|
||||
status: 'Ready to execute',
|
||||
lastActivity: `${today} — Phase ${phaseNumber} planning complete`,
|
||||
});
|
||||
|
||||
return reassemble(body);
|
||||
const result = transitionCore(content, intent, deps);
|
||||
updated = result.updated;
|
||||
return result.content;
|
||||
}, cwd, { resync: false });
|
||||
|
||||
output({ updated, phase: phaseNumber, plan_count: planCount }, raw, updated.length > 0 ? 'true' : 'false');
|
||||
@@ -2358,58 +2226,21 @@ function cmdStateMilestoneSwitch(cwd: string, version: string | undefined, name:
|
||||
}
|
||||
const resolvedName = (name && String(name).trim()) || 'milestone';
|
||||
const statePath = planningPaths(cwd).state;
|
||||
const today = realClock.today();
|
||||
|
||||
// ADR-1769 Phase 4: dispatches to the STATE.md Transition Module. The reset
|
||||
// policy (frontmatter rebuild + Current Position body reset) is the pure
|
||||
// `milestoneSwitchCore` in src/state-transition.cts. acquireStateLock +
|
||||
// platformWriteSync are retained (NOT readModifyWriteStateMd) because
|
||||
// milestoneSwitch rebuilds frontmatter directly and must not run the
|
||||
// steady-state syncStateFrontmatter post-sync.
|
||||
const intent: StateTransitionIntent = { kind: 'milestoneSwitch', version, name: resolvedName };
|
||||
const deps: StateTransitionDeps = { clock: realClock, progressProvider: () => null };
|
||||
|
||||
const lockPath = acquireStateLock(statePath);
|
||||
try {
|
||||
const content = platformReadSync(statePath) || '';
|
||||
const existingFm = extractFrontmatter(content) as Record<string, unknown>;
|
||||
const body = stripFrontmatter(content);
|
||||
|
||||
// ADR-1372 T6: positionPattern → tokenizeHeadings + spliceStateSection.
|
||||
// Mirrors /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i; stop at level ≥ 2.
|
||||
const resetPositionBody =
|
||||
`\nPhase: Not started (defining requirements)\n` +
|
||||
`Plan: —\n` +
|
||||
`Status: Defining requirements\n` +
|
||||
`Last activity: ${today} — Milestone ${version} started\n\n`;
|
||||
let newBody: string;
|
||||
const msPosHs = tokenizeHeadings(body);
|
||||
const msPosIdx = msPosHs.findIndex(h => h.level === 2 && /^current\s+position$/i.test(h.text));
|
||||
if (msPosIdx !== -1) {
|
||||
const msPosH = msPosHs[msPosIdx];
|
||||
const msBodyLines = body.split('\n');
|
||||
const msPosHL = msBodyLines[msPosH.line - 1];
|
||||
const msPosBodyStart = msPosH.offset + msPosHL.length + 1;
|
||||
let msPosBodyEnd = body.length;
|
||||
for (let j = msPosIdx + 1; j < msPosHs.length; j++) {
|
||||
if (STOP_H2_PLUS(msPosHs[j].level)) { msPosBodyEnd = msPosHs[j].offset - 1; break; }
|
||||
}
|
||||
newBody = body.slice(0, msPosBodyStart) + resetPositionBody + body.slice(msPosBodyEnd);
|
||||
} else {
|
||||
const preface = body.trim().length > 0 ? body : '# Project State\n';
|
||||
newBody = `${preface.trimEnd()}\n\n## Current Position\n${resetPositionBody}`;
|
||||
}
|
||||
|
||||
const fm: Record<string, unknown> = {
|
||||
gsd_state_version: existingFm['gsd_state_version'] || '1.0',
|
||||
milestone: version,
|
||||
milestone_name: resolvedName,
|
||||
status: 'planning',
|
||||
last_updated: realClock.nowIso(),
|
||||
last_activity: today,
|
||||
progress: {
|
||||
total_phases: 0,
|
||||
completed_phases: 0,
|
||||
total_plans: 0,
|
||||
completed_plans: 0,
|
||||
percent: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const yamlStr = reconstructFrontmatter(fm as unknown as Frontmatter);
|
||||
const assembled = `---\n${yamlStr}\n---\n\n${newBody.replace(/^\n+/, '')}`;
|
||||
platformWriteSync(statePath, assembled);
|
||||
const result = transitionCore(content, intent, deps);
|
||||
platformWriteSync(statePath, result.content);
|
||||
output(
|
||||
{ switched: true, version, name: resolvedName, status: 'planning' },
|
||||
raw,
|
||||
|
||||
@@ -746,3 +746,172 @@ describe('ADR-1769 Phase 3: completePhase edge cases', () => {
|
||||
assert.strictEqual(stateExtractField(result.content, 'Current Phase'), '4');
|
||||
});
|
||||
});
|
||||
|
||||
// ADR-1769 Phase 4: plannedPhase + milestoneSwitch
|
||||
|
||||
function plannedPhaseBody() {
|
||||
return [
|
||||
'# Project State',
|
||||
'',
|
||||
'**Status:** Planning',
|
||||
'**Total Plans in Phase:** 0',
|
||||
'**Last Activity:** 2026-06-20',
|
||||
'**Last Activity Description:** previous planning',
|
||||
'',
|
||||
'## Current Position',
|
||||
'',
|
||||
'Phase: 3 (Test Phase) — EXECUTING',
|
||||
'Plan: —',
|
||||
'Status: Executing Phase 3',
|
||||
'Last activity: 2026-06-20 — mid-flight',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
describe('ADR-1769 Phase 4: plannedPhase transition — body field updates', () => {
|
||||
const deps = { clock: fixedClock, progressProvider: noProgress };
|
||||
|
||||
test('Status advances to "Ready to execute" when the existing value is a template default (Planning)', () => {
|
||||
const result = transitionCore(plannedPhaseBody(), { kind: 'plannedPhase', phaseNumber: 3, planCount: 4 }, deps);
|
||||
assert.strictEqual(stateExtractField(result.content, 'Status'), 'Ready to execute');
|
||||
assert.ok(result.updated.includes('Status'));
|
||||
});
|
||||
|
||||
test('Total Plans in Phase is set to planCount', () => {
|
||||
const result = transitionCore(plannedPhaseBody(), { kind: 'plannedPhase', phaseNumber: 3, planCount: 4 }, deps);
|
||||
assert.strictEqual(stateExtractField(result.content, 'Total Plans in Phase'), '4');
|
||||
assert.ok(result.updated.includes('Total Plans in Phase'));
|
||||
});
|
||||
|
||||
test('Last Activity is refreshed to clock.today() when the existing value is a date (template default)', () => {
|
||||
const result = transitionCore(plannedPhaseBody(), { kind: 'plannedPhase', phaseNumber: 3, planCount: 4 }, deps);
|
||||
assert.strictEqual(stateExtractField(result.content, 'Last Activity'), '2026-06-27');
|
||||
});
|
||||
|
||||
test('Last Activity Description carries the planning-complete narrative', () => {
|
||||
const result = transitionCore(plannedPhaseBody(), { kind: 'plannedPhase', phaseNumber: 3, planCount: 4 }, deps);
|
||||
assert.strictEqual(
|
||||
stateExtractField(result.content, 'Last Activity Description'),
|
||||
'Phase 3 planning complete — 4 plans ready',
|
||||
);
|
||||
});
|
||||
|
||||
test('Current Position Status + Last activity are updated', () => {
|
||||
const result = transitionCore(plannedPhaseBody(), { kind: 'plannedPhase', phaseNumber: 3, planCount: 4 }, deps);
|
||||
assert.ok(result.updated.includes('Current Position'),
|
||||
`updated should include Current Position; got ${JSON.stringify(result.updated)}`);
|
||||
// The Current Position section should now carry the planning-complete narrative.
|
||||
assert.ok(/Phase 3 planning complete/.test(result.content));
|
||||
});
|
||||
|
||||
test('executor-authored Status is preserved (Knuth invariant — non-template value not overwritten)', () => {
|
||||
const custom = plannedPhaseBody().replace('**Status:** Planning', '**Status:** Awaiting human design review');
|
||||
const result = transitionCore(custom, { kind: 'plannedPhase', phaseNumber: 3, planCount: 4 }, deps);
|
||||
assert.strictEqual(stateExtractField(result.content, 'Status'), 'Awaiting human design review');
|
||||
assert.ok(!result.updated.includes('Status'),
|
||||
`Status must not be in updated for an executor-authored value; got ${JSON.stringify(result.updated)}`);
|
||||
});
|
||||
|
||||
test('planCount=null leaves Total Plans in Phase untouched', () => {
|
||||
const result = transitionCore(plannedPhaseBody(), { kind: 'plannedPhase', phaseNumber: 3, planCount: null }, deps);
|
||||
assert.strictEqual(stateExtractField(result.content, 'Total Plans in Phase'), '0');
|
||||
assert.ok(!result.updated.includes('Total Plans in Phase'));
|
||||
});
|
||||
|
||||
test('frontmatter is preserved and body Status (not YAML status) is updated (#1255)', () => {
|
||||
const input = [
|
||||
'---',
|
||||
'status: planning',
|
||||
'---',
|
||||
'',
|
||||
'# Project State',
|
||||
'',
|
||||
'**Status:** Planning',
|
||||
'**Total Plans in Phase:** 0',
|
||||
'**Last Activity:** 2026-06-20',
|
||||
'**Last Activity Description:** prev',
|
||||
'',
|
||||
'## Current Position',
|
||||
'',
|
||||
'Status: Executing Phase 3',
|
||||
'Last activity: 2026-06-20 — mid',
|
||||
'',
|
||||
].join('\n');
|
||||
const result = transitionCore(input, { kind: 'plannedPhase', phaseNumber: 3, planCount: 2 }, deps);
|
||||
assert.strictEqual(stateExtractField(result.content, 'Status'), 'Ready to execute');
|
||||
assert.ok(/^---\r?\n[\s\S]*?\r?\n---/.test(result.content), 'frontmatter block preserved');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ADR-1769 Phase 4: milestoneSwitch transition — milestone reset', () => {
|
||||
const deps = { clock: fixedClock, progressProvider: noProgress };
|
||||
|
||||
function milestoneBody() {
|
||||
return [
|
||||
'---',
|
||||
'gsd_state_version: 1.0',
|
||||
'milestone: v1.0',
|
||||
'milestone_name: Old Milestone',
|
||||
'status: executing',
|
||||
'current_phase: "3"',
|
||||
'progress:',
|
||||
' total_phases: 5',
|
||||
' completed_phases: 2',
|
||||
' percent: 40',
|
||||
'---',
|
||||
'',
|
||||
'# Project State',
|
||||
'',
|
||||
'## Current Position',
|
||||
'',
|
||||
'Phase: 3 — EXECUTING',
|
||||
'Plan: 2 of 5',
|
||||
'Status: Executing Phase 3',
|
||||
'Last activity: 2026-06-20 — mid-flight',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
test('frontmatter milestone + milestone_name are reset to the new version', () => {
|
||||
const result = transitionCore(milestoneBody(), { kind: 'milestoneSwitch', version: 'v2.0', name: 'New Milestone' }, deps);
|
||||
const fmLine = (key) => result.content.split('\n').find((l) => new RegExp(`^${key}:`).test(l));
|
||||
assert.strictEqual(fmLine('milestone'), 'milestone: v2.0');
|
||||
assert.strictEqual(fmLine('milestone_name'), 'milestone_name: New Milestone');
|
||||
});
|
||||
|
||||
test('frontmatter status resets to planning and progress resets to zero', () => {
|
||||
const result = transitionCore(milestoneBody(), { kind: 'milestoneSwitch', version: 'v2.0', name: 'New Milestone' }, deps);
|
||||
assert.strictEqual(result.content.split('\n').find((l) => /^status:/.test(l)), 'status: planning');
|
||||
assert.ok(/total_phases:\s*0/.test(result.content), 'total_phases should reset to 0');
|
||||
assert.ok(/completed_phases:\s*0/.test(result.content), 'completed_phases should reset to 0');
|
||||
assert.ok(/percent:\s*0/.test(result.content), 'percent should reset to 0');
|
||||
});
|
||||
|
||||
test('gsd_state_version is preserved across the reset', () => {
|
||||
const result = transitionCore(milestoneBody(), { kind: 'milestoneSwitch', version: 'v2.0', name: 'New Milestone' }, deps);
|
||||
assert.ok(/gsd_state_version:\s*1\.0/.test(result.content), 'gsd_state_version must be preserved');
|
||||
});
|
||||
|
||||
test('Current Position section is reset to "Not started (defining requirements)"', () => {
|
||||
const result = transitionCore(milestoneBody(), { kind: 'milestoneSwitch', version: 'v2.0', name: 'New Milestone' }, deps);
|
||||
assert.ok(/Phase: Not started \(defining requirements\)/.test(result.content));
|
||||
assert.ok(/Status: Defining requirements/.test(result.content));
|
||||
assert.ok(new RegExp(`Last activity: 2026-06-27 — Milestone v2.0 started`).test(result.content));
|
||||
});
|
||||
|
||||
test('Accumulated Context / body content outside Current Position is preserved', () => {
|
||||
const input = milestoneBody() +
|
||||
'\n## Accumulated Context\n\n- An important decision we must keep.\n';
|
||||
const result = transitionCore(input, { kind: 'milestoneSwitch', version: 'v2.0', name: 'New Milestone' }, deps);
|
||||
assert.ok(/An important decision we must keep/.test(result.content),
|
||||
'Accumulated Context must survive the milestone reset');
|
||||
});
|
||||
|
||||
test('blank name falls back to the "milestone" placeholder', () => {
|
||||
const result = transitionCore(milestoneBody(), { kind: 'milestoneSwitch', version: 'v2.0', name: '' }, deps);
|
||||
assert.strictEqual(
|
||||
result.content.split('\n').find((l) => /^milestone_name:/.test(l)),
|
||||
'milestone_name: milestone',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user