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:
13
src/init.cts
13
src/init.cts
@@ -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(
|
||||
|
||||
@@ -61,7 +61,8 @@ const { evaluateUatPassed } = uatPredicate;
|
||||
import verificationMod = require('./verification.cjs');
|
||||
const { readVerificationStatus } = verificationMod;
|
||||
|
||||
const { planningDir, withPlanningLock } = planningWorkspace;
|
||||
const { planningDir, withPlanningLock, listAvailableWorkstreams, getActiveWorkstream } =
|
||||
planningWorkspace;
|
||||
const { extractFrontmatter } = frontmatterMod;
|
||||
const {
|
||||
readModifyWriteStateMd,
|
||||
@@ -1376,6 +1377,22 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
|
||||
error('phase number required for phase complete');
|
||||
}
|
||||
|
||||
// #2028: fail safe in workstream mode with no active workstream. With no active
|
||||
// workstream and no --ws, planningDir(cwd) resolves to root .planning, so
|
||||
// phase.complete would write STATE.md/ROADMAP.md (and mislabel milestone status)
|
||||
// into the shared root that other workstreams read. Mirror the #1912 guard that
|
||||
// init.progress got (resolution: GSD_WORKSTREAM env > stored active pointer; an
|
||||
// explicit --ws sets GSD_WORKSTREAM upstream and satisfies the check).
|
||||
const availableWorkstreams = listAvailableWorkstreams(cwd);
|
||||
const resolvedWorkstream = process.env['GSD_WORKSTREAM'] || getActiveWorkstream(cwd);
|
||||
if (availableWorkstreams.length > 0 && !resolvedWorkstream) {
|
||||
error(
|
||||
`phase.complete requires a workstream in workstream mode — no active workstream is set, so root STATE.md/ROADMAP.md (likely stale) would be written. ` +
|
||||
`Pass --ws <name> or run ${formatGsdSlash('workstream set', resolveRuntime(cwd)) as string} first. ` +
|
||||
`Available workstreams: ${availableWorkstreams.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
|
||||
const statePath = path.join(planningDir(cwd), 'STATE.md');
|
||||
const phasesDir = path.join(planningDir(cwd), 'phases');
|
||||
@@ -1698,6 +1715,48 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
|
||||
}
|
||||
}
|
||||
|
||||
// #2028: don't stamp "Milestone complete" when a LOWER-numbered phase is
|
||||
// still outstanding. The two blocks above only clear isLastPhase when a
|
||||
// HIGHER-numbered phase exists, so completing the numerically-highest phase
|
||||
// out of order (e.g. Phase 10 before Phase 9) wrongly read as milestone-end.
|
||||
// A phase is complete iff its roadmap checkbox is `[x]` (phase.complete sets
|
||||
// this on completion — including the one just marked above); any earlier
|
||||
// phase in this milestone whose checkbox is still `[ ]` means the milestone
|
||||
// is not done, and the LOWEST such phase is the real next actionable item —
|
||||
// point next_phase at it so STATE.md advances to the gap rather than parking
|
||||
// on the just-completed phase. Roadmaps without phase checkboxes (heading-
|
||||
// only) retain the prior behavior — there is nothing to scan. The checkbox
|
||||
// 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) {
|
||||
try {
|
||||
const milestoneScope = extractCurrentMilestone(roadmapContent, cwd);
|
||||
const cbPattern =
|
||||
/-\s*\[(x| )\]\s*(?:\*\*|__)?\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)(?:\s*\([^)\n]*\))?\s*:\s*([^\n*]+)/gi;
|
||||
let cbm: RegExpExecArray | null;
|
||||
let lowestOutstanding: { num: string; name: string } | null = null;
|
||||
while ((cbm = cbPattern.exec(milestoneScope)) !== null) {
|
||||
const isChecked = cbm[1].toLowerCase() === 'x';
|
||||
if (!isChecked && comparePhaseNum(cbm[2], phaseNum) < 0) {
|
||||
if (lowestOutstanding === null || comparePhaseNum(cbm[2], lowestOutstanding.num) < 0) {
|
||||
lowestOutstanding = {
|
||||
num: cbm[2],
|
||||
name: cbm[3].replace(/\(INSERTED\)/i, '').trim().toLowerCase().replace(/\s+/g, '-'),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
if (lowestOutstanding !== null) {
|
||||
isLastPhase = false;
|
||||
nextPhaseNum = lowestOutstanding.num;
|
||||
nextPhaseName = lowestOutstanding.name;
|
||||
}
|
||||
} catch {
|
||||
/* intentionally empty */
|
||||
}
|
||||
}
|
||||
|
||||
if (fs.existsSync(statePath)) {
|
||||
const originalStateContent = platformReadSync(statePath) || '';
|
||||
let stateContent = originalStateContent;
|
||||
|
||||
@@ -142,6 +142,22 @@ function planningRoot(cwd: string): string {
|
||||
return path.join(cwd, '.planning');
|
||||
}
|
||||
|
||||
// Sorted list of workstream directory names under `<root>/.planning/workstreams`,
|
||||
// or `[]` when the project is flat (no workstreams dir). Single source of truth
|
||||
// for the "workstream mode" detection shared by the #1912/#2028 fail-safe guards
|
||||
// (init.progress, phase.complete) so the two paths cannot drift.
|
||||
function listAvailableWorkstreams(cwd: string): string[] {
|
||||
try {
|
||||
return fs
|
||||
.readdirSync(path.join(planningRoot(cwd), 'workstreams'), { withFileTypes: true })
|
||||
.filter((e) => e.isDirectory())
|
||||
.map((e) => e.name)
|
||||
.sort();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
interface PlanningPaths {
|
||||
planning: string;
|
||||
state: string;
|
||||
@@ -389,6 +405,7 @@ export = {
|
||||
createMemoryPointerAdapter,
|
||||
planningDir,
|
||||
planningRoot,
|
||||
listAvailableWorkstreams,
|
||||
planningPaths,
|
||||
withPlanningLock,
|
||||
getActiveWorkstream,
|
||||
|
||||
Reference in New Issue
Block a user