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:
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
|
### 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
|
||||||
|
|||||||
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.
|
* 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 })
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user