cmdInitProgress used planningDir(cwd), which resolves to root .planning when no active workstream and no --ws/GSD_WORKSTREAM is set — regardless of mode:workstream. So /gsd-progress confidently reported a stale root milestone with no signal it was stale. Fail safe: when .planning/workstreams/ has workstreams AND no active workstream is resolved, error with an actionable hint naming the available workstreams and the --ws / `workstream set` fix. Flat mode (no workstreams dir) and --ws <name> are unchanged. Regression in tests/init.test.cjs: errors when workstreams exist but none active (no stale root report); succeeds with --ws; flat mode unchanged. Closes #1912
This commit is contained in:
5
.changeset/lively-lynx-snooze.md
Normal file
5
.changeset/lively-lynx-snooze.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 1918
|
||||
---
|
||||
**`/gsd-progress` no longer reports a stale root milestone in workstream mode** — in a multi-workstream project with no active workstream set, `gsd-tools query init.progress` silently fell back to root `.planning/STATE.md` (often stale) and reported it confidently. It now fails safe with an actionable error naming the available workstreams and the `--ws`/`workstream set` fix, so a stale root value is never reported. Flat mode and `--ws <name>` are unchanged. (#1912)
|
||||
26
src/init.cts
26
src/init.cts
@@ -71,6 +71,7 @@ const {
|
||||
planningPaths,
|
||||
planningDir,
|
||||
planningRoot,
|
||||
getActiveWorkstream,
|
||||
findContextMdIn,
|
||||
} = planningWorkspace;
|
||||
|
||||
@@ -1683,6 +1684,31 @@ function cmdInitProgress(cwd: string, raw: boolean): void {
|
||||
const milestone = getMilestoneInfo(cwd) as unknown as Record<string, unknown>;
|
||||
const _slashRuntime = resolveRuntime(cwd);
|
||||
|
||||
// #1912: fail safe in workstream mode with no active workstream. With no active
|
||||
// workstream and no --ws, planningDir(cwd) resolves to root .planning — silently
|
||||
// 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 _resolvedWorkstream = process.env['GSD_WORKSTREAM'] || getActiveWorkstream(cwd);
|
||||
if (_availableWorkstreams.length > 0 && !_resolvedWorkstream) {
|
||||
error(
|
||||
`init.progress requires a workstream in workstream mode — no active workstream is set, so root STATE.md (likely stale) would be reported. ` +
|
||||
`Pass --ws <name> or run ${formatGsdSlash('workstream set', _slashRuntime) as string} first. ` +
|
||||
`Available workstreams: ${_availableWorkstreams.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const phasesDir = path.join(planningDir(cwd), 'phases');
|
||||
const phases: Record<string, unknown>[] = [];
|
||||
let currentPhase: Record<string, unknown> | null = null;
|
||||
|
||||
@@ -2220,6 +2220,54 @@ describe('init handlers honor GSD_WORKSTREAM (ADR-0006 planningPaths consumption
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// #1912: init.progress fails safe in workstream mode with no active workstream
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('#1912 — init.progress fails safe in workstream mode with no active workstream', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => { tmpDir = createTempProject(); });
|
||||
afterEach(() => { cleanup(tmpDir); });
|
||||
|
||||
function seedWs(name, milestoneVersion) {
|
||||
const wsDir = path.join(tmpDir, '.planning', 'workstreams', name);
|
||||
fs.mkdirSync(path.join(wsDir, 'phases'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(wsDir, 'STATE.md'),
|
||||
`# State\n\n**Status:** executing\n**Milestone:** ${milestoneVersion}\n`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(wsDir, 'ROADMAP.md'),
|
||||
`# Roadmap\n\n## Milestones\n- ${milestoneVersion} Test (Phase 1)\n\n## Phases\n### Phase 1: X\n**Goal:** do x\n`,
|
||||
);
|
||||
return wsDir;
|
||||
}
|
||||
|
||||
test('errors (does NOT report stale root) when workstreams exist but none active', () => {
|
||||
seedWs('alpha', 'v9.0');
|
||||
seedWs('beta', 'v9.0');
|
||||
// Stale root STATE — the misleading value that must never be silently reported.
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'milestone: v7.1\nstatus: executing\n');
|
||||
// No active-workstream pointer, no --ws.
|
||||
const result = runGsdTools('init progress', tmpDir);
|
||||
assert.equal(result.success, false, 'should fail safe rather than report the stale root milestone');
|
||||
assert.match(result.error || '', /workstream|--ws/i, 'error should name the workstream requirement');
|
||||
});
|
||||
|
||||
test('succeeds with --ws (reads the named workstream, not root)', () => {
|
||||
seedWs('alpha', 'v9.0');
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'milestone: v7.1\nstatus: executing\n');
|
||||
const result = runGsdTools('init progress --ws alpha', tmpDir);
|
||||
assert.ok(result.success, `should succeed with --ws: ${result.error}`);
|
||||
});
|
||||
|
||||
test('flat mode (no workstreams dir) is unchanged', () => {
|
||||
const result = runGsdTools('init progress', tmpDir);
|
||||
assert.ok(result.success, `flat mode should still work: ${result.error}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// roadmap analyze command
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user