diff --git a/docs/adr/1769-state-md-transition-module.md b/docs/adr/1769-state-md-transition-module.md index e4bea5f36..7751fefcc 100644 --- a/docs/adr/1769-state-md-transition-module.md +++ b/docs/adr/1769-state-md-transition-module.md @@ -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 | diff --git a/gsd-core/bin/lib/state-transition.cjs b/gsd-core/bin/lib/state-transition.cjs index fad501b62..704d7bc4a 100644 --- a/gsd-core/bin/lib/state-transition.cjs +++ b/gsd-core/bin/lib/state-transition.cjs @@ -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 }; +} diff --git a/src/state-transition.cts b/src/state-transition.cts index df5204c5e..6121f53da 100644 --- a/src/state-transition.cts +++ b/src/state-transition.cts @@ -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; + 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; + 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 = { + 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 }; +} diff --git a/src/state.cts b/src/state.cts index 3f9979986..935628a2b 100644 --- a/src/state.cts +++ b/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; - 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; - 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 = { - 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, diff --git a/tests/state-transition.test.cjs b/tests/state-transition.test.cjs index dcdf4fa29..5f2847440 100644 --- a/tests/state-transition.test.cjs +++ b/tests/state-transition.test.cjs @@ -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', + ); + }); +});