Repository: open-gsd/gsd-core (2026-06-27T20:43:05Z)

Issue:
enh(#1769): STATE.md Transition Module — Phase 7 sync, prune, update (covers #1760, #1761)
### Pre-submission checklist

- [x] I have confirmed this improves existing behavior — it does not add a new command, workflow, or concept
- [x] I have searched existing issues and this enhancement has not already been proposed
- [x] I have read CONTRIBUTING.md and understand I must wait for `approved-enhancement` before writing any code
- [x] I can clearly describe the concrete benefit — not just "it would be nicer"

### What existing feature or behavior does this improve?

Phase 7 (final) of epic #1769 — migrate `cmdStateSync`, `cmdStatePrune`, and `cmdStateUpdate` (`src/state.cts`) onto the STATE.md Transition Module substrate, and close the maintenance bug pair #1760 (prune no-ops) and #1761 (sync writes wrong progress).

### Current behavior

- `cmdStateSync` recomputes milestone-wide progress from disk + ROADMAP and rewrites STATE.md frontmatter/body. When the ROADMAP lacks versioned milestone headings it cannot bound the current milestone's phase set, yet silently writes fallback-derived (wrong) progress (#1761).
- `cmdStatePrune` reads the current phase via `stateExtractField(content, 'Current Phase')` only; the canonical template emits `Phase: [X] of [Y]`, so the field is null → `currentPhase = 0` → prune always bails with "Only 0 phases — nothing to prune" (#1760).
- `cmdStateUpdate` is a single-field `readModifyWriteStateMd({resync})` write — same scattered shape patch carried.

### Proposed behavior

Add `{kind: 'sync'}`, `{kind: 'prune'}`, and `{kind: 'update'}` to `StateTransitionIntent` and collapse the three callbacks onto `transitionCore`. Ship the two bug fixes:
- **#1760**: derive the current phase with a `Phase` / `Current Phase` fallback (mirroring `buildStateFrontmatter`), so prune engages on template-conformant STATE.md.
- **#1761**: when the current milestone cannot be bounded to a versioned phase set, `sync` warns and leaves progress untouched rather than silently writing fallback values.

### Reason and benefit

Completes the substrate migration (all 10 lifecycle/maintenance transitions now route through `transitionCore`) and retires the bug cluster the epic was filed against (#1760/#1761/#1743/#1695/#1264/#1255/#1257/#3242). ADR-1769 §T6 "high risk, surgical" surface is fully converged at Phase 7 closeout; the old per-call-site shape is retired.

### Scope of changes

| Change | File |
|---|---|
| Add sync + prune + update intents + implementations | `src/state-transition.cts` |
| Collapse the three callbacks; #1760 phase-extraction fallback; #1761 milestone-bound guard | `src/state.cts` |
| Regression + characterization tests (#1760, #1761) | `tests/` |

### Breaking changes

None externally. `state sync`/`prune`/`update` CLI surface unchanged. Bug-fix behavior: prune now works on template STATE.md; sync no longer silently writes wrong progress on unversioned ROADMAPs.



Discussion:

Patch:
diff --git a/docs/adr/1769-state-md-transition-module.md b/docs/adr/1769-state-md-transition-module.md
index 2b8184b1c7..da6c3c002d 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 bf7650c1ce..a79ccc7c54 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/src/state-transition.cts b/src/state-transition.cts
index 981ad12fe4..13862d3656 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<string, string> };
-// Phase 7 adds the remaining intent kinds to this discriminated union.
+  | { kind: 'patch'; patches: Record<string, string> }
+  | { 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<string, unknown>;
+  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 1e0eda460f..08cc0fbad5 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<string, unknown>;
-        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<string>();
   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;
-    }
-  }
-
-  // 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 = (() => {
+  // 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<string, unknown>).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);
+  }
+  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);


Test patch:
diff --git a/scripts/lint-regression-test-names.allowlist.json b/scripts/lint-regression-test-names.allowlist.json
index e4446e9e8a..2b2620a858 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 879d79beab..6758bb7967 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/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 0000000000..9b4402b2bb
--- /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 0000000000..a5ebbe4125
--- /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 7e39a23ea5..3e6b102b5e 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');
+  });
+});