fix(#2028): phase.complete milestone-end out-of-order + workstream root-fallback guard

Two code-confirmed defects in `gsd-tools phase complete` (re-verified against
next; the three severe corruption paths the issue filed are superseded by the
ADR-1769 Transition Module migration + #2012, so this is the confirmed remainder).

1. Milestone-end mislabel (isLastPhase). The milestone-end determination only
   cleared isLastPhase when a HIGHER-numbered phase existed, so completing the
   numerically-highest phase out of order (e.g. Phase 10 before Phase 9) stamped
   STATE.md `Status: Milestone complete` while a lower phase was still outstanding.
   Added a lower-phase check: after the existing higher-phase scans, if any earlier
   phase in the current milestone has an unchecked roadmap checkbox (`[ ]`),
   isLastPhase becomes false AND next_phase/next_phase_name point at the LOWEST
   outstanding lower phase — so STATE.md advances to the real gap instead of
   parking on the just-completed phase. A completed phase always has `[x]`
   (phase.complete sets it), so all-lower-complete still reports milestone-end;
   heading-only roadmaps (no checkboxes) retain prior behavior. The checkbox regex
   mirrors the sibling phasePattern's anchoring (whitespace/bold + required `:`) so
   unrelated checklist lines mentioning "Phase N" don't match.

2. Workstream root-fallback (no guard). cmdPhaseComplete resolves every path via
   planningDir(cwd); with a `workstreams/` dir present but no active workstream and
   no --ws, that returns root `.planning`, so phase.complete wrote STATE.md/
   ROADMAP.md (and the mislabel) into the shared root other workstreams read.
   Added the same #1912 fail-safe guard init.progress got: refuse (asking for
   `--ws`/active workstream) instead of silently writing root. Resolution itself
   was already wired globally (resolveActiveWorkstream: --ws > GSD_WORKSTREAM >
   pointer, set in bin/gsd-tools.cjs), so only the refusal guard was missing.

The workstream-mode detection (`listAvailableWorkstreams`) is extracted into
planning-workspace.cts as the single source of truth and consumed by BOTH
init.progress and phase.complete, so the two fail-safe paths cannot drift.

Tests (tests/phase.test.cjs, new #2028 describe): out-of-order completion becomes
`Ready to plan` with is_last_phase=false, next_phase pointing at the outstanding
phase and Current Phase advancing to it (not the completed phase); all-lower-
complete still reports milestone-end; workstream-mode-no-active refuses with an
`--ws` hint; `--ws` completes in the workstream leaving root untouched; flat mode
unaffected. Fail-first verified locally via direct gsd-tools invocation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-07-07 13:45:00 -04:00
parent 9a9f304690
commit 51dfa683d4
5 changed files with 277 additions and 12 deletions

View File

@@ -3749,6 +3749,199 @@ describe('phase complete milestone-scoped next-phase', () => {
});
});
// ─────────────────────────────────────────────────────────────────────────────
// #2028 — phase.complete milestone-end inference + workstream root-fallback guard
// ─────────────────────────────────────────────────────────────────────────────
describe('#2028 — phase complete milestone-end + workstream guard', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
// A complement phase numbered AFTER Phase 9 but executed first. Completing the
// numerically-highest phase must not read as milestone-end while a lower phase
// is still outstanding (the isLastPhase blocks only checked for HIGHER phases).
test('does NOT stamp "Milestone complete" when a lower-numbered phase is still outstanding', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n\n- [ ] Phase 9: Introspection\n- [ ] Phase 10: Complement\n\n### Phase 9: Introspection\n**Goal:** baseline\n\n### Phase 10: Complement\n**Goal:** complement\n`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# State\n\n**Current Phase:** 10\n**Status:** In progress\n**Current Plan:** 10-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
);
const p10 = path.join(tmpDir, '.planning', 'phases', '10-complement');
fs.mkdirSync(p10, { recursive: true });
fs.writeFileSync(path.join(p10, '10-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(p10, '10-01-SUMMARY.md'), '# Summary');
const result = runVerifiedPhaseComplete('phase complete 10', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(
output.is_last_phase,
false,
'Phase 10 is numerically highest but Phase 9 is outstanding → not milestone-end',
);
// The outstanding lower phase IS the real next actionable item — STATE.md must
// advance to it (the gap), not park on the just-completed Phase 10.
assert.strictEqual(String(Number(output.next_phase)), '9', 'next_phase should point at the outstanding Phase 9');
const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(
!/Milestone complete/i.test(state),
'STATE.md must NOT flip to "Milestone complete" while a lower phase is outstanding',
);
assert.ok(/Ready to plan/i.test(state), 'status should be "Ready to plan"');
assert.match(
state,
/\*\*Current Phase:\*\*\s*0*9\b/,
'Current Phase must advance to the outstanding Phase 9, not stay on the completed Phase 10',
);
assert.doesNotMatch(
state,
/\*\*Current Phase:\*\*\s*10\b/,
'Current Phase must NOT remain on the just-completed Phase 10',
);
});
// Guard against over-correction: when every earlier phase is [x], completing
// the numerically-highest phase IS still the milestone end.
test('still detects milestone-end when all lower phases are checked complete', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n\n- [x] Phase 9: Introspection\n- [ ] Phase 10: Complement\n\n### Phase 9: Introspection\n**Goal:** baseline\n\n### Phase 10: Complement\n**Goal:** complement\n`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# State\n\n**Current Phase:** 10\n**Status:** In progress\n**Current Plan:** 10-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
);
const p10 = path.join(tmpDir, '.planning', 'phases', '10-complement');
fs.mkdirSync(p10, { recursive: true });
fs.writeFileSync(path.join(p10, '10-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(p10, '10-01-SUMMARY.md'), '# Summary');
const result = runVerifiedPhaseComplete('phase complete 10', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.is_last_phase, true, 'all lower phases complete → Phase 10 is milestone-end');
const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(/Milestone complete/i.test(state), 'status should be "Milestone complete"');
});
// The lower-phase scan must not treat an unrelated checklist line that merely
// mentions "Phase N" (no `:` after the number) as an outstanding phase — the
// checkbox regex is anchored like the sibling phase scan.
test('does not treat an unrelated checklist line mentioning a phase number as outstanding', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n\n- [ ] Add regression coverage for Phase 3 rollback\n- [ ] Phase 5: Final\n\n### Phase 5: Final\n**Goal:** end\n`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# State\n\n**Current Phase:** 05\n**Status:** In progress\n**Current Plan:** 05-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
);
const p5 = path.join(tmpDir, '.planning', 'phases', '05-final');
fs.mkdirSync(p5, { recursive: true });
fs.writeFileSync(path.join(p5, '05-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(p5, '05-01-SUMMARY.md'), '# Summary');
const result = runVerifiedPhaseComplete('phase complete 5', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(
output.is_last_phase,
true,
'the "Phase 3 rollback" prose line must NOT be read as an outstanding Phase 3',
);
});
// #1912 parity: in workstream mode with no active workstream, planningDir(cwd)
// resolves to root .planning — writing STATE.md/ROADMAP.md into the shared root
// that other workstreams read. Refuse instead of silently writing root.
test('refuses to write root in workstream mode when no workstream is resolved', () => {
fs.mkdirSync(path.join(tmpDir, '.planning', 'workstreams', 'alpha'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'workstreams', 'beta'), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'# State\n\n**Current Phase:** 01\n**Status:** In progress\n',
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n- [ ] Phase 1: A\n\n### Phase 1: A\n**Goal:** x\n',
);
const result = runGsdTools('phase complete 1', tmpDir);
assert.equal(result.success, false, 'should refuse rather than silently writing root STATE/ROADMAP');
assert.match(result.error || '', /workstream|--ws/i, 'error should name the workstream requirement');
});
// An explicit --ws satisfies the guard (it sets GSD_WORKSTREAM upstream) AND
// targets that workstream — the write must land in the workstream's own
// STATE.md/ROADMAP.md, leaving root untouched.
test('--ws satisfies the guard and writes the workstream, not root', () => {
fs.mkdirSync(path.join(tmpDir, '.planning', 'workstreams', 'beta'), { recursive: true });
const wsDir = path.join(tmpDir, '.planning', 'workstreams', 'alpha');
fs.mkdirSync(path.join(wsDir, 'phases', '01-only'), { recursive: true });
fs.writeFileSync(path.join(wsDir, 'ROADMAP.md'), '# Roadmap\n\n### Phase 1: Only\n**Goal:** x\n');
fs.writeFileSync(
path.join(wsDir, 'STATE.md'),
'# State\n\n**Current Phase:** 01\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** W\n',
);
fs.writeFileSync(path.join(wsDir, 'phases', '01-only', '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(wsDir, 'phases', '01-only', '01-01-SUMMARY.md'), '# Summary');
fs.writeFileSync(
path.join(wsDir, 'phases', '01-only', '01-VERIFICATION.md'),
'---\nstatus: passed\n---\n# Verification\n',
);
// A distinct root STATE.md that must be left byte-for-byte untouched.
const rootState = '# ROOT State\n\n**Current Phase:** 99\n**Status:** Root sentinel\n';
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), rootState);
const result = runGsdTools('phase complete 1 --ws alpha', tmpDir);
assert.ok(result.success, `--ws alpha should complete in the workstream: ${result.error}`);
// Root STATE.md must be untouched — the write landed in the workstream.
assert.strictEqual(
fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'),
rootState,
'root STATE.md must NOT be written when --ws targets a workstream',
);
// The workstream's own STATE.md advanced (single phase → milestone complete).
const wsState = fs.readFileSync(path.join(wsDir, 'STATE.md'), 'utf-8');
assert.match(wsState, /Milestone complete/i, "the workstream's STATE.md should be the one updated");
});
// The guard only fires in workstream mode — a flat project (no workstreams dir)
// completes normally.
test('flat mode (no workstreams dir) still completes normally', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n\n### Phase 1: Only\n**Goal:** x\n`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# State\n\n**Current Phase:** 01\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
);
const p1 = path.join(tmpDir, '.planning', 'phases', '01-only');
fs.mkdirSync(p1, { recursive: true });
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
const result = runVerifiedPhaseComplete('phase complete 1', tmpDir);
assert.ok(result.success, `flat mode should still complete: ${result.error}`);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// exact token matching (no prefix collisions)
// ─────────────────────────────────────────────────────────────────────────────