fix(query): workstream resolution in init.milestone-op and roadmap.analyze (#3196)

- initMilestoneOp now accepts and propagates the workstream parameter:
  relPlanningPath(workstream) replaces the hardcoded '.planning' dir,
  getMilestoneInfo gets workstream passed, extractCurrentMilestone gets
  workstream passed, archiveDir is derived from planningDir not root.

- resolveQueryRuntimeContext now reads .planning/active-workstream as a
  third-priority fallback after --ws flag and GSD_WORKSTREAM env var,
  completing the documented resolution chain for all query handlers.

Closes #3196

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-05-06 21:05:27 -04:00
parent 608da536fd
commit 8054959417
5 changed files with 369 additions and 7 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 3196
---
**Workstream resolution in `init.milestone-op` and `roadmap.analyze`** — both handlers now respect the `--ws` flag, `GSD_WORKSTREAM` env, and the `.planning/active-workstream` file; workstream-scoped repos no longer exit with "All phases complete — Nothing left to do" due to `phase_count: 0` caused by reading from the wrong (root) `.planning/` directory.

View File

@@ -8,7 +8,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
### Fixed ### Fixed
- **`--sdk` flag now wired into SDK deployment** — `hasSdk` was parsed in `bin/install.js` but never passed to `installSdkIfNeeded`, so `npx get-shit-done-cc@latest --sdk` silently skipped SDK deployment and produced a misleading "✓ GSD SDK ready" message. `installSdkIfNeeded` now accepts `forceSdk: true` (set when `--sdk` is passed), which bypasses the local-install soft-skip and runs the full shim-link path so `gsd-sdk` is materialized on PATH. The `#2678` soft-skip for local installs without `--sdk` is preserved. (#3033) - **Workstream resolution in `init.milestone-op` and `roadmap.analyze`** — both handlers now respect `--ws`, `GSD_WORKSTREAM`, and the `.planning/active-workstream` file; workstream-scoped repos no longer exit "All phases complete — Nothing left to do" due to `phase_count: 0` from reading the wrong root `.planning/`. (#3196)
- **Milestone-archive layout support** — `validate consistency`, `validate health`, and `find-phase` now scan `.planning/milestones/v*-phases/` directories in addition to the flat `.planning/phases/` layout. Projects that have graduated to milestone-archive layout no longer receive spurious W006 "Phase N in ROADMAP.md but no directory on disk" warnings for every active phase. (#3164) - **Milestone-archive layout support** — `validate consistency`, `validate health`, and `find-phase` now scan `.planning/milestones/v*-phases/` directories in addition to the flat `.planning/phases/` layout. Projects that have graduated to milestone-archive layout no longer receive spurious W006 "Phase N in ROADMAP.md but no directory on disk" warnings for every active phase. (#3164)
### Feature ### Feature

View File

@@ -0,0 +1,321 @@
/**
* Tests for workstream resolution in initMilestoneOp and roadmapAnalyze.
*
* Regression coverage for #3196: both handlers were ignoring the workstream
* parameter and always reading from root `.planning/`, causing
* `phase_count: 0` / `roadmap_exists: false` in workstream-scoped repos.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { initMilestoneOp } from './init.js';
import { roadmapAnalyze } from './roadmap.js';
import { resolveQueryRuntimeContext } from './query-runtime-context.js';
// ─── Shared fixture ────────────────────────────────────────────────────────
const ROADMAP_CONTENT = [
'# Roadmap',
'',
'## v2.0: Test Milestone',
'',
'**Goal:** Run tests',
'',
'### Phase 1: Alpha',
'',
'**Goal:** First phase',
'',
'### Phase 2: Beta',
'',
'**Goal:** Second phase',
'',
].join('\n');
const STATE_CONTENT = [
'---',
'milestone: v2.0',
'milestone_name: Test Milestone',
'status: executing',
'---',
'',
'# Project State',
'',
].join('\n');
const CONFIG_CONTENT = JSON.stringify({
model_profile: 'balanced',
commit_docs: false,
git: {
branching_strategy: 'none',
phase_branch_template: 'gsd/phase-{phase}-{slug}',
milestone_branch_template: 'gsd/{milestone}-{slug}',
quick_branch_template: null,
},
workflow: { research: false, plan_check: false, verifier: false, nyquist_validation: false },
});
// ─── initMilestoneOp workstream tests ─────────────────────────────────────
describe('initMilestoneOp workstream resolution (#3196)', () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'gsd-ws-milestone-op-'));
// Root planning dir (has config, but no ROADMAP for the workstream)
await mkdir(join(tmpDir, '.planning'), { recursive: true });
await writeFile(join(tmpDir, '.planning', 'config.json'), CONFIG_CONTENT);
// Root STATE.md with a different milestone (should be ignored when ws is set)
await writeFile(join(tmpDir, '.planning', 'STATE.md'), [
'---',
'milestone: v0.0',
'milestone_name: Root Milestone',
'status: idle',
'---',
].join('\n'));
// Workstream dir
const wsDir = join(tmpDir, '.planning', 'workstreams', 'test-ws');
await mkdir(join(wsDir, 'phases', '01-alpha'), { recursive: true });
await writeFile(join(wsDir, 'ROADMAP.md'), ROADMAP_CONTENT);
await writeFile(join(wsDir, 'STATE.md'), STATE_CONTENT);
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
it('reads phase_count from workstream ROADMAP when --ws is passed', async () => {
const result = await initMilestoneOp([], tmpDir, 'test-ws');
const data = result.data as Record<string, unknown>;
expect(data.phase_count).toBe(2);
expect(data.roadmap_exists).toBe(true);
expect(data.state_exists).toBe(true);
expect(data.milestone_version).toBe('v2.0');
});
it('returns phase_count 0 when reading root .planning/ (no workstream) that has no ROADMAP', async () => {
// Root .planning has no ROADMAP — without the fix this was where milestone-op
// always looked even when a workstream was active.
const result = await initMilestoneOp([], tmpDir, undefined);
const data = result.data as Record<string, unknown>;
// Root has no ROADMAP so phase_count falls back to on-disk dirs (0)
expect(data.roadmap_exists).toBe(false);
expect(data.phase_count).toBe(0);
});
it('reads from active-workstream file when no explicit --ws is passed', async () => {
// Write the active-workstream pointer
await writeFile(join(tmpDir, '.planning', 'active-workstream'), 'test-ws\n');
// Resolve context as the CLI would (no --ws arg, no GSD_WORKSTREAM env)
const prev = process.env.GSD_WORKSTREAM;
delete process.env.GSD_WORKSTREAM;
try {
const ctx = resolveQueryRuntimeContext({ projectDir: tmpDir });
expect(ctx.ws).toBe('test-ws');
const result = await initMilestoneOp([], ctx.projectDir, ctx.ws);
const data = result.data as Record<string, unknown>;
expect(data.phase_count).toBe(2);
expect(data.roadmap_exists).toBe(true);
expect(data.milestone_version).toBe('v2.0');
} finally {
if (prev === undefined) delete process.env.GSD_WORKSTREAM;
else process.env.GSD_WORKSTREAM = prev;
}
});
it('--ws flag overrides active-workstream file', async () => {
// Write a different active-workstream
await writeFile(join(tmpDir, '.planning', 'active-workstream'), 'other-ws\n');
const prev = process.env.GSD_WORKSTREAM;
delete process.env.GSD_WORKSTREAM;
try {
// Explicitly pass --ws test-ws
const ctx = resolveQueryRuntimeContext({ projectDir: tmpDir, ws: 'test-ws' });
expect(ctx.ws).toBe('test-ws');
const result = await initMilestoneOp([], ctx.projectDir, ctx.ws);
const data = result.data as Record<string, unknown>;
expect(data.phase_count).toBe(2);
} finally {
if (prev === undefined) delete process.env.GSD_WORKSTREAM;
else process.env.GSD_WORKSTREAM = prev;
}
});
it('GSD_WORKSTREAM env overrides active-workstream file', async () => {
// File says other-ws, env says test-ws
await writeFile(join(tmpDir, '.planning', 'active-workstream'), 'other-ws\n');
const prev = process.env.GSD_WORKSTREAM;
process.env.GSD_WORKSTREAM = 'test-ws';
try {
const ctx = resolveQueryRuntimeContext({ projectDir: tmpDir });
expect(ctx.ws).toBe('test-ws');
} finally {
if (prev === undefined) delete process.env.GSD_WORKSTREAM;
else process.env.GSD_WORKSTREAM = prev;
}
});
});
// ─── roadmapAnalyze workstream tests ──────────────────────────────────────
describe('roadmapAnalyze workstream resolution (#3196)', () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'gsd-ws-roadmap-analyze-'));
// Root planning dir — no ROADMAP
await mkdir(join(tmpDir, '.planning'), { recursive: true });
await writeFile(join(tmpDir, '.planning', 'config.json'), CONFIG_CONTENT);
await writeFile(join(tmpDir, '.planning', 'STATE.md'), [
'---',
'milestone: v0.0',
'status: idle',
'---',
].join('\n'));
// Workstream dir
const wsDir = join(tmpDir, '.planning', 'workstreams', 'test-ws');
await mkdir(join(wsDir, 'phases'), { recursive: true });
await writeFile(join(wsDir, 'ROADMAP.md'), ROADMAP_CONTENT);
await writeFile(join(wsDir, 'STATE.md'), STATE_CONTENT);
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
it('analyzes workstream ROADMAP when workstream is passed', async () => {
const result = await roadmapAnalyze([], tmpDir, 'test-ws');
const data = result.data as Record<string, unknown>;
const phases = data.phases as Array<Record<string, unknown>>;
expect(data.phase_count).toBe(2);
expect(phases[0].number).toBe('1');
expect(phases[1].number).toBe('2');
});
it('returns error when no ROADMAP in root .planning (no workstream)', async () => {
const result = await roadmapAnalyze([], tmpDir, undefined);
const data = result.data as Record<string, unknown>;
// Root has no ROADMAP.md → error path
expect(data.error).toBeDefined();
expect(data.phase_count).toBeUndefined();
});
it('resolves workstream via active-workstream file for roadmapAnalyze', async () => {
await writeFile(join(tmpDir, '.planning', 'active-workstream'), 'test-ws\n');
const prev = process.env.GSD_WORKSTREAM;
delete process.env.GSD_WORKSTREAM;
try {
const ctx = resolveQueryRuntimeContext({ projectDir: tmpDir });
expect(ctx.ws).toBe('test-ws');
const result = await roadmapAnalyze([], ctx.projectDir, ctx.ws);
const data = result.data as Record<string, unknown>;
expect(data.phase_count).toBe(2);
} finally {
if (prev === undefined) delete process.env.GSD_WORKSTREAM;
else process.env.GSD_WORKSTREAM = prev;
}
});
});
// ─── resolveQueryRuntimeContext active-workstream file tests ──────────────
describe('resolveQueryRuntimeContext active-workstream file fallback (#3196)', () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'gsd-rtctx-'));
await mkdir(join(tmpDir, '.planning', 'workstreams', 'my-ws'), { recursive: true });
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
it('reads ws from active-workstream file when no --ws and no GSD_WORKSTREAM', async () => {
await writeFile(join(tmpDir, '.planning', 'active-workstream'), 'my-ws\n');
const prev = process.env.GSD_WORKSTREAM;
delete process.env.GSD_WORKSTREAM;
try {
const ctx = resolveQueryRuntimeContext({ projectDir: tmpDir });
expect(ctx.ws).toBe('my-ws');
} finally {
if (prev === undefined) delete process.env.GSD_WORKSTREAM;
else process.env.GSD_WORKSTREAM = prev;
}
});
it('returns ws: undefined when active-workstream file is missing', async () => {
const prev = process.env.GSD_WORKSTREAM;
delete process.env.GSD_WORKSTREAM;
try {
const ctx = resolveQueryRuntimeContext({ projectDir: tmpDir });
expect(ctx.ws).toBeUndefined();
} finally {
if (prev === undefined) delete process.env.GSD_WORKSTREAM;
else process.env.GSD_WORKSTREAM = prev;
}
});
it('returns ws: undefined when active-workstream names a non-existent dir', async () => {
await writeFile(join(tmpDir, '.planning', 'active-workstream'), 'nonexistent\n');
const prev = process.env.GSD_WORKSTREAM;
delete process.env.GSD_WORKSTREAM;
try {
const ctx = resolveQueryRuntimeContext({ projectDir: tmpDir });
expect(ctx.ws).toBeUndefined();
} finally {
if (prev === undefined) delete process.env.GSD_WORKSTREAM;
else process.env.GSD_WORKSTREAM = prev;
}
});
it('GSD_WORKSTREAM env takes priority over active-workstream file', async () => {
await writeFile(join(tmpDir, '.planning', 'active-workstream'), 'my-ws\n');
await mkdir(join(tmpDir, '.planning', 'workstreams', 'env-ws'), { recursive: true });
const prev = process.env.GSD_WORKSTREAM;
process.env.GSD_WORKSTREAM = 'env-ws';
try {
const ctx = resolveQueryRuntimeContext({ projectDir: tmpDir });
expect(ctx.ws).toBe('env-ws');
} finally {
if (prev === undefined) delete process.env.GSD_WORKSTREAM;
else process.env.GSD_WORKSTREAM = prev;
}
});
it('--ws flag takes priority over both env and active-workstream file', async () => {
await writeFile(join(tmpDir, '.planning', 'active-workstream'), 'my-ws\n');
await mkdir(join(tmpDir, '.planning', 'workstreams', 'env-ws'), { recursive: true });
await mkdir(join(tmpDir, '.planning', 'workstreams', 'explicit-ws'), { recursive: true });
const prev = process.env.GSD_WORKSTREAM;
process.env.GSD_WORKSTREAM = 'env-ws';
try {
const ctx = resolveQueryRuntimeContext({ projectDir: tmpDir, ws: 'explicit-ws' });
expect(ctx.ws).toBe('explicit-ws');
} finally {
if (prev === undefined) delete process.env.GSD_WORKSTREAM;
else process.env.GSD_WORKSTREAM = prev;
}
});
});

View File

@@ -794,10 +794,10 @@ export const initTodos: QueryHandler = async (args, projectDir) => {
* Init handler for complete-milestone and audit-milestone workflows. * Init handler for complete-milestone and audit-milestone workflows.
* Port of cmdInitMilestoneOp from init.cjs lines 758-817. * Port of cmdInitMilestoneOp from init.cjs lines 758-817.
*/ */
export const initMilestoneOp: QueryHandler = async (_args, projectDir) => { export const initMilestoneOp: QueryHandler = async (_args, projectDir, workstream) => {
const config = await loadConfig(projectDir); const config = await loadConfig(projectDir);
const planningDir = join(projectDir, '.planning'); const planningDir = join(projectDir, relPlanningPath(workstream));
const milestone = await getMilestoneInfo(projectDir); const milestone = await getMilestoneInfo(projectDir, workstream);
const phasesDir = join(planningDir, 'phases'); const phasesDir = join(planningDir, 'phases');
let phaseCount = 0; let phaseCount = 0;
@@ -813,7 +813,7 @@ export const initMilestoneOp: QueryHandler = async (_args, projectDir) => {
try { try {
const { readFile } = await import('node:fs/promises'); const { readFile } = await import('node:fs/promises');
const roadmapRaw = await readFile(join(planningDir, 'ROADMAP.md'), 'utf-8'); const roadmapRaw = await readFile(join(planningDir, 'ROADMAP.md'), 'utf-8');
const currentSection = await extractCurrentMilestone(roadmapRaw, projectDir); const currentSection = await extractCurrentMilestone(roadmapRaw, projectDir, workstream);
roadmapPhaseNumbers = extractPhasesFromSection(currentSection).map(p => p.number); roadmapPhaseNumbers = extractPhasesFromSection(currentSection).map(p => p.number);
} catch { /* intentionally empty */ } } catch { /* intentionally empty */ }
@@ -869,7 +869,7 @@ export const initMilestoneOp: QueryHandler = async (_args, projectDir) => {
} catch { /* intentionally empty */ } } catch { /* intentionally empty */ }
} }
const archiveDir = join(projectDir, '.planning', 'archive'); const archiveDir = join(planningDir, 'archive');
let archivedMilestones: string[] = []; let archivedMilestones: string[] = [];
try { try {
archivedMilestones = readdirSync(archiveDir, { withFileTypes: true }) archivedMilestones = readdirSync(archiveDir, { withFileTypes: true })

View File

@@ -1,3 +1,5 @@
import { join } from 'node:path';
import { readFileSync, existsSync } from 'node:fs';
import { findProjectRoot } from './helpers.js'; import { findProjectRoot } from './helpers.js';
import { validateWorkstreamName } from '../workstream-utils.js'; import { validateWorkstreamName } from '../workstream-utils.js';
@@ -11,6 +13,35 @@ export interface QueryRuntimeContext {
ws?: string; ws?: string;
} }
/**
* Read the active workstream from `.planning/active-workstream` file.
*
* Mirrors the logic in workstream.ts:getActiveWorkstream — returns null
* when the file is missing, empty, contains invalid characters, or names
* a workstream directory that doesn't exist on disk.
*/
function readActiveWorkstreamFile(projectDir: string): string | null {
const filePath = join(projectDir, '.planning', 'active-workstream');
try {
const name = readFileSync(filePath, 'utf-8').trim();
if (!name || !validateWorkstreamName(name)) return null;
const wsDir = join(projectDir, '.planning', 'workstreams', name);
if (!existsSync(wsDir)) return null;
return name;
} catch {
return null;
}
}
/**
* Resolve the runtime context for a query invocation.
*
* Workstream resolution priority:
* 1. `--ws <name>` flag (input.ws)
* 2. `GSD_WORKSTREAM` environment variable
* 3. `.planning/active-workstream` file
* 4. Root `.planning/` (no workstream)
*/
export function resolveQueryRuntimeContext(input: QueryRuntimeContextInput): QueryRuntimeContext { export function resolveQueryRuntimeContext(input: QueryRuntimeContextInput): QueryRuntimeContext {
const projectDir = findProjectRoot(input.projectDir); const projectDir = findProjectRoot(input.projectDir);
@@ -22,8 +53,13 @@ export function resolveQueryRuntimeContext(input: QueryRuntimeContextInput): Que
} }
const envWs = process.env.GSD_WORKSTREAM; const envWs = process.env.GSD_WORKSTREAM;
if (envWs && validateWorkstreamName(envWs)) {
return { projectDir, ws: envWs };
}
const fileWs = readActiveWorkstreamFile(projectDir);
return { return {
projectDir, projectDir,
ws: envWs && validateWorkstreamName(envWs) ? envWs : undefined, ws: fileWs ?? undefined,
}; };
} }