Merge pull request #3207 from gsd-build/fix/3196-workstream-milestone-op
fix(query): workstream resolution in init.milestone-op and roadmap.analyze
This commit is contained in:
5
.changeset/fix-3196-workstream-milestone-op.md
Normal file
5
.changeset/fix-3196-workstream-milestone-op.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
321
sdk/src/query/init-workstream-milestone-op.test.ts
Normal file
321
sdk/src/query/init-workstream-milestone-op.test.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 <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 {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user