From 8fb6681a1e75016f7a998cd7623f66579ce531ec Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 6 Aug 2026 01:35:06 -0400 Subject: [PATCH] fix(#3017): scope state write disk scan to stored milestone (#3105) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(#3017): scope state write's disk scan to stored milestone buildStateFrontmatter called getMilestonePhaseFilter(cwd) WITHOUT the stored milestone version, so it auto-derived from ROADMAP.md — and when getMilestoneInfo mis-bound (the stored milestone had no matching non-✅ heading), it picked a confidently-wrong milestone and clobbered the stored value + rewrote progress with whole-project counts on every state.* write. Pass the stored milestone from STATE.md frontmatter through to buildStateFrontmatter and use it as the explicit versionOverride for getMilestonePhaseFilter. When the stored milestone is available, the filter scopes to it instead of auto-deriving. * chore(#3017): backfill changeset PR number 3105 --------- Co-authored-by: sim --- .changeset/quick-otters-howl.md | 5 +++++ src/state.cts | 13 ++++++++++--- 2 files changed, 15 insertions(+), 3 deletions(-) create mode 100644 .changeset/quick-otters-howl.md diff --git a/.changeset/quick-otters-howl.md b/.changeset/quick-otters-howl.md new file mode 100644 index 000000000..0699eeb7b --- /dev/null +++ b/.changeset/quick-otters-howl.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3105 +--- +**`state.*` writes no longer flip the milestone or rewrite progress with whole-project counts** — when the stored milestone had no matching non-shipped ROADMAP heading, `buildStateFrontmatter` auto-derived a confidently-wrong milestone and clobbered the stored value + progress on every write. The disk scan now scopes to the STORED milestone explicitly, so a state write that doesn't change progress leaves the milestone and progress block untouched. (#3017) diff --git a/src/state.cts b/src/state.cts index 867b03f58..836a3fdb3 100644 --- a/src/state.cts +++ b/src/state.cts @@ -1605,7 +1605,7 @@ function extractRetiredPhaseNumbers(scope: string): Set { * a YAML frontmatter object. Allows hooks and scripts to read state * reliably via `state json` instead of fragile regex parsing. */ -function buildStateFrontmatter(bodyContent: string, cwd: string | undefined): Record { +function buildStateFrontmatter(bodyContent: string, cwd: string | undefined, storedMilestone?: string | null): Record { // #2956: scope `Phase` extraction to ## Current Position (mirrors the read // path in cmdStateSnapshot and the Stopped At / Paused At ## Session scoping // below). Phase canonically lives in ## Current Position (templates/state.md); @@ -1682,7 +1682,10 @@ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined): Re } } catch { /* fall through: no roadmap scope → no retired exclusion */ } - const isDirInMilestone = getMilestonePhaseFilter(cwd) as (dir: string) => boolean; + // #3017: scope the milestone filter to the STORED milestone when available, + // so a state.* write doesn't auto-derive (and mis-bind) to a different + // milestone's heading and clobber the stored value + progress counts. + const isDirInMilestone = getMilestonePhaseFilter(cwd, storedMilestone ?? undefined) as (dir: string) => boolean; const allMatchingDirs = fs.readdirSync(phasesDir, { withFileTypes: true }) .filter(e => e.isDirectory()).map(e => e.name) .filter(isDirInMilestone); @@ -1855,7 +1858,11 @@ function syncStateFrontmatter(content: string, cwd: string | undefined, authorit cwd ? planningPaths(cwd).state : undefined, ) as Record; const body = stripFrontmatter(content); - const derivedFm = buildStateFrontmatter(body, cwd); + // #3017: pass the stored milestone from the existing frontmatter so + // buildStateFrontmatter scopes its disk scan to the correct milestone + // instead of auto-deriving (and potentially mis-binding). + const storedMilestone = typeof existingFm['milestone'] === 'string' ? existingFm['milestone'] : null; + const derivedFm = buildStateFrontmatter(body, cwd, storedMilestone); // Preserve existing frontmatter status when body-derived status is 'unknown'. // This prevents a missing Status: field in the body from overwriting a