fix(#3350): prefer lowest outstanding phase over positional next in phase complete (#3482)

* fix(#3350): prefer lowest outstanding phase over positional next in phase complete

* chore(#3350): add changeset fragment

* chore(#3350): backfill changeset pr field

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-14 11:30:39 -04:00
committed by GitHub
parent 70b5c1a1bf
commit c90ae479f9
3 changed files with 331 additions and 8 deletions

View File

@@ -2899,7 +2899,17 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
// pattern mirrors the sibling phasePattern's anchoring (only whitespace/bold
// between the box and "Phase", a required `:`) so unrelated checklist lines
// that merely mention "Phase N" don't match.
if (isLastPhase && roadmapContent !== null) {
// #3350: this stage answers a DIFFERENT question than stages 1-2 ("what is
// the next actionable phase?" vs "is this the last phase?"), so it must not
// be gated on their answer. Gating on isLastPhase let a merely-positionally
// next higher heading (stage 2) permanently mask a genuinely-outstanding
// lower phase — stage 2 cleared isLastPhase and this scan never ran. The
// scan already refuses anything not strictly lower than the completed phase
// (plus sentinels, #2949), so running it unconditionally cannot manufacture
// a wrong answer: when no lower phase is outstanding it finds nothing and
// stages 1-2's pick stands unchanged; in the masking case isLastPhase is
// already false, so the last-phase signal has no reachable regression.
if (roadmapContent !== null) {
try {
const milestoneScope = extractCurrentMilestone(roadmapContent, cwd);
const cbPattern = new RegExp(
@@ -2988,11 +2998,27 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
// the intent; pass it as authoritative so the sync's prose
// re-derivation cannot rewrite current_phase_name to the name's own
// parenthetical (`Closer-ruling measurement (D1a)` → `D1a`).
stateContent = syncStateFrontmatter(
stateContent,
cwd,
nextPhaseDisplayName ? { current_phase_name: nextPhaseDisplayName } : undefined,
);
// #3350: PAIR the override. When STATE.md's body carries no Current
// Phase / Phase field to re-derive from (narrative prose), the #905
// preserve guard in syncStateFrontmatter keeps the OLD frontmatter
// current_phase while the authoritative current_phase_name advances —
// leaving the two fields describing different phases. Pin BOTH to the
// resolved next phase in that case. When the body DOES carry the field
// (completePhaseCore just rewrote it), stay name-only so the body's
// richer `N of T (name)` derived shape survives the sync.
const fmBody = frontmatterMod.stripFrontmatter(stateContent);
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;
stateContent = syncStateFrontmatter(stateContent, cwd, authoritativeFm);
writes.push({ filePath: statePath, before: originalStateContent, after: stateContent });
}