fix(#3573): keep the stored total_phases when the roadmap is absent at state-write time (#3595)

* test(#3573): pin stored-total retention when the roadmap is absent at state-write time

Failing-first regression for #3573: with ROADMAP.md absent and a milestone
asserted, every state.* write persisted the phase-directory count as
progress.total_phases (5 -> 1 in the issue) — only STARTED phases count,
quietly defeating #549's single source of truth. Rows pin the stored-value
outcome + stderr warning across record-session and begin-phase, the
fresh-project doctrine (no milestone asserted -> dir count stays), and the
roadmap-present control.

* fix(#3573): keep the stored total_phases when the roadmap is absent at state-write time

The #3354 withhold covered milestoned-but-unbounded roadmaps but not the
roadmap-absent shape: with ROADMAP.md unreadable the #549 heading counter
never runs, milestoneBounded is vacuously true, and every state.* write
persisted the phase-directory count as progress.total_phases — counting
only STARTED phases (5 -> 1 in the issue). When the STATE asserts a
milestone (storedMilestone), the stored frontmatter total now wins and a
(#3353)-style stderr warning names the condition; with no asserted
milestone the disk count stays authoritative (fresh-project doctrine).

* fix(#3573): thread stored milestone into the state json read for write/read parity; discriminate the doctrine row; pin planned-phase

Review findings: cmdStateJson passed storedMilestone=undefined so the new
withhold never fired on the read surface — state json reported the dir
count while the persisted file preserved the stored total (exactly the
divergence #3354 closed for its shape). The fresh-project doctrine row now
uses stored 5 vs dirs 2 so a milestone-gate-less withhold mutant cannot
survive it; the third issue-named verb (planned-phase) is pinned.

* chore(#3573): add changeset fragment

* chore(#3573): backfill changeset pr number

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-17 14:06:09 -04:00
committed by GitHub
parent 7c649a9970
commit b08af152e4
3 changed files with 196 additions and 4 deletions

View File

@@ -2198,10 +2198,35 @@ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined, sto
`gsd: warning — milestone '${String(assertedMilestoneVersion ?? '').trim()}' is asserted in STATE.md but matches no ROADMAP heading, and the ROADMAP carries multiple milestone sections; the on-disk phase-directory count would understate the declared total, so progress.total_phases is left at its stored value. (#3354)\n`
);
}
// #3573: the roadmap-absent sibling of the #3354 shape. With ROADMAP.md
// absent/unreadable the #549 heading counter never ran (roadmapScope
// stayed null), `milestoneBounded` is vacuously true (its gate requires
// roadmapRaw), and the dir count — which only ever counts phases that
// have STARTED — would be persisted as progress.total_phases by every
// state.* write. A STATE that asserts a milestone (storedMilestone —
// getMilestoneInfo is useless here, it reads the roadmap that is
// absent) declared a total somewhere; keep the stored frontmatter
// value instead. Without an asserted milestone (fresh project,
// pre-roadmap) the disk count is still the only source and stays
// authoritative (the #3354 doctrine's degenerate case).
const roadmapAbsentWithAssertedMilestone =
roadmapRaw === null &&
typeof storedMilestone === 'string' &&
storedMilestone.trim() !== '';
if (roadmapAbsentWithAssertedMilestone) {
process.stderr.write(
`gsd: warning — milestone '${storedMilestone.trim()}' is asserted in STATE.md but ROADMAP.md is absent or unreadable, so the phase-heading total cannot be derived; the on-disk phase-directory count would understate the declared total, so progress.total_phases is left at its stored value. (#3573)\n`
);
}
return {
totalPhases: safeToUseRoadmapCount
? Math.max(phaseDirs.length, roadmapPhaseCount)
: (milestonedButUnbounded ? null : phaseDirs.length),
// The two WITHHOLD shapes (#3354 milestoned-but-unbounded, #3573
// roadmap-absent-with-asserted-milestone) must be evaluated BEFORE
// safeToUseRoadmapCount — in the #3573 shape milestoneBounded is
// vacuously true (its gate requires roadmapRaw), so the safe-count
// arm would otherwise swallow the withhold.
totalPhases: (milestonedButUnbounded || roadmapAbsentWithAssertedMilestone)
? null
: (safeToUseRoadmapCount ? Math.max(phaseDirs.length, roadmapPhaseCount) : phaseDirs.length),
milestoneBounded,
completedPhases: diskCompletedPhases,
totalPlans: diskTotalPlans,
@@ -3510,7 +3535,12 @@ function cmdStateJson(cwd: string, raw: boolean): void {
// when SUMMARY files were added after the last STATE.md write (#1589).
// #3354: pass the stored total so the milestoned-but-unbounded withhold can
// report the preserved value instead of omitting the key.
const built = buildStateFrontmatter(body, cwd, undefined, readStoredTotalPhases(existingFm));
// #3573: pass the STORED MILESTONE too (same parity reasoning) — otherwise the
// roadmap-absent withhold never fires on this read surface and `state json`
// reports the phase-directory count while the persisted file preserves the
// stored total, exactly the write/read divergence #3354 closed for its shape.
const storedMilestoneJson = typeof existingFm['milestone'] === 'string' ? existingFm['milestone'] : null;
const built = buildStateFrontmatter(body, cwd, storedMilestoneJson, readStoredTotalPhases(existingFm));
// ADR-3408 §8.5 / D3: route stopped_at / paused_at / status / current_phase /
// current_phase_name / current_plan through the SAME `preserve-when-unchanged`