fix(#3807): advance-plan refuses an ambiguous multi-Phase Current Position (#4028)

* test(#3807): advance-plan must refuse an ambiguous multi-entry Current Position

* fix(#3807): refuse an ambiguous multi-Phase Current Position before advancing

* chore(#3807): changeset fragment (pr number backfilled after PR creation)

* chore(#3807): backfill changeset PR number (4028)

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-29 03:50:03 -04:00
committed by GitHub
parent 3a4c3cb83e
commit b811ea16fc
5 changed files with 146 additions and 3 deletions

View File

@@ -16,7 +16,7 @@
// eslint-disable-next-line @typescript-eslint/no-require-imports
import frontmatter = require('./frontmatter.cjs');
import { stateReplaceField, stateExtractField, stateReplaceFieldIfTemplate, stateReplaceFieldWithFallback, stateReplaceFieldInSession } from './state-document.cjs';
import { stateReplaceField, stateExtractField, stateReplaceFieldIfTemplate, stateReplaceFieldWithFallback, stateReplaceFieldInSession, stateCurrentPositionSlice } from './state-document.cjs';
import { KNOWN_TEMPLATE_DEFAULTS, toFiniteNumber } from './state-document.cjs';
import { tokenizeHeadings } from './markdown-sectionizer.cjs';
import type { HeadingToken } from './markdown-sectionizer.cjs';
@@ -1382,6 +1382,34 @@ function advancePlanCore(content: string, deps: StateTransitionDeps): StateTrans
const { body: initialBody, reassemble } = beginFrontmatterReassembly(content, deps.sourcePath);
let body = initialBody;
// #3807: refuse a Current Position section carrying more than one `Phase:`
// entry BEFORE mutating. The plan fields below come from document-wide
// first-match extraction, so in a wave-log style section (one entry per
// completed wave) the FIRST entry's plan counter silently advanced — in the
// reporting incident, a hard-gated final plan 7→8 of 8 — while the entry
// the caller meant sat untouched below it, with advanced:true and no
// ambiguity signal. advance-plan now refuses before acting. Scoped via the
// #2956 canonical locator (stateCurrentPositionSlice — H2 or H3 heading,
// the same one cmdStateAdvancePlan's own milestone read uses); NO whole-body
// fallback — a legacy-format document with unrelated `Phase:` history lines
// elsewhere has no section to disambiguate and must keep its current
// behavior rather than be falsely refused.
const positionScope = stateCurrentPositionSlice(body);
if (positionScope !== null) {
const phaseCandidates = (positionScope.match(/^Phase:.*$/gm) || []);
if (phaseCandidates.length > 1) {
return {
content,
updated: [],
data: {
error: true,
reason: 'ambiguous_position_phase',
phase_candidates: phaseCandidates.map((l) => l.trim()),
},
};
}
}
// Parse plan number — legacy first, then compound.
const legacyPlan = stateExtractField(content, 'Current Plan');
const legacyTotal = stateExtractField(content, 'Total Plans in Phase');

View File

@@ -894,6 +894,16 @@ function cmdStateAdvancePlan(cwd: string, raw: boolean): void {
}, cwd, { divergedFields, preWriteState });
if (!resultData || resultData['error']) {
// #3807: a multi-`Phase:` Current Position section carries its own cause
// and its own remedy (name the candidates; the caller resolves them).
if (resultData && resultData['reason'] === 'ambiguous_position_phase') {
output({
error: 'Current Position section contains more than one Phase: entry — refusing to silently advance the first. Resolve the section to a single current entry and re-run.',
reason: resultData['reason'],
phase_candidates: resultData['phase_candidates'],
}, raw, undefined);
return;
}
output({ error: 'Cannot parse Current Plan or Total Plans in Phase from STATE.md' }, raw, undefined);
return;
}