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

@@ -80,6 +80,7 @@ const {
planningPaths,
planningDir,
planningRoot,
listAvailableWorkstreams,
getActiveWorkstream,
findContextMdIn,
} = planningWorkspace;
@@ -1661,17 +1662,7 @@ function cmdInitProgress(cwd: string, raw: boolean): void {
// reporting a stale root milestone. Require an explicit workstream instead.
// Mirror planningDir's resolution (GSD_WORKSTREAM env > stored active pointer) so
// an explicit --ws (which sets GSD_WORKSTREAM) satisfies the check.
const _wsRoot = path.join(planningRoot(cwd), 'workstreams');
let _availableWorkstreams: string[] = [];
try {
_availableWorkstreams = fs
.readdirSync(_wsRoot, { withFileTypes: true })
.filter((e) => e.isDirectory())
.map((e) => e.name)
.sort();
} catch {
/* no workstreams dir → flat mode */
}
const _availableWorkstreams = listAvailableWorkstreams(cwd);
const _resolvedWorkstream = process.env['GSD_WORKSTREAM'] || getActiveWorkstream(cwd);
if (_availableWorkstreams.length > 0 && !_resolvedWorkstream) {
error(