From 8054959417754f88493d704a0e758c67ac65c433 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 6 May 2026 21:05:27 -0400 Subject: [PATCH] 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 --- .../fix-3196-workstream-milestone-op.md | 5 + CHANGELOG.md | 2 +- .../init-workstream-milestone-op.test.ts | 321 ++++++++++++++++++ sdk/src/query/init.ts | 10 +- sdk/src/query/query-runtime-context.ts | 38 ++- 5 files changed, 369 insertions(+), 7 deletions(-) create mode 100644 .changeset/fix-3196-workstream-milestone-op.md create mode 100644 sdk/src/query/init-workstream-milestone-op.test.ts diff --git a/.changeset/fix-3196-workstream-milestone-op.md b/.changeset/fix-3196-workstream-milestone-op.md new file mode 100644 index 000000000..4417021f3 --- /dev/null +++ b/.changeset/fix-3196-workstream-milestone-op.md @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index ceec0aeb8..e5c9fb2cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### 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) ### Feature diff --git a/sdk/src/query/init-workstream-milestone-op.test.ts b/sdk/src/query/init-workstream-milestone-op.test.ts new file mode 100644 index 000000000..6edc3a929 --- /dev/null +++ b/sdk/src/query/init-workstream-milestone-op.test.ts @@ -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; + + 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; + + // 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; + 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; + 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; + const phases = data.phases as Array>; + + 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; + + // 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; + 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; + } + }); +}); diff --git a/sdk/src/query/init.ts b/sdk/src/query/init.ts index 02aee7327..73889a61b 100644 --- a/sdk/src/query/init.ts +++ b/sdk/src/query/init.ts @@ -794,10 +794,10 @@ export const initTodos: QueryHandler = async (args, projectDir) => { * Init handler for complete-milestone and audit-milestone workflows. * 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 planningDir = join(projectDir, '.planning'); - const milestone = await getMilestoneInfo(projectDir); + const planningDir = join(projectDir, relPlanningPath(workstream)); + const milestone = await getMilestoneInfo(projectDir, workstream); const phasesDir = join(planningDir, 'phases'); let phaseCount = 0; @@ -813,7 +813,7 @@ export const initMilestoneOp: QueryHandler = async (_args, projectDir) => { try { const { readFile } = await import('node:fs/promises'); 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); } catch { /* intentionally empty */ } @@ -869,7 +869,7 @@ export const initMilestoneOp: QueryHandler = async (_args, projectDir) => { } catch { /* intentionally empty */ } } - const archiveDir = join(projectDir, '.planning', 'archive'); + const archiveDir = join(planningDir, 'archive'); let archivedMilestones: string[] = []; try { archivedMilestones = readdirSync(archiveDir, { withFileTypes: true }) diff --git a/sdk/src/query/query-runtime-context.ts b/sdk/src/query/query-runtime-context.ts index 037d348c6..f0dbde4d4 100644 --- a/sdk/src/query/query-runtime-context.ts +++ b/sdk/src/query/query-runtime-context.ts @@ -1,3 +1,5 @@ +import { join } from 'node:path'; +import { readFileSync, existsSync } from 'node:fs'; import { findProjectRoot } from './helpers.js'; import { validateWorkstreamName } from '../workstream-utils.js'; @@ -11,6 +13,35 @@ export interface QueryRuntimeContext { 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 ` flag (input.ws) + * 2. `GSD_WORKSTREAM` environment variable + * 3. `.planning/active-workstream` file + * 4. Root `.planning/` (no workstream) + */ export function resolveQueryRuntimeContext(input: QueryRuntimeContextInput): QueryRuntimeContext { const projectDir = findProjectRoot(input.projectDir); @@ -22,8 +53,13 @@ export function resolveQueryRuntimeContext(input: QueryRuntimeContextInput): Que } const envWs = process.env.GSD_WORKSTREAM; + if (envWs && validateWorkstreamName(envWs)) { + return { projectDir, ws: envWs }; + } + + const fileWs = readActiveWorkstreamFile(projectDir); return { projectDir, - ws: envWs && validateWorkstreamName(envWs) ? envWs : undefined, + ws: fileWs ?? undefined, }; }