fix(#3578): derive milestone status from phase counters, not phase-completion prose (#3614)

* test(3578): failing-first coverage for milestone status on partial completion

Completing phase 2 of a 4-phase milestone sets frontmatter status: completed while
the same call correctly writes completed_phases: 2 / total_phases: 4. These tests
fail on that conflation and pin the boundary either side of it (3-of-4 must not
complete, 4-of-4 must), plus milestone_name byte-identity and the 1-of-1 case that
legitimately does complete.

* fix(3578): derive milestone status from phase counters, not phase-completion prose

RED proven at 253843b4 (tests-only): the 2-of-4 and 3-of-4 cases failed while the
4-of-4, milestone_name and 1-of-1 controls passed — the conflation, and nothing else.

state complete-phase writes body prose `Phase N complete`. normalizeStateStatus
matches 'complete' as a case-insensitive SUBSTRING, so phase-level prose collapsed
into milestone-level frontmatter status: completed — even while the same call
correctly derived completed_phases: 2 / total_phases: 4 / percent: 50.

Check ORDER is why the sibling surface stays correct: completePhaseCore writes
'Ready to plan' for non-final phases, hitting the 'planning' arm before 'complete'.
The two phase-completion surfaces disagreed and this was the conflated one — a
violation of ADR-2207, which gives milestone termination solely to
milestoneCompleteCore.

buildStateFrontmatter now honors a 'completed' normalization from phase-completion
prose only when the counters it already derived agree. Scoped deliberately:

- anchored to bare `Phase <token> complete`, so 'All phases complete' and
  '<version> milestone complete' are untouched (both out of scope). Verified by
  executing the guard's own regex from source against both forms.
- gated on counter trustworthiness (COMPLETE disk scope, finite counts, positive
  denominator) so an unknown scope withholds rather than guessing 'not complete',
  which would be the mirror-image bug
- normalizeStateStatus itself is NOT modified — it feeds every state.* write and
  the read path

A 1-of-1 milestone still yields 'completed' by the rule, not by exemption, so the
#1255 pinning test stays green on its merits.

Fixes #3578

* fix(3578): gate the guard on milestone boundedness and close the review gaps

Review findings from two orthogonal passes, all fixed inline.

GUARD (correctness, from the standards pass): the guard omitted `milestoneUnbounded`,
which is the established trust authority for these very counters in this same function
— it nulls progressPercent at :2286 and gates the prose fallback at :2294. An unbounded
milestone yields a conflated/understated total, so `completedPhases < totalPhases` could
be an artifact of a bad denominator and demote a genuinely-complete milestone. Now gated.

TESTS:
- Prose/guard parity assertion. The guard regex-matches prose emitted from a DIFFERENT
  file; if that prose drifts the guard silently stops firing and the bug returns
  undetected. Per the repo's generative-fix-divergence rule, a test now asserts the
  emitted body Status still matches the guard's pattern — asserting the emitted value
  against the pattern rather than duplicating the string.
- limit+1: completedPhases > totalPhases must NOT fire; inconsistent counters fall
  through rather than guessing.
- Untrustworthy counters (no phases dir → totalPhases null) must NOT fire.
- AC4: MCP invoke-command dispatch parity via handleMessage, the criterion both
  reviewers independently flagged as asserted-but-untested.
- Hand-rolled STATE.md writes routed through the existing writeState fixture helper.

The adversarial pass independently verified, by reading rather than trusting the diff's
own comments, that: paused/stopped short-circuit before 'completed' so a paused milestone
can never be clobbered; only cmdStateCompletePhase emits the targeted prose, so no
sibling caller over-fires; the counters come from a fresh disk scan independent of this
write, so there is no pre/post off-by-one; and the #1255 pinning fixture creates no
phases dir, leaving completedPhases null and the guard inert — so that test is provably
unaffected rather than assumed to be.

* chore(3578): add changeset fragment

* chore(3578): backfill changeset PR number (#3614)

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-18 08:33:46 -04:00
committed by GitHub
parent b42cb4fb29
commit bcefffc132
3 changed files with 409 additions and 2 deletions

View File

@@ -2296,7 +2296,39 @@ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined, sto
if (pctMatch) progressPercent = parseInt(pctMatch[1], 10);
}
const normalizedStatus = normalizeStateStatus(status, pausedAt);
let normalizedStatus = normalizeStateStatus(status, pausedAt);
// #3578: normalizeStateStatus matches 'complete' as a case-insensitive
// SUBSTRING, so the phase-completion prose cmdStateCompletePhase writes to
// the body (`Phase ${N} complete`) collapses to the milestone-level
// 'completed' status even when other phases remain open. Phase-level
// prose must never decide milestone-level status — completedPhases /
// totalPhases / diskScope, already derived above from a disk scan, are
// the authority on whether the MILESTONE is actually done. Only override
// when: (a) normalizeStateStatus actually landed on 'completed'; (b) the
// raw prose is UNAMBIGUOUSLY phase-completion prose — the anchored
// pattern below deliberately excludes "All phases complete" (no `\S+`
// phase token) and milestone-close prose like "v1.0 milestone complete"
// (no leading "phase"); and (c) the counters are trustworthy (a COMPLETE
// disk scope, both counts are finite numbers, and a positive
// denominator) and affirmatively disagree with 'completed'. In every
// other case normalizedStatus is left exactly as normalizeStateStatus
// returned it.
if (
normalizedStatus === 'completed' &&
typeof status === 'string' &&
/^\s*phase\s+\S+\s+complete\s*$/i.test(status) &&
diskScope === SCOPE.COMPLETE &&
// #1761: an unbounded milestone yields a conflated/understated total — the
// same authority that nulls progressPercent above. Without this, a bad
// denominator could demote a genuinely-complete milestone.
!milestoneUnbounded &&
typeof completedPhases === 'number' && Number.isFinite(completedPhases) &&
typeof totalPhases === 'number' && Number.isFinite(totalPhases) &&
totalPhases > 0 &&
completedPhases < totalPhases
) {
normalizedStatus = 'executing';
}
const fm: Record<string, unknown> = { gsd_state_version: '1.0' };