From 79c69d443bc7c3bd6f0a59030263b25f58bd8c30 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 27 Jun 2026 16:53:44 -0400 Subject: [PATCH] =?UTF-8?q?refactor(#1793):=20ADR-1769=20Phase=207=20?= =?UTF-8?q?=E2=80=94=20sync,=20prune,=20update=20migrations=20(#1760,=20#1?= =?UTF-8?q?761)=20(#1794)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate cmdStateSync, cmdStatePrune, cmdStateUpdate (state.cts) onto the STATE.md Transition Module substrate and close the maintenance bug pair #1760/#1761 (ADR-1769, epic #1769). Completes the substrate: all 10 lifecycle/maintenance transitions now route through transitionCore. - Add {kind: 'sync'|'prune'|'update'} to StateTransitionIntent with syncCore, pruneCore, updateCore in src/state-transition.cts. - updateCore: body-only single-field update (strip/reassemble), mirroring the pre-migration cmdStateUpdate contract. - pruneCore: pure content→content section pruning (Decisions / Recently Completed / resolved Blockers / Performance Metrics rows at or below cutoff), byte-identical tokenizeHeadings splicing. Adapter owns currentPhase, dry-run, and STATE-ARCHIVE.md. - syncCore: body writes (Total Plans in Phase, Progress bar, Last Activity) given disk-derived numbers. Adapter owns the disk scan + roadmap scope. - #1760: cmdStatePrune now derives currentPhase from 'Current Phase' OR 'Phase' (the canonical template emits 'Phase: X of Y'), so prune engages on template-conformant STATE.md instead of bailing 'Only 0 phases'. - #1761: cmdStateSync skips the Progress write (percent=null) when a milestone version is set in frontmatter but the ROADMAP has no versioned heading for it (milestone cannot be bounded). Projects without a milestone version are unaffected. Leaves progress untouched rather than silently writing fallback- derived wrong values. - Regression: bug-1760 (prune engages on template field) + bug-1761 (sync leaves progress untouched when unbounded). ADR-1769 marked Accepted. All 82 transition + 515 regression tests pass. Closes #1793 --- docs/adr/1769-state-md-transition-module.md | 6 +- gsd-core/bin/lib/state-transition.cjs | 202 +++++++++++++++ .../lint-regression-test-names.allowlist.json | 2 + scripts/lint-test-file-count.allowlist.json | 2 + src/state-transition.cts | 243 +++++++++++++++++- src/state.cts | 220 +++++----------- ...0-state-prune-noop-template-field.test.cjs | 80 ++++++ ...ug-1761-state-sync-wrong-progress.test.cjs | 89 +++++++ tests/state-transition.test.cjs | 125 +++++++++ 9 files changed, 804 insertions(+), 165 deletions(-) create mode 100644 tests/bug-1760-state-prune-noop-template-field.test.cjs create mode 100644 tests/bug-1761-state-sync-wrong-progress.test.cjs diff --git a/docs/adr/1769-state-md-transition-module.md b/docs/adr/1769-state-md-transition-module.md index 2b8184b1c..da6c3c002 100644 --- a/docs/adr/1769-state-md-transition-module.md +++ b/docs/adr/1769-state-md-transition-module.md @@ -1,7 +1,7 @@ # ADR-1769: STATE.md Transition Module — intent-based transitions over scattered RMW callbacks -- **Status:** Proposed (Phase 0); **Accepted** at Phase 7 closeout -- **Date:** 2026-06-27 (Phase 0) +- **Status:** Accepted (Phase 7 closeout — all 10 lifecycle/maintenance transitions migrated; bug cluster #1760/#1761/#1743/#1695/#1264/#1255/#1257/#3242 retired) +- **Date:** 2026-06-27 (Phase 0); Phase 7 closeout 2026-06-27 - **Issue:** [#1769](https://github.com/open-gsd/gsd-core/issues/1769) — epic - **Supersedes:** the policy portions of `syncStateFrontmatter` (`src/state.cts:1667–1743`), `readModifyWriteStateMd`'s post-sync preservation block (`src/state.cts:2008–2119`), and @@ -214,4 +214,4 @@ alongside, leave callbacks — parallel worlds don't converge (ADR-857's failure | 4 | `plannedPhase` + `milestoneSwitch` | #1786 | — | | 5 | `milestoneComplete` + `milestone.cts:352` | #1789 | — | | 6 | `patch` | #1791 | #1743, #1695 | -| 7 | `sync`, `prune`, `update` | TBD | #1760, #1761 | +| 7 | `sync`, `prune`, `update` | #1793 | #1760, #1761 | diff --git a/gsd-core/bin/lib/state-transition.cjs b/gsd-core/bin/lib/state-transition.cjs index bf7650c1c..a79ccc7c5 100644 --- a/gsd-core/bin/lib/state-transition.cjs +++ b/gsd-core/bin/lib/state-transition.cjs @@ -127,6 +127,12 @@ function transitionCore(content, intent, deps) { return milestoneCompleteCore(content, intent, deps); case 'patch': return patchCore(content, intent); + case 'update': + return updateCore(content, intent); + case 'prune': + return pruneCore(content, intent); + case 'sync': + return syncCore(content, intent, deps); } } // ---------------------------------------------------------------------------- @@ -941,3 +947,199 @@ function patchCore(content, intent) { } return { content: result, updated, data: { updated, failed } }; } +// ---------------------------------------------------------------------------- +// update — intent implementation (Phase 7) +// ---------------------------------------------------------------------------- +/** + * Apply an `update` transition to STATE.md content. + * + * Migrates `cmdStateUpdate` (state.cts) onto the substrate. A single-field + * body-only update (the field is replaced in the body; frontmatter is preserved + * as-is and re-synced by the adapter's `readModifyWriteStateMd` post-sync). + * Mirrors the pre-migration body-strip/reassemble contract. + */ +function updateCore(content, intent) { + const existingFm = extractFrontmatter(content); + const hasFrontmatter = Object.keys(existingFm).length > 0; + const body = stripFrontmatter(content); + const result = (0, state_document_cjs_1.stateReplaceField)(body, intent.field, intent.value); + if (result === null) { + return { content, updated: [], data: { updated: false } }; + } + const reassembled = hasFrontmatter + ? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${result}` + : result; + return { content: reassembled, updated: [intent.field], data: { updated: true } }; +} +// Stop predicate for prune section slicing: a level-2 OR level-3 heading ends +// the section (mirrors state.cts STOP_H2_H3 — Decisions / Recently Completed / +// Blockers / Performance Metrics live at H2 or H3). +const STOP_H2_H3 = (lv) => lv === 2 || lv === 3; +/** + * Apply a `prune` transition to STATE.md content. + * + * Migrates the section-pruning half of `cmdStatePrune` (state.cts) onto the + * substrate. Pure `content → {content, archivedSections}` given a cutoff phase: + * archives Decisions / Recently Completed / resolved Blockers / Performance + * Metrics table rows whose phase number is <= cutoff. ADR-1372 T6 + * tokenizeHeadings + untrimmed-span splicing, byte-identical to the pre-migration + * `prunePass`. + * + * The adapter owns currentPhase derivation (with the #1760 `Phase` / `Current + * Phase` fallback), keepRecent/dryRun, and STATE-ARCHIVE.md writes. + */ +function pruneCore(content, intent) { + const cutoff = intent.cutoff; + const sections = []; + let c = content; + // Helper: locate a heading matching pred, extract untrimmed body [bs, se), + // apply transform, splice back. All prune sections stop at level 2 or 3. + const pruneSectionSpan = (pred, transform, sectionName) => { + const hs = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(c); + const i = hs.findIndex((h) => pred(h.level, h.text)); + if (i === -1) + return; + const h = hs[i]; + const ls = c.split('\n'); + const hl = ls[h.line - 1]; + const bs = h.offset + hl.length + 1; + let se = c.length; + for (let j = i + 1; j < hs.length; j++) { + if (STOP_H2_H3(hs[j].level)) { + se = hs[j].offset - 1; + break; + } + } + const body = c.slice(bs, se); + const { keep, archive } = transform(body); + if (archive.length > 0) { + sections.push({ section: sectionName, count: archive.length, lines: archive }); + c = c.slice(0, bs) + keep.join('\n') + c.slice(se); + } + }; + pruneSectionSpan((lv, text) => (lv === 2 || lv === 3) && /^(?:Decisions|Decisions Made|Accumulated.*Decisions)$/i.test(text), (body) => { + const keep = [], archive = []; + for (const line of body.split('\n')) { + const phaseMatch = line.match(/^\s*-\s*\[Phase\s+(\d+)/i); + if (phaseMatch && parseInt(phaseMatch[1], 10) <= cutoff) { + archive.push(line); + } + else { + keep.push(line); + } + } + return { keep, archive }; + }, 'Decisions'); + pruneSectionSpan((lv, text) => (lv === 2 || lv === 3) && /^recently\s+completed$/i.test(text), (body) => { + const keep = [], archive = []; + for (const line of body.split('\n')) { + const phaseMatch = line.match(/Phase\s+(\d+)/i); + if (phaseMatch && parseInt(phaseMatch[1], 10) <= cutoff) { + archive.push(line); + } + else { + keep.push(line); + } + } + return { keep, archive }; + }, 'Recently Completed'); + pruneSectionSpan((lv, text) => (lv === 2 || lv === 3) && /^(?:Blockers|Blockers\/Concerns|Blockers\s*&\s*Concerns)$/i.test(text), (body) => { + const keep = [], archive = []; + for (const line of body.split('\n')) { + const isResolved = /~~.*~~|\[RESOLVED\]/i.test(line); + const phaseMatch = line.match(/Phase\s+(\d+)/i); + if (isResolved && phaseMatch && parseInt(phaseMatch[1], 10) <= cutoff) { + archive.push(line); + } + else { + keep.push(line); + } + } + return { keep, archive }; + }, 'Blockers (resolved)'); + pruneSectionSpan((lv, text) => (lv === 2 || lv === 3) && /^performance\s+metrics$/i.test(text), (body) => { + const keep = [], archive = []; + for (const line of body.split('\n')) { + const tableRowMatch = line.match(/^\|\s*(\d+)\s*\|/); + if (tableRowMatch) { + const rowPhase = parseInt(tableRowMatch[1], 10); + if (rowPhase <= cutoff) { + archive.push(line); + } + else { + keep.push(line); + } + } + else { + keep.push(line); + } + } + return { keep, archive }; + }, 'Performance Metrics'); + const totalPruned = sections.reduce((sum, s) => sum + s.count, 0); + return { + content: c, + updated: totalPruned > 0 ? ['pruned'] : [], + data: { archivedSections: sections, totalPruned }, + }; +} +// ---------------------------------------------------------------------------- +// sync — intent implementation (Phase 7) +// ---------------------------------------------------------------------------- +/** + * Apply a `sync` transition to STATE.md content. + * + * Migrates the body-write half of `cmdStateSync` (state.cts) onto the substrate. + * Updates Total Plans in Phase, the Progress bar, and Last Activity from + * disk-derived numbers (injected via the intent). Returns the per-field change + * log via `data.changes` so the adapter can build the CLI output. + * + * #1761: when the current milestone cannot be bounded to a versioned phase set, + * the adapter passes `percent: null` and this core leaves Progress untouched + * (rather than silently writing fallback-derived wrong values). + */ +function syncCore(content, intent, deps) { + const today = deps.clock.today(); + const changes = []; + let modified = content; + const updated = []; + if (intent.totalPlansInPhase !== null) { + const currentPlansField = (0, state_document_cjs_1.stateExtractField)(modified, 'Total Plans in Phase'); + if (currentPlansField && parseInt(currentPlansField, 10) !== intent.totalPlansInPhase) { + changes.push(`Total Plans in Phase: ${currentPlansField} -> ${intent.totalPlansInPhase}`); + const result = (0, state_document_cjs_1.stateReplaceField)(modified, 'Total Plans in Phase', String(intent.totalPlansInPhase)); + if (result) { + modified = result; + updated.push('Total Plans in Phase'); + } + } + } + if (intent.percent !== null) { + const currentProgress = (0, state_document_cjs_1.stateExtractField)(modified, 'Progress'); + if (currentProgress) { + const currentPercent = parseInt(currentProgress.replace(/[^\d]/g, ''), 10); + if (currentPercent !== intent.percent) { + const barWidth = 10; + const filled = Math.round((intent.percent / 100) * barWidth); + const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled); + const progressStr = `[${bar}] ${intent.percent}%`; + changes.push(`Progress: ${currentProgress} -> ${progressStr}`); + const result = (0, state_document_cjs_1.stateReplaceField)(modified, 'Progress', progressStr); + if (result) { + modified = result; + updated.push('Progress'); + } + } + } + } + const lastActivityResult = (0, state_document_cjs_1.stateReplaceField)(modified, 'Last Activity', today); + if (lastActivityResult) { + const oldActivity = (0, state_document_cjs_1.stateExtractField)(modified, 'Last Activity'); + if (oldActivity !== today) { + changes.push(`Last Activity: ${oldActivity} -> ${today}`); + updated.push('Last Activity'); + } + modified = lastActivityResult; + } + return { content: modified, updated, data: { changes } }; +} diff --git a/scripts/lint-regression-test-names.allowlist.json b/scripts/lint-regression-test-names.allowlist.json index e4446e9e8..2b2620a85 100644 --- a/scripts/lint-regression-test-names.allowlist.json +++ b/scripts/lint-regression-test-names.allowlist.json @@ -10,6 +10,8 @@ "bug-170-workflow-fallback-install-hint.test.cjs", "bug-1736-local-install-commands.test.cjs", "bug-1754-js-hook-guard.test.cjs", + "bug-1760-state-prune-noop-template-field.test.cjs", + "bug-1761-state-sync-wrong-progress.test.cjs", "bug-1817-sh-hook-guard.test.cjs", "bug-1818-unknown-flags.test.cjs", "bug-1826-phases-clear-confirm.test.cjs", diff --git a/scripts/lint-test-file-count.allowlist.json b/scripts/lint-test-file-count.allowlist.json index 879d79bea..6758bb796 100644 --- a/scripts/lint-test-file-count.allowlist.json +++ b/scripts/lint-test-file-count.allowlist.json @@ -100,6 +100,8 @@ "state": { "files": [ "bug-1695-state-patch-clobbers-phase-name.test.cjs", + "bug-1760-state-prune-noop-template-field.test.cjs", + "bug-1761-state-sync-wrong-progress.test.cjs", "bug-21-state-md-template-frontmatter.test.cjs", "bug-2630-state-frontmatter-milestone-switch.test.cjs", "bug-3127-state-begin-phase-idempotent.test.cjs", diff --git a/src/state-transition.cts b/src/state-transition.cts index 981ad12fe..13862d365 100644 --- a/src/state-transition.cts +++ b/src/state-transition.cts @@ -180,8 +180,21 @@ export type StateTransitionIntent = /** Resolved runtime slash command for the Operator Next Steps hint (e.g. '/gsd:new-milestone'). */ nextMilestoneCommand: string; } - | { kind: 'patch'; patches: Record }; -// Phase 7 adds the remaining intent kinds to this discriminated union. + | { kind: 'patch'; patches: Record } + | { kind: 'update'; field: string; value: string } + | { + kind: 'prune'; + /** Phases at or below this number are archived (currentPhase - keepRecent). */ + cutoff: number; + } + | { + kind: 'sync'; + /** Disk-derived plan count for the highest incomplete phase, or null. */ + totalPlansInPhase: number | null; + /** Recomputed progress percent (0-100), or null when it must be left untouched (#1761). */ + percent: number | null; + }; +// Phase 7 closes out the discriminated union (all 10 lifecycle/maintenance intents). export type StateTransitionResult = { content: string; @@ -224,6 +237,12 @@ export function transitionCore( return milestoneCompleteCore(content, intent, deps); case 'patch': return patchCore(content, intent); + case 'update': + return updateCore(content, intent); + case 'prune': + return pruneCore(content, intent); + case 'sync': + return syncCore(content, intent, deps); } } @@ -1174,3 +1193,223 @@ function patchCore( return { content: result, updated, data: { updated, failed } }; } + +// ---------------------------------------------------------------------------- +// update — intent implementation (Phase 7) +// ---------------------------------------------------------------------------- + +/** + * Apply an `update` transition to STATE.md content. + * + * Migrates `cmdStateUpdate` (state.cts) onto the substrate. A single-field + * body-only update (the field is replaced in the body; frontmatter is preserved + * as-is and re-synced by the adapter's `readModifyWriteStateMd` post-sync). + * Mirrors the pre-migration body-strip/reassemble contract. + */ +function updateCore( + content: string, + intent: { kind: 'update'; field: string; value: string }, +): StateTransitionResult { + const existingFm = extractFrontmatter(content) as Record; + const hasFrontmatter = Object.keys(existingFm).length > 0; + const body = stripFrontmatter(content); + const result = stateReplaceField(body, intent.field, intent.value); + if (result === null) { + return { content, updated: [], data: { updated: false } }; + } + const reassembled = hasFrontmatter + ? `---\n${reconstructFrontmatter(existingFm as unknown as Frontmatter)}\n---\n\n${result}` + : result; + return { content: reassembled, updated: [intent.field], data: { updated: true } }; +} + +// ---------------------------------------------------------------------------- +// prune — intent implementation (Phase 7) +// ---------------------------------------------------------------------------- + +type PrunedSection = { section: string; count: number; lines: string[] }; + +// Stop predicate for prune section slicing: a level-2 OR level-3 heading ends +// the section (mirrors state.cts STOP_H2_H3 — Decisions / Recently Completed / +// Blockers / Performance Metrics live at H2 or H3). +const STOP_H2_H3 = (lv: number): boolean => lv === 2 || lv === 3; + +/** + * Apply a `prune` transition to STATE.md content. + * + * Migrates the section-pruning half of `cmdStatePrune` (state.cts) onto the + * substrate. Pure `content → {content, archivedSections}` given a cutoff phase: + * archives Decisions / Recently Completed / resolved Blockers / Performance + * Metrics table rows whose phase number is <= cutoff. ADR-1372 T6 + * tokenizeHeadings + untrimmed-span splicing, byte-identical to the pre-migration + * `prunePass`. + * + * The adapter owns currentPhase derivation (with the #1760 `Phase` / `Current + * Phase` fallback), keepRecent/dryRun, and STATE-ARCHIVE.md writes. + */ +function pruneCore( + content: string, + intent: { kind: 'prune'; cutoff: number }, +): StateTransitionResult { + const cutoff = intent.cutoff; + const sections: PrunedSection[] = []; + let c = content; + + // Helper: locate a heading matching pred, extract untrimmed body [bs, se), + // apply transform, splice back. All prune sections stop at level 2 or 3. + const pruneSectionSpan = ( + pred: (lv: number, text: string) => boolean, + transform: (body: string) => { keep: string[]; archive: string[] }, + sectionName: string, + ): void => { + const hs = tokenizeHeadings(c); + const i = hs.findIndex((h) => pred(h.level, h.text)); + if (i === -1) return; + const h = hs[i]; + const ls = c.split('\n'); + const hl = ls[h.line - 1]; + const bs = h.offset + hl.length + 1; + let se = c.length; + for (let j = i + 1; j < hs.length; j++) { + if (STOP_H2_H3(hs[j].level)) { + se = hs[j].offset - 1; + break; + } + } + const body = c.slice(bs, se); + const { keep, archive } = transform(body); + if (archive.length > 0) { + sections.push({ section: sectionName, count: archive.length, lines: archive }); + c = c.slice(0, bs) + keep.join('\n') + c.slice(se); + } + }; + + pruneSectionSpan( + (lv, text) => (lv === 2 || lv === 3) && /^(?:Decisions|Decisions Made|Accumulated.*Decisions)$/i.test(text), + (body) => { + const keep: string[] = [], archive: string[] = []; + for (const line of body.split('\n')) { + const phaseMatch = line.match(/^\s*-\s*\[Phase\s+(\d+)/i); + if (phaseMatch && parseInt(phaseMatch[1], 10) <= cutoff) { archive.push(line); } else { keep.push(line); } + } + return { keep, archive }; + }, + 'Decisions', + ); + + pruneSectionSpan( + (lv, text) => (lv === 2 || lv === 3) && /^recently\s+completed$/i.test(text), + (body) => { + const keep: string[] = [], archive: string[] = []; + for (const line of body.split('\n')) { + const phaseMatch = line.match(/Phase\s+(\d+)/i); + if (phaseMatch && parseInt(phaseMatch[1], 10) <= cutoff) { archive.push(line); } else { keep.push(line); } + } + return { keep, archive }; + }, + 'Recently Completed', + ); + + pruneSectionSpan( + (lv, text) => (lv === 2 || lv === 3) && /^(?:Blockers|Blockers\/Concerns|Blockers\s*&\s*Concerns)$/i.test(text), + (body) => { + const keep: string[] = [], archive: string[] = []; + for (const line of body.split('\n')) { + const isResolved = /~~.*~~|\[RESOLVED\]/i.test(line); + const phaseMatch = line.match(/Phase\s+(\d+)/i); + if (isResolved && phaseMatch && parseInt(phaseMatch[1], 10) <= cutoff) { archive.push(line); } else { keep.push(line); } + } + return { keep, archive }; + }, + 'Blockers (resolved)', + ); + + pruneSectionSpan( + (lv, text) => (lv === 2 || lv === 3) && /^performance\s+metrics$/i.test(text), + (body) => { + const keep: string[] = [], archive: string[] = []; + for (const line of body.split('\n')) { + const tableRowMatch = line.match(/^\|\s*(\d+)\s*\|/); + if (tableRowMatch) { + const rowPhase = parseInt(tableRowMatch[1], 10); + if (rowPhase <= cutoff) { archive.push(line); } else { keep.push(line); } + } else { + keep.push(line); + } + } + return { keep, archive }; + }, + 'Performance Metrics', + ); + + const totalPruned = sections.reduce((sum, s) => sum + s.count, 0); + return { + content: c, + updated: totalPruned > 0 ? ['pruned'] : [], + data: { archivedSections: sections, totalPruned }, + }; +} + +// ---------------------------------------------------------------------------- +// sync — intent implementation (Phase 7) +// ---------------------------------------------------------------------------- + +/** + * Apply a `sync` transition to STATE.md content. + * + * Migrates the body-write half of `cmdStateSync` (state.cts) onto the substrate. + * Updates Total Plans in Phase, the Progress bar, and Last Activity from + * disk-derived numbers (injected via the intent). Returns the per-field change + * log via `data.changes` so the adapter can build the CLI output. + * + * #1761: when the current milestone cannot be bounded to a versioned phase set, + * the adapter passes `percent: null` and this core leaves Progress untouched + * (rather than silently writing fallback-derived wrong values). + */ +function syncCore( + content: string, + intent: { kind: 'sync'; totalPlansInPhase: number | null; percent: number | null }, + deps: StateTransitionDeps, +): StateTransitionResult { + const today = deps.clock.today(); + const changes: string[] = []; + let modified = content; + const updated: string[] = []; + + if (intent.totalPlansInPhase !== null) { + const currentPlansField = stateExtractField(modified, 'Total Plans in Phase'); + if (currentPlansField && parseInt(currentPlansField, 10) !== intent.totalPlansInPhase) { + changes.push(`Total Plans in Phase: ${currentPlansField} -> ${intent.totalPlansInPhase}`); + const result = stateReplaceField(modified, 'Total Plans in Phase', String(intent.totalPlansInPhase)); + if (result) { modified = result; updated.push('Total Plans in Phase'); } + } + } + + if (intent.percent !== null) { + const currentProgress = stateExtractField(modified, 'Progress'); + if (currentProgress) { + const currentPercent = parseInt(currentProgress.replace(/[^\d]/g, ''), 10); + if (currentPercent !== intent.percent) { + const barWidth = 10; + const filled = Math.round((intent.percent / 100) * barWidth); + const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled); + const progressStr = `[${bar}] ${intent.percent}%`; + changes.push(`Progress: ${currentProgress} -> ${progressStr}`); + const result = stateReplaceField(modified, 'Progress', progressStr); + if (result) { modified = result; updated.push('Progress'); } + } + } + } + + const lastActivityResult = stateReplaceField(modified, 'Last Activity', today); + if (lastActivityResult) { + const oldActivity = stateExtractField(modified, 'Last Activity'); + if (oldActivity !== today) { + changes.push(`Last Activity: ${oldActivity} -> ${today}`); + updated.push('Last Activity'); + } + modified = lastActivityResult; + } + + return { content: modified, updated, data: { changes } }; +} diff --git a/src/state.cts b/src/state.cts index 1e0eda460..08cc0fbad 100644 --- a/src/state.cts +++ b/src/state.cts @@ -435,20 +435,20 @@ function cmdStateUpdate(cwd: string, field: string | undefined, value: string | try { let updated = false; const shouldResync = shouldResyncStateProgress([field as string]); + // ADR-1769 Phase 7: dispatches to the STATE.md Transition Module. The + // body-strip/reassemble single-field update is the pure `updateCore` in + // src/state-transition.cts. readModifyWriteStateMd still owns the lock, the + // #1230/#1264/#1695 post-sync preservation, and the no-op write guard. // Preserve curated progress for body-only updates, but allow fields that // directly project into progress.* frontmatter to rebuild after mutation. readModifyWriteStateMd(statePath, (content) => { - const body = stripFrontmatter(content); - const result = stateReplaceField(body, field as string, value as string); - if (result) { - updated = true; - const existingFm = extractFrontmatter(content) as Record; - if (Object.keys(existingFm).length > 0) { - return `---\n${reconstructFrontmatter(existingFm as unknown as Frontmatter)}\n---\n\n${result}`; - } - return result; - } - return content; + const result = transitionCore( + content, + { kind: 'update', field: field as string, value: value as string }, + { clock: realClock, progressProvider: () => null }, + ); + updated = (result.data as { updated: boolean } | undefined)?.updated === true; + return result.content; }, cwd, { resync: shouldResync }); if (updated) { output({ updated: true }, false, undefined); @@ -2362,7 +2362,7 @@ function cmdStateSync(cwd: string, options: StateSyncOptions | undefined, raw: b const content = fs.readFileSync(statePath, 'utf-8'); const changes: string[] = []; let modified = content; - const today = realClock.today(); + const phasesDir = planningPaths(cwd).phases; if (!fs.existsSync(phasesDir)) { @@ -2375,10 +2375,12 @@ function cmdStateSync(cwd: string, options: StateSyncOptions | undefined, raw: b // exactly as buildStateFrontmatter does — otherwise `state sync --verify` // would keep re-deriving the inflated denominator and report "no drift". let syncRoadmapScope: string | null = null; + let syncRoadmapRaw: string | null = null; let syncRetiredPhaseNums = new Set(); try { const roadmapRaw = platformReadSync(path.join(planningDir(cwd), 'ROADMAP.md')); if (roadmapRaw !== null) { + syncRoadmapRaw = roadmapRaw; syncRoadmapScope = extractCurrentMilestone(roadmapRaw, cwd); syncRetiredPhaseNums = extractRetiredPhaseNumbers(syncRoadmapScope); } @@ -2456,46 +2458,36 @@ function cmdStateSync(cwd: string, options: StateSyncOptions | undefined, raw: b } } catch { /* intentionally empty */ } - // Sync Total Plans in Phase - if (highestIncompletePhase) { - const currentPlansField = stateExtractField(modified, 'Total Plans in Phase'); - if (currentPlansField && parseInt(currentPlansField, 10) !== highestIncompletePhaseplanCount) { - changes.push(`Total Plans in Phase: ${currentPlansField} -> ${highestIncompletePhaseplanCount}`); - const result = stateReplaceField(modified, 'Total Plans in Phase', String(highestIncompletePhaseplanCount)); - if (result) modified = result; - } + // ADR-1769 Phase 7: the body writes (Total Plans in Phase, Progress bar, Last + // Activity) are the pure `syncCore` in src/state-transition.cts. + // #1761: when a milestone version is set in frontmatter but the ROADMAP has no + // versioned heading for it, the milestone cannot be bounded to a versioned phase + // set — leave Progress untouched (percent=null) rather than silently writing + // fallback-derived wrong values. Projects without a milestone version (the common + // sync-test shape) are unaffected: the gate only fires when a version is asserted. + const fmVersion = (extractFrontmatter(content) as Record).milestone; + const versionStr = typeof fmVersion === 'string' && fmVersion.trim() ? fmVersion.trim() : null; + let milestoneBounded = true; + if (versionStr !== null && syncRoadmapRaw !== null) { + const versionedHeading = new RegExp(`^#{1,3}\\s+(?!Phase\\s+\\S).*${escapeRegex(versionStr)}`, 'mi'); + milestoneBounded = versionedHeading.test(syncRoadmapRaw); } - - // Sync Progress — use shared helper so formula stays in one place (#3242 Bug B). - // computeProgressPercent applies min(plan_fraction, phase_fraction) so unrealised - // ROADMAP phases cap the reported percent rather than allowing a false 100%. - const percent = (() => { + let percent: number | null = null; + if (!milestoneBounded) { + changes.push(`Progress: skipped — milestone ${versionStr} cannot be bounded to a versioned ROADMAP phase set (#1761)`); + } else { const p = computeProgressPercent(totalDiskSummaries, totalDiskPlans, diskCompletedPhases, syncTotalPhases); - return p !== null ? p : 0; - })(); - const currentProgress = stateExtractField(modified, 'Progress'); - if (currentProgress) { - const currentPercent = parseInt(currentProgress.replace(/[^\d]/g, ''), 10); - if (currentPercent !== percent) { - const barWidth = 10; - const filled = Math.round(percent / 100 * barWidth); - const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled); - const progressStr = `[${bar}] ${percent}%`; - changes.push(`Progress: ${currentProgress} -> ${progressStr}`); - const result = stateReplaceField(modified, 'Progress', progressStr); - if (result) modified = result; - } + percent = p !== null ? p : 0; } - // Sync Last Activity - const result = stateReplaceField(modified, 'Last Activity', today); - if (result) { - const oldActivity = stateExtractField(modified, 'Last Activity'); - if (oldActivity !== today) { - changes.push(`Last Activity: ${oldActivity} -> ${today}`); - } - modified = result; - } + const syncResult = transitionCore( + modified, + { kind: 'sync', totalPlansInPhase: highestIncompletePhase ? highestIncompletePhaseplanCount : null, percent }, + { clock: realClock, progressProvider: () => null }, + ); + modified = syncResult.content; + const coreChanges = (syncResult.data as { changes?: string[] } | undefined)?.changes ?? []; + changes.push(...coreChanges); if (verify) { output({ synced: false, changes, dry_run: true }, raw, undefined); @@ -2526,8 +2518,13 @@ function cmdStatePrune(cwd: string, options: StatePruneOptions, raw: boolean): v const keepRecent = parseInt(String(options.keepRecent), 10) || 3; const dryRun = !!options.dryRun; - const currentPhaseRaw = stateExtractField(fs.readFileSync(statePath, 'utf-8'), 'Current Phase'); - const currentPhase = parseInt(currentPhaseRaw as string, 10) || 0; + // #1760: the canonical STATE.md template emits `Phase: [X] of [Y]`, not + // `Current Phase:`. Read both (mirroring buildStateFrontmatter / + // resolvePhaseIdForCompletePhase) so prune engages on template-conformant + // STATE.md instead of bailing with "Only 0 phases — nothing to prune". + const rawState = fs.readFileSync(statePath, 'utf-8'); + const currentPhaseRaw = stateExtractField(rawState, 'Current Phase') || stateExtractField(rawState, 'Phase'); + const currentPhase = parseInt(String(currentPhaseRaw), 10) || 0; const cutoff = currentPhase - keepRecent; if (cutoff <= 0) { @@ -2538,119 +2535,22 @@ function cmdStatePrune(cwd: string, options: StatePruneOptions, raw: boolean): v const archivePath = path.join(path.dirname(statePath), 'STATE-ARCHIVE.md'); const archived: PrunedSection[] = []; - // Shared pruning logic applied to both dry-run and real passes. - // Returns { newContent, archivedSections }. - // ADR-1372 T6: all four inline section-collect regexes replaced with - // tokenizeHeadings + untrimmed-span splicing for byte-identical writes. - function prunePass(content: string): { newContent: string; archivedSections: PrunedSection[] } { - const sections: PrunedSection[] = []; - - // Helper: locate a heading matching pred, extract untrimmed body [bs, se), - // apply transform, and splice back. Returns updated content. - // All prune-section patterns stop at level 2 or 3 (STOP_H2_H3). - function pruneSectionSpan( - c: string, - pred: (lv: number, text: string) => boolean, - transform: (body: string) => { keep: string[]; archive: string[] }, - sectionName: string, - ): string { - const hs = tokenizeHeadings(c); - const i = hs.findIndex(h => pred(h.level, h.text)); - if (i === -1) return c; - const h = hs[i]; - const ls = c.split('\n'); - const hl = ls[h.line - 1]; - const bs = h.offset + hl.length + 1; - let se = c.length; - for (let j = i + 1; j < hs.length; j++) { - if (STOP_H2_H3(hs[j].level)) { se = hs[j].offset - 1; break; } - } - const body = c.slice(bs, se); - const { keep, archive } = transform(body); - if (archive.length > 0) { - sections.push({ section: sectionName, count: archive.length, lines: archive }); - return c.slice(0, bs) + keep.join('\n') + c.slice(se); - } - return c; - } - - // Prune Decisions section: entries like "- [Phase N]: ..." - content = pruneSectionSpan( - content, - (lv, text) => (lv === 2 || lv === 3) && /^(?:Decisions|Decisions Made|Accumulated.*Decisions)$/i.test(text), - (body) => { - const keep: string[] = [], archive: string[] = []; - for (const line of body.split('\n')) { - const phaseMatch = line.match(/^\s*-\s*\[Phase\s+(\d+)/i); - if (phaseMatch && parseInt(phaseMatch[1], 10) <= cutoff) { archive.push(line); } else { keep.push(line); } - } - return { keep, archive }; - }, - 'Decisions', - ); - - // Prune Recently Completed section: entries mentioning phase numbers - content = pruneSectionSpan( - content, - (lv, text) => (lv === 2 || lv === 3) && /^recently\s+completed$/i.test(text), - (body) => { - const keep: string[] = [], archive: string[] = []; - for (const line of body.split('\n')) { - const phaseMatch = line.match(/Phase\s+(\d+)/i); - if (phaseMatch && parseInt(phaseMatch[1], 10) <= cutoff) { archive.push(line); } else { keep.push(line); } - } - return { keep, archive }; - }, - 'Recently Completed', - ); - - // Prune resolved blockers: lines marked as resolved (strikethrough ~~text~~ - // or "[RESOLVED]" prefix) with a phase reference older than cutoff - content = pruneSectionSpan( - content, - (lv, text) => (lv === 2 || lv === 3) && /^(?:Blockers|Blockers\/Concerns|Blockers\s*&\s*Concerns)$/i.test(text), - (body) => { - const keep: string[] = [], archive: string[] = []; - for (const line of body.split('\n')) { - const isResolved = /~~.*~~|\[RESOLVED\]/i.test(line); - const phaseMatch = line.match(/Phase\s+(\d+)/i); - if (isResolved && phaseMatch && parseInt(phaseMatch[1], 10) <= cutoff) { archive.push(line); } else { keep.push(line); } - } - return { keep, archive }; - }, - 'Blockers (resolved)', - ); - - // Prune Performance Metrics table rows: keep only rows for phases > cutoff. - // Preserves header rows (| Phase | ... and |---|...) and any prose around the table. - content = pruneSectionSpan( - content, - (lv, text) => (lv === 2 || lv === 3) && /^performance\s+metrics$/i.test(text), - (body) => { - const keep: string[] = [], archive: string[] = []; - for (const line of body.split('\n')) { - // Table data row: starts with | followed by a number (phase) - const tableRowMatch = line.match(/^\|\s*(\d+)\s*\|/); - if (tableRowMatch) { - const rowPhase = parseInt(tableRowMatch[1], 10); - if (rowPhase <= cutoff) { archive.push(line); } else { keep.push(line); } - } else { - // Header row, separator row, or prose — always keep - keep.push(line); - } - } - return { keep, archive }; - }, - 'Performance Metrics', - ); - - return { newContent: content, archivedSections: sections }; - } + // ADR-1769 Phase 7: the section-pruning is the pure `pruneCore` in + // src/state-transition.cts (byte-identical tokenizeHeadings section splicing). + // This adapter owns currentPhase derivation (#1760 `Phase`/`Current Phase` + // fallback above), dry-run, and STATE-ARCHIVE.md writes. + const runPruneCore = (content: string): { newContent: string; archivedSections: PrunedSection[] } => { + const result = transitionCore(content, { kind: 'prune', cutoff }, { clock: realClock, progressProvider: () => null }); + return { + newContent: result.content, + archivedSections: ((result.data as { archivedSections?: PrunedSection[] } | undefined)?.archivedSections) ?? [], + }; + }; if (dryRun) { // Dry-run: compute what would be pruned without writing anything const content = fs.readFileSync(statePath, 'utf-8'); - const result = prunePass(content); + const result = runPruneCore(content); const totalPruned = result.archivedSections.reduce((sum, s) => sum + s.count, 0); emit({ pruned: false, @@ -2665,7 +2565,7 @@ function cmdStatePrune(cwd: string, options: StatePruneOptions, raw: boolean): v } readModifyWriteStateMd(statePath, (content) => { - const result = prunePass(content); + const result = runPruneCore(content); archived.push(...result.archivedSections); return result.newContent; }, cwd); diff --git a/tests/bug-1760-state-prune-noop-template-field.test.cjs b/tests/bug-1760-state-prune-noop-template-field.test.cjs new file mode 100644 index 000000000..9b4402b2b --- /dev/null +++ b/tests/bug-1760-state-prune-noop-template-field.test.cjs @@ -0,0 +1,80 @@ +'use strict'; +// Regression test for issue #1760 — `state prune` no-ops on template-conformant +// STATE.md because it reads `Current Phase` only, never the `Phase: X of Y` line +// the canonical template emits. +// +// ADR-1769 Phase 7 fix: derive the current phase with a `Phase` / `Current Phase` +// fallback (mirroring buildStateFrontmatter), so prune engages on template STATE.md. + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); + +function writeStateMd(tmpDir, content) { + fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), content); +} +function readStateMd(tmpDir) { + return fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); +} + +describe('#1760: state prune engages on template-conformant STATE.md (Phase: X of Y)', () => { + let tmpDir; + beforeEach(() => { tmpDir = createTempProject(); }); + afterEach(() => { cleanup(tmpDir); }); + + test('prune engages when STATE.md uses "Phase: N of M" (no Current Phase field)', () => { + // Template-conformant STATE.md: Current Position uses `Phase: 10 of 15`, and + // there is NO `**Current Phase:**` body field. Pre-fix this made prune bail + // with "Only 0 phases — nothing to prune". + writeStateMd(tmpDir, [ + '# Session State', + '', + '## Current Position', + '', + 'Phase: 10 of 15', + 'Plan: 2 of 4', + 'Status: Executing Phase 10', + '', + '## Decisions', + '', + '- [Phase 1]: Old decision', + '- [Phase 3]: Older decision', + '- [Phase 9]: Recent decision', + '', + ].join('\n')); + + const result = runGsdTools('state prune --keep-recent 3 --dry-run', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + const out = JSON.parse(result.output); + + // Pre-fix: { pruned: false, reason: 'Only 0 phases — nothing to prune...' }. + // Post-fix: prune engages and reports a real cutoff_phase (10 - 3 = 7). + assert.strictEqual(out.pruned, false, 'dry-run must report pruned:false'); + assert.ok(out.reason === undefined || !/Only 0 phases/i.test(String(out.reason)), + `prune must not bail with "Only 0 phases" on template STATE.md; got reason=${JSON.stringify(out.reason)}`); + assert.strictEqual(out.cutoff_phase, 7, + `cutoff_phase must be 7 (current 10 - keep-recent 3); got ${JSON.stringify(out.cutoff_phase)}`); + }); + + test('prune still engages when "Current Phase:" IS present (no regression)', () => { + writeStateMd(tmpDir, [ + '# Session State', + '', + '**Current Phase:** 10', + '', + '## Decisions', + '', + '- [Phase 1]: Old decision', + '- [Phase 9]: Recent decision', + '', + ].join('\n')); + + const result = runGsdTools('state prune --keep-recent 3 --dry-run', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + const out = JSON.parse(result.output); + assert.strictEqual(out.cutoff_phase, 7); + }); +}); diff --git a/tests/bug-1761-state-sync-wrong-progress.test.cjs b/tests/bug-1761-state-sync-wrong-progress.test.cjs new file mode 100644 index 000000000..a5ebbe412 --- /dev/null +++ b/tests/bug-1761-state-sync-wrong-progress.test.cjs @@ -0,0 +1,89 @@ +'use strict'; +// Regression test for issue #1761 — `state sync` silently writes wrong progress +// when ROADMAP lacks versioned milestone headings. +// +// ADR-1769 Phase 7 fix: when getMilestonePhaseFilter().missingExplicitVersion is +// true (the current milestone cannot be bounded to a versioned phase set), the +// sync transition leaves Progress untouched (percent=null) rather than silently +// computing/writing values off a fallback milestone. + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); + +function buildStateWithProgress({ percent = 50 } = {}) { + const barWidth = 10; + const filled = Math.round((percent / 100) * barWidth); + const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled); + return [ + '---', + 'gsd_state_version: 1.0', + 'milestone: v1.0', + 'milestone_name: Test', + 'current_phase: "3"', + 'status: executing', + 'progress:', + ' total_phases: 10', + ' completed_phases: 5', + ` percent: ${percent}`, + '---', + '', + '# GSD State', + '', + '**Current Phase:** 3', + '**Total Plans in Phase:** 4', + '**Current Plan:** 2', + '**Status:** Executing Phase 3', + '**Last Activity:** 2026-06-20', + `**Progress:** [${bar}] ${percent}%`, + '', + ].join('\n'); +} + +// ROADMAP with an UNVERSIONED milestone heading (no vX.Y) — the #1761 trigger. +function buildUnversionedRoadmap(numPhases) { + const lines = ['# ROADMAP', '', '## Milestone 1: Test Milestone', '']; + for (let i = 1; i <= numPhases; i++) { + lines.push(`### Phase ${i}: phase-${i}`); + lines.push(''); + } + return lines.join('\n'); +} + +function readBodyProgress(statePath) { + const m = fs.readFileSync(statePath, 'utf-8').match(/\*\*Progress:\*\*\s*(.*)/); + return m ? m[1].trim() : null; +} + +describe('#1761: state sync leaves Progress untouched when milestone is unbounded', () => { + let tmpDir; + beforeEach(() => { tmpDir = createTempProject(); }); + afterEach(() => { cleanup(tmpDir); }); + + test('sync does NOT rewrite the Progress bar when ROADMAP lacks a versioned milestone heading', () => { + const statePath = path.join(tmpDir, '.planning', 'STATE.md'); + fs.writeFileSync(statePath, buildStateWithProgress({ percent: 50 })); + fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), buildUnversionedRoadmap(10)); + + // Seed disk: 2 of 10 phases fully summarized → if sync naively recomputes, + // it would write ~20%, clobbering the curated 50% (#1761). + const phasesDir = path.join(tmpDir, '.planning', 'phases'); + for (let i = 1; i <= 2; i++) { + const dir = path.join(phasesDir, String(i).padStart(2, '0')); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, '01-PLAN.md'), '# Plan\n'); + fs.writeFileSync(path.join(dir, '01-SUMMARY.md'), '# Summary\n'); + } + + const before = readBodyProgress(statePath); + const result = runGsdTools('state sync', tmpDir); + assert.ok(result.success, `state sync failed: ${result.error}`); + const after = readBodyProgress(statePath); + + assert.strictEqual(after, before, + `Progress must be left untouched when the milestone is unbounded; before=${JSON.stringify(before)} after=${JSON.stringify(after)} (#1761)`); + }); +}); diff --git a/tests/state-transition.test.cjs b/tests/state-transition.test.cjs index 7e39a23ea..3e6b102b5 100644 --- a/tests/state-transition.test.cjs +++ b/tests/state-transition.test.cjs @@ -1085,3 +1085,128 @@ describe('ADR-1769 Phase 6: patch transition — field updates', () => { assert.deepStrictEqual(result.data && result.data.updated, ['stopped_at']); }); }); + +// ADR-1769 Phase 7: update, prune, sync + +describe('ADR-1769 Phase 7: update transition — single body field', () => { + const deps = { clock: fixedClock, progressProvider: noProgress }; + + test('replaces a body field and reports updated:true', () => { + const input = '# Project State\n\n**Status:** Planning\n**Current Plan:** 2\n'; + const result = transitionCore(input, { kind: 'update', field: 'Current Plan', value: '3' }, deps); + assert.strictEqual(stateExtractField(result.content, 'Current Plan'), '3'); + assert.strictEqual(result.data && result.data.updated, true); + }); + + test('reports updated:false when the field is absent', () => { + const input = '# Project State\n\n**Status:** Planning\n'; + const result = transitionCore(input, { kind: 'update', field: 'Nonexistent', value: 'x' }, deps); + assert.strictEqual(result.content, input); + assert.strictEqual(result.data && result.data.updated, false); + }); + + test('preserves frontmatter across the body update', () => { + const input = ['---', 'status: planning', '---', '', '# State', '', '**Status:** Planning', ''].join('\n'); + const result = transitionCore(input, { kind: 'update', field: 'Status', value: 'Paused' }, deps); + assert.strictEqual(stateExtractField(result.content, 'Status'), 'Paused'); + assert.ok(/^---\r?\n[\s\S]*?\r?\n---/.test(result.content)); + }); +}); + +describe('ADR-1769 Phase 7: prune transition — section pruning', () => { + const deps = { clock: fixedClock, progressProvider: noProgress }; + + test('archives Decisions entries at or below the cutoff phase', () => { + const input = [ + '# Session State', + '', + '## Decisions', + '', + '- [Phase 1]: Old', + '- [Phase 3]: Older', + '- [Phase 9]: Recent', + '', + ].join('\n'); + const result = transitionCore(input, { kind: 'prune', cutoff: 7 }, deps); + const archived = (result.data && result.data.archivedSections) || []; + assert.strictEqual(result.content.includes('[Phase 1]: Old'), false); + assert.strictEqual(result.content.includes('[Phase 3]: Older'), false); + assert.ok(result.content.includes('[Phase 9]: Recent')); + const decisions = archived.find((s) => s.section === 'Decisions'); + assert.ok(decisions, 'Decisions archive entry must exist'); + assert.strictEqual(decisions.count, 2); + }); + + test('archives Performance Metrics table rows at or below the cutoff', () => { + const input = [ + '# State', + '', + '## Performance Metrics', + '', + '| Phase | Plans | Total | Avg/Plan |', + '| --- | --- | --- | --- |', + '| 1 | 4 | 8 | 2 |', + '| 9 | 2 | 4 | 2 |', + '', + ].join('\n'); + const result = transitionCore(input, { kind: 'prune', cutoff: 7 }, deps); + assert.ok(result.content.includes('| 9 | 2 | 4 | 2 |'), 'phase-9 row must remain'); + assert.strictEqual(result.content.includes('| 1 | 4 | 8 | 2 |'), false, 'phase-1 row must be archived'); + assert.ok(result.content.includes('| Phase | Plans |'), 'header row preserved'); + }); + + test('no-op when nothing is old enough (totalPruned === 0)', () => { + const input = '# State\n\n## Decisions\n\n- [Phase 9]: Recent\n'; + const result = transitionCore(input, { kind: 'prune', cutoff: 7 }, deps); + assert.strictEqual(result.content, input); + assert.strictEqual((result.data && result.data.totalPruned) || 0, 0); + }); +}); + +describe('ADR-1769 Phase 7: sync transition — body writes + #1761', () => { + const deps = { clock: fixedClock, progressProvider: noProgress }; + + test('updates Total Plans in Phase + Progress bar + Last Activity when bounded', () => { + const input = [ + '# Project State', + '', + '**Total Plans in Phase:** 2', + '**Last Activity:** 2026-06-20', + '**Progress:** [████░░░░░░] 40%', + '', + ].join('\n'); + const result = transitionCore( + input, + { kind: 'sync', totalPlansInPhase: 5, percent: 60 }, + deps, + ); + assert.strictEqual(stateExtractField(result.content, 'Total Plans in Phase'), '5'); + assert.strictEqual(stateExtractField(result.content, 'Last Activity'), '2026-06-27'); + assert.ok(/\[██████░░░░\] 60%/.test(result.content), 'Progress bar must be 60%'); + }); + + test('#1761: leaves Progress untouched when percent is null (milestone unbounded)', () => { + const input = [ + '# Project State', + '', + '**Total Plans in Phase:** 2', + '**Last Activity:** 2026-06-20', + '**Progress:** [█████░░░░░] 50%', + '', + ].join('\n'); + const result = transitionCore( + input, + { kind: 'sync', totalPlansInPhase: 5, percent: null }, + deps, + ); + // Total Plans + Last Activity still advance; Progress bar is left untouched. + assert.strictEqual(stateExtractField(result.content, 'Total Plans in Phase'), '5'); + assert.ok(/\[█████░░░░░\] 50%/.test(result.content), 'Progress bar must be unchanged when percent is null'); + }); + + test('skips Total Plans write when totalPlansInPhase is null', () => { + const input = '# Project State\n\n**Total Plans in Phase:** 2\n**Progress:** 40%\n'; + const result = transitionCore(input, { kind: 'sync', totalPlansInPhase: null, percent: null }, deps); + assert.strictEqual(stateExtractField(result.content, 'Total Plans in Phase'), '2'); + }); +});