fix(#4129): derive completed_phases from the ROADMAP authority; honor the progress-ratchet on every state write (#4359)

* test(#4129): failing-first regressions — completed_phases clobber on resyncing writes and phase-complete failure to increment

* fix(#4129): completed_phases derives from the ROADMAP authority and the write path honors the progress-ratchet

Three coordinated prongs (diagnosis in .gsd/bug/fix-4129-completed-phases-recompute/):

P1 — buildStateFrontmatter's disk scan floors the completed-phases numerator at
the milestone-scoped ROADMAP Complete-row count (deriveProgressFromRoadmap, the
one owner), gated inside the same safeToUseRoadmapCount / not-withheld branch
that owns the denominator. A completed phase whose verification routes stale
(#2348 clean-commit-time drift) or is missing no longer under-counts forever.

P2 — applyPreserveAlways's resync arm merges instead of wholesale-replacing on
a measured scan: totals derived both directions (#2440), completed counters
up-only (#2969 — the schema-declared progress-ratchet, now enforced on the
write path like the read path always has), percent recomputed from the merged
counters. The #3756 unmeasured guard and the #3242 explicit-progress contract
are unchanged.

P3 — phase complete's atomic 3-file commit passes the post-completion
ROADMAP-derived counters through the #2736 authoritativeFm seam (new object
direction for the progress key; completedOnlyRaise at the post-preservation
re-assert), because the transaction's disk scan reads the pre-completion
ROADMAP and failed to increment on the completing phase's own write.

* fix(#4129): adversarial-review hardening — intent is a floor at BOTH authoritativeFm sites

The pre-preservation merge could lower a correctly-higher disk-derived
counter (a verification-passed phase whose ROADMAP table row drifted behind
the disk signal). completedOnlyRaise now governs both application sites: the
intent and the derivation agree on direction (up), never on subtraction.

* fix(#4129): the ratchet merge keeps derived values verbatim when numerically equal

The re-parsed derived block carries string scalars ("2") while the curated
snapshot carries numbers (2); substituting the curated spelling over an
equal derived one was a no-op in substance but a shape churn the ADR-3473
§8.7 reporting loop surfaced as a phantom preserved-over-disagreeing-derived
warning on phase complete (ADR-3408 §8.5 Matrix B). Only a strictly-greater
curated counter replaces the derived value now; percent gets the same
verbatim rule.

* changeset(#4129): backfill PR 4359

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-09-05 23:02:07 -04:00
committed by GitHub
parent bd75d42f52
commit e6d047decc
7 changed files with 827 additions and 34 deletions

View File

@@ -59,6 +59,12 @@ const { findPhaseInternal, getArchivedPhaseDirs, listMilestonePhaseDirs } = phas
// eslint-disable-next-line @typescript-eslint/no-require-imports -- roadmap-parser.cjs is an export= CommonJS module
import roadmapParserMod = require('./roadmap-parser.cjs');
const { stripShippedMilestones, extractCurrentMilestone, currentMilestoneRawRanges, withPhaseSection, findMilestoneScopeHeadingLines } = roadmapParserMod;
// #4129: the single owner of "count the ROADMAP's milestone Complete rows"
// (pure computation, no I/O — no cycle on this path) for the intent-first
// progress counters the phase-complete transaction passes downstream.
// eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-lifecycle.cjs is an export= CommonJS module
import phaseLifecycleMod = require('./phase-lifecycle.cjs');
const { deriveProgressFromRoadmap: deriveProgressFromRoadmapForIntent, clampPercent: clampPercentForIntent } = phaseLifecycleMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-workspace.cjs is an export= CommonJS module
import planningWorkspace = require('./planning-workspace.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module
@@ -4395,14 +4401,57 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
const bodyHasPhaseField =
stateExtractField(fmBody, 'Current Phase') != null ||
stateExtractField(fmBody, 'Phase') != null;
const authoritativeFm: Record<string, string> | undefined = nextPhaseDisplayName
? bodyHasPhaseField || !nextPhaseNum
? { current_phase_name: nextPhaseDisplayName }
: {
current_phase: String(nextPhaseNum),
current_phase_name: nextPhaseDisplayName,
}
: undefined;
// #4129: the POST-completion progress counters, derived from the very
// ROADMAP this transaction just mutated (still in memory — it hits disk
// only at writePlanningFileSet, AFTER this content was assembled).
// buildStateFrontmatter's disk scan inside syncAndPreserveStateMd
// reads the PRE-completion ROADMAP (and any stale-dated sibling
// verification), so without this intent the persisted counter failed
// to increment on the completing phase's own transaction. Routed
// through the #2736 authoritativeFm seam's object direction: the
// pre-preservation merge makes it the derived truth the ratchet
// compares, and the post-preservation re-assert (completedOnlyRaise)
// is a floor no preservation branch can drop below. clampPercent is
// completePhaseCore's own percent formula (state-transition.cts),
// reused so the frontmatter and the body `Progress:` line agree.
const postCompletionRoadmapScope = roadmapContent !== null
? extractCurrentMilestone(roadmapContent, cwd)
: null;
const postCompletionRoadmapProgress = postCompletionRoadmapScope !== null
? deriveProgressFromRoadmapForIntent(postCompletionRoadmapScope)
: null;
const authoritativeProgress: Record<string, number> | undefined =
postCompletionRoadmapProgress && postCompletionRoadmapProgress.completedPhases !== null
? postCompletionRoadmapProgress.totalPhases !== null && postCompletionRoadmapProgress.totalPhases > 0
? {
completed_phases: postCompletionRoadmapProgress.completedPhases,
percent: clampPercentForIntent(
postCompletionRoadmapProgress.completedPhases,
postCompletionRoadmapProgress.totalPhases,
),
}
: { completed_phases: postCompletionRoadmapProgress.completedPhases }
: undefined;
const authoritativeFm: Record<string, unknown> | undefined = authoritativeProgress
? {
...(nextPhaseDisplayName
? bodyHasPhaseField || !nextPhaseNum
? { current_phase_name: nextPhaseDisplayName }
: {
current_phase: String(nextPhaseNum),
current_phase_name: nextPhaseDisplayName,
}
: {}),
progress: authoritativeProgress,
}
: nextPhaseDisplayName
? bodyHasPhaseField || !nextPhaseNum
? { current_phase_name: nextPhaseDisplayName }
: {
current_phase: String(nextPhaseNum),
current_phase_name: nextPhaseDisplayName,
}
: undefined;
// ADR-3408 §8.3 / #3469: this deliberately bypasses
// readModifyWriteStateMd (STATE.md is committed atomically with
// ROADMAP/REQUIREMENTS), so it calls the single write-seam