fix(#4067): derive advance-plan phase-complete from disk, not the plan counter (#4292)

* test(#4067): pin advance-plan phase-complete guard matrix (RED)

Five-case matrix: decline on unsummarized plans (regression), fire on
fully-summarized phase, fail-open on unresolvable phase dir, idempotent
decline, normal advance untouched.

* fix(#4067): derive advance-plan phase-complete from disk, not the plan counter

The phase-complete branch of state.advance-plan was decided purely by
STATE.md's scalar plan counter (currentPlan >= totalPlans). A stale
counter carried into a newly planned phase, or a counter raced by
wave-parallel executors, let 'Phase complete — ready for verification'
land while sibling plans were still executing.

cmdStateAdvancePlan now re-decides that branch from disk before the
write: every plan in the Current Position phase's directory must have a
SUMMARY.md (scanPhasePlans single owner, the same source
state.update-progress recalculates from). Outstanding plans decline the
entire write byte-identically (idempotent, concurrency-safe, counter
stays display-only); an unavailable disk answer fails open to the
counter-derived decision.

* fix(#4067): review round 1 — route phase-dir lookup through listMilestonePhaseDirs

#3185 drift guard: no hand-rolled phases-dir readdirSync. Windowed
(current-milestone) lookup first so an archived milestone's stale dir
cannot shadow the live one; unscoped retry when the window cannot
answer. Also restore the transform's undefined-data error semantics and
extract scanOutstanding.

* chore(#4067): add changeset fragment

* chore(#4067): backfill PR number in changeset fragment

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-09-04 17:49:01 -04:00
committed by GitHub
parent d29b50d696
commit 2e056488d9
3 changed files with 265 additions and 0 deletions

View File

@@ -1986,6 +1986,131 @@ describe('cmdStateAdvancePlan (state advance-plan)', () => {
const updated = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(updated.includes('Phase complete'), 'Status should contain Phase complete');
});
// #4067: advance-plan's phase-complete decision must be derived from disk
// state (every plan in the phase directory has a SUMMARY.md, via the
// scanPhasePlans single owner) rather than from STATE.md's scalar plan
// counter. A serial counter cannot represent wave-parallel execution — a
// stale counter from the prior phase (the reported trigger) or a racing
// counter under N concurrent executors both let `X >= Y` fire the
// phase-complete branch while sibling plans are mid-flight.
describe('cmdStateAdvancePlan #4067 wave-parallel phase-complete guard', () => {
const waveFixture = [
'# Project State',
'',
'## Current Position',
'',
'Phase: 2 — Build out',
'Plan: 7 of 7',
'Status: Executing',
'Last Activity: 2026-09-01',
'',
].join('\n');
const seedPhaseDir = (dir, planCount, summaryCount) => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', dir);
fs.mkdirSync(phaseDir, { recursive: true });
for (let i = 1; i <= planCount; i++) {
fs.writeFileSync(path.join(phaseDir, `02-0${i}-PLAN.md`), `# plan ${i}\n`);
}
for (let i = 1; i <= summaryCount; i++) {
fs.writeFileSync(path.join(phaseDir, `02-0${i}-SUMMARY.md`), `# summary ${i}\n`);
}
return phaseDir;
};
test('declines phase-complete while plans lack summaries (stale counter)', () => {
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), waveFixture);
seedPhaseDir('02-second', 3, 1);
const result = runGsdTools('state advance-plan', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.advanced, false, 'advanced should be false');
assert.strictEqual(out.reason, 'plans_outstanding',
`reason should be plans_outstanding; got: ${JSON.stringify(out)}`);
assert.ok(Array.isArray(out.outstanding_plans) && out.outstanding_plans.length === 2,
`outstanding_plans should name the 2 unsummarized plans; got: ${JSON.stringify(out.outstanding_plans)}`);
const updated = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(!updated.includes('Phase complete'),
'STATE.md must NOT say Phase complete while plans are unsummarized');
assert.ok(updated.includes('Status: Executing'),
'STATE.md Status must be left unchanged by the decline');
});
test('fires phase-complete when every plan on disk has a summary', () => {
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), waveFixture);
seedPhaseDir('02-second', 3, 3);
const result = runGsdTools('state advance-plan', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.advanced, false);
assert.strictEqual(out.reason, 'last_plan',
`a fully-summarized phase must still take the phase-complete branch; got: ${JSON.stringify(out)}`);
const updated = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(updated.includes('Phase complete'), 'Status should contain Phase complete');
});
test('keeps counter-derived phase-complete when the phase directory cannot be determined', () => {
// No phase directory matching Current Position's "Phase: 2" exists —
// the disk answer is unavailable, so the guard fails open to the
// counter-derived decision (existing pinned fixtures exercise the
// no-Current-Position spelling; this one pins the no-matching-dir one).
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), waveFixture);
seedPhaseDir('09-unrelated', 3, 0);
const result = runGsdTools('state advance-plan', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.reason, 'last_plan',
`unresolvable phase dir must keep legacy counter behavior; got: ${JSON.stringify(out)}`);
const updated = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(updated.includes('Phase complete'), 'Status should contain Phase complete');
});
test('is idempotent when re-run while plans are outstanding', () => {
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), waveFixture);
seedPhaseDir('02-second', 3, 1);
const first = runGsdTools('state advance-plan', tmpDir);
assert.ok(first.success, `First call failed: ${first.error}`);
const afterFirst = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
const second = runGsdTools('state advance-plan', tmpDir);
assert.ok(second.success, `Second call failed: ${second.error}`);
const out = JSON.parse(second.output);
assert.strictEqual(out.reason, 'plans_outstanding',
`re-run must decline identically; got: ${JSON.stringify(out)}`);
const afterSecond = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.strictEqual(afterSecond, afterFirst,
'a declined advance-plan must leave STATE.md byte-identical (idempotent, race-safe)');
});
test('normal advance is untouched by the disk guard', () => {
const midPhase = waveFixture.replace('Plan: 7 of 7', 'Plan: 1 of 3');
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), midPhase);
seedPhaseDir('02-second', 3, 0);
const result = runGsdTools('state advance-plan', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.advanced, true,
`counter below total must still advance (display-only counter); got: ${JSON.stringify(out)}`);
assert.strictEqual(out.current_plan, 2);
const updated = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(updated.includes('Plan: 2 of 3'), 'Plan counter should advance to 2 of 3');
});
});
});
describe('cmdStateRecordMetric (state record-metric)', () => {