diff --git a/sdk/src/cli.ts b/sdk/src/cli.ts index 6ab38cab2..561af20e8 100644 --- a/sdk/src/cli.ts +++ b/sdk/src/cli.ts @@ -15,6 +15,7 @@ import { GSD } from './index.js'; import { CLITransport } from './cli-transport.js'; import { WSTransport } from './ws-transport.js'; import { InitRunner } from './init-runner.js'; +import { validateWorkstreamName } from './workstream-utils.js'; // ─── Parsed CLI args ───────────────────────────────────────────────────────── @@ -29,6 +30,8 @@ export interface ParsedCliArgs { wsPort: number | undefined; model: string | undefined; maxBudget: number | undefined; + /** Workstream name for multi-workstream projects. Routes .planning/ to .planning/workstreams//. */ + ws: string | undefined; help: boolean; version: boolean; } @@ -43,6 +46,7 @@ export function parseCliArgs(argv: string[]): ParsedCliArgs { options: { 'project-dir': { type: 'string', default: process.cwd() }, 'ws-port': { type: 'string' }, + ws: { type: 'string' }, model: { type: 'string' }, 'max-budget': { type: 'string' }, init: { type: 'string' }, @@ -69,6 +73,7 @@ export function parseCliArgs(argv: string[]): ParsedCliArgs { wsPort: values['ws-port'] ? Number(values['ws-port']) : undefined, model: values.model as string | undefined, maxBudget: values['max-budget'] ? Number(values['max-budget']) : undefined, + ws: values.ws as string | undefined, help: values.help as boolean, version: values.version as boolean, }; @@ -92,6 +97,7 @@ Options: --init Bootstrap from a PRD before running (auto only) Accepts @path/to/prd.md or "description text" --project-dir Project directory (default: cwd) + --ws Route .planning/ to .planning/workstreams// --ws-port Enable WebSocket transport on --model Override LLM model --max-budget Max budget per step in USD @@ -194,6 +200,13 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise", "gsd-sdk auto", or "gsd-sdk init [input]"'); console.error(USAGE); @@ -226,6 +239,7 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise { - const configPath = join(projectDir, '.planning', 'config.json'); +export async function loadConfig(projectDir: string, workstream?: string): Promise { + const configPath = join(projectDir, relPlanningPath(workstream), 'config.json'); + const rootConfigPath = join(projectDir, '.planning', 'config.json'); let raw: string; try { raw = await readFile(configPath, 'utf-8'); } catch { - // File missing — normal for new projects - return structuredClone(CONFIG_DEFAULTS); + // If workstream config missing, fall back to root config + if (workstream) { + try { + raw = await readFile(rootConfigPath, 'utf-8'); + } catch { + return structuredClone(CONFIG_DEFAULTS); + } + } else { + // File missing — normal for new projects + return structuredClone(CONFIG_DEFAULTS); + } } const trimmed = raw.trim(); diff --git a/sdk/src/context-engine.ts b/sdk/src/context-engine.ts index 36a1f1dd2..b772cfca3 100644 --- a/sdk/src/context-engine.ts +++ b/sdk/src/context-engine.ts @@ -25,6 +25,7 @@ import { DEFAULT_TRUNCATION_OPTIONS, type TruncationOptions, } from './context-truncation.js'; +import { relPlanningPath } from './workstream-utils.js'; // ─── File manifest per phase ───────────────────────────────────────────────── @@ -77,8 +78,8 @@ export class ContextEngine { private readonly logger?: GSDLogger; private readonly truncation: TruncationOptions; - constructor(projectDir: string, logger?: GSDLogger, truncation?: Partial) { - this.planningDir = join(projectDir, '.planning'); + constructor(projectDir: string, logger?: GSDLogger, truncation?: Partial, workstream?: string) { + this.planningDir = join(projectDir, relPlanningPath(workstream)); this.logger = logger; this.truncation = { ...DEFAULT_TRUNCATION_OPTIONS, ...truncation }; } diff --git a/sdk/src/gsd-tools.ts b/sdk/src/gsd-tools.ts index 324a8cc4b..3730e99d0 100644 --- a/sdk/src/gsd-tools.ts +++ b/sdk/src/gsd-tools.ts @@ -39,16 +39,19 @@ export class GSDTools { private readonly projectDir: string; private readonly gsdToolsPath: string; private readonly timeoutMs: number; + private readonly workstream?: string; constructor(opts: { projectDir: string; gsdToolsPath?: string; timeoutMs?: number; + workstream?: string; }) { this.projectDir = opts.projectDir; this.gsdToolsPath = opts.gsdToolsPath ?? resolveGsdToolsPath(opts.projectDir); this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; + this.workstream = opts.workstream; } // ─── Core exec ─────────────────────────────────────────────────────────── @@ -58,7 +61,8 @@ export class GSDTools { * Handles the `@file:` prefix pattern for large results. */ async exec(command: string, args: string[] = []): Promise { - const fullArgs = [this.gsdToolsPath, command, ...args]; + const wsArgs = this.workstream ? ['--ws', this.workstream] : []; + const fullArgs = [this.gsdToolsPath, command, ...args, ...wsArgs]; return new Promise((resolve, reject) => { const child = execFile( @@ -160,7 +164,8 @@ export class GSDTools { * Use for commands like `config-set` that return plain text, not JSON. */ async execRaw(command: string, args: string[] = []): Promise { - const fullArgs = [this.gsdToolsPath, command, ...args, '--raw']; + const wsArgs = this.workstream ? ['--ws', this.workstream] : []; + const fullArgs = [this.gsdToolsPath, command, ...args, ...wsArgs, '--raw']; return new Promise((resolve, reject) => { const child = execFile( diff --git a/sdk/src/index.ts b/sdk/src/index.ts index 55bf2aecd..6687b9c60 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -44,6 +44,7 @@ export class GSD { private readonly defaultMaxBudgetUsd: number; private readonly defaultMaxTurns: number; private readonly autoMode: boolean; + private readonly workstream?: string; readonly eventStream: GSDEventStream; constructor(options: GSDOptions) { @@ -54,6 +55,7 @@ export class GSD { this.defaultMaxBudgetUsd = options.maxBudgetUsd ?? 5.0; this.defaultMaxTurns = options.maxTurns ?? 50; this.autoMode = options.autoMode ?? false; + this.workstream = options.workstream; this.eventStream = new GSDEventStream(); } @@ -75,7 +77,7 @@ export class GSD { const plan = await parsePlanFile(absolutePlanPath); // Load project config - const config = await loadConfig(this.projectDir); + const config = await loadConfig(this.projectDir, this.workstream); // Try to load agent definition for tool restrictions const agentDef = await this.loadAgentDefinition(); @@ -117,6 +119,7 @@ export class GSD { return new GSDTools({ projectDir: this.projectDir, gsdToolsPath: this.gsdToolsPath, + workstream: this.workstream, }); } @@ -133,8 +136,8 @@ export class GSD { async runPhase(phaseNumber: string, options?: PhaseRunnerOptions): Promise { const tools = this.createTools(); const promptFactory = new PromptFactory(); - const contextEngine = new ContextEngine(this.projectDir); - const config = await loadConfig(this.projectDir); + const contextEngine = new ContextEngine(this.projectDir, undefined, undefined, this.workstream); + const config = await loadConfig(this.projectDir, this.workstream); // Auto mode: force auto_advance on and skip_discuss off so self-discuss kicks in if (this.autoMode) { @@ -314,6 +317,9 @@ export { CLITransport } from './cli-transport.js'; export { WSTransport } from './ws-transport.js'; export type { WSTransportOptions } from './ws-transport.js'; +// Workstream utilities +export { validateWorkstreamName, relPlanningPath } from './workstream-utils.js'; + // Init workflow export { InitRunner } from './init-runner.js'; export type { InitRunnerDeps } from './init-runner.js'; diff --git a/sdk/src/types.ts b/sdk/src/types.ts index cc5ee7ddf..cefca767b 100644 --- a/sdk/src/types.ts +++ b/sdk/src/types.ts @@ -207,6 +207,8 @@ export interface GSDOptions { maxTurns?: number; /** Enable auto mode: sets auto_advance=true, skip_discuss=false in workflow config. */ autoMode?: boolean; + /** Workstream name. Routes all .planning/ paths to .planning/workstreams//. */ + workstream?: string; } // ─── S02: Event stream types ───────────────────────────────────────────────── diff --git a/sdk/src/workstream-utils.ts b/sdk/src/workstream-utils.ts new file mode 100644 index 000000000..5f78326c5 --- /dev/null +++ b/sdk/src/workstream-utils.ts @@ -0,0 +1,32 @@ +/** + * Workstream utility functions for multi-workstream project support. + * + * When --ws is provided, all .planning/ paths are routed to + * .planning/workstreams// instead. + */ + +import { join } from 'node:path'; + +/** + * Validate a workstream name. + * Allowed: alphanumeric, hyphens, underscores, dots. + * Disallowed: empty, spaces, slashes, special chars, path traversal. + */ +export function validateWorkstreamName(name: string): boolean { + if (!name || name.length === 0) return false; + // Only allow alphanumeric, hyphens, underscores, dots + // Must not be ".." or start with ".." (path traversal) + if (name === '..' || name.startsWith('../')) return false; + return /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name); +} + +/** + * Return the relative planning directory path. + * + * - Without workstream: `.planning` + * - With workstream: `.planning/workstreams/` + */ +export function relPlanningPath(workstream?: string): string { + if (!workstream) return '.planning'; + return join('.planning', 'workstreams', workstream); +} diff --git a/sdk/src/ws-flag.test.ts b/sdk/src/ws-flag.test.ts new file mode 100644 index 000000000..c164dfdbb --- /dev/null +++ b/sdk/src/ws-flag.test.ts @@ -0,0 +1,285 @@ +/** + * Tests for --ws (workstream) flag support. + * + * Validates: + * - CLI parsing of --ws flag + * - Workstream name validation + * - GSDOptions.workstream propagation + * - GSDTools workstream-aware invocation + * - Config path resolution with workstream + * - ContextEngine workstream-aware planning dir + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdir, writeFile, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +// ─── Workstream name validation ───────────────────────────────────────────── + +import { validateWorkstreamName } from './workstream-utils.js'; + +describe('validateWorkstreamName', () => { + it('accepts alphanumeric names', () => { + expect(validateWorkstreamName('frontend')).toBe(true); + expect(validateWorkstreamName('backend2')).toBe(true); + }); + + it('accepts names with hyphens', () => { + expect(validateWorkstreamName('my-feature')).toBe(true); + }); + + it('accepts names with underscores', () => { + expect(validateWorkstreamName('my_feature')).toBe(true); + }); + + it('accepts names with dots', () => { + expect(validateWorkstreamName('v1.0')).toBe(true); + }); + + it('rejects empty strings', () => { + expect(validateWorkstreamName('')).toBe(false); + }); + + it('rejects names with spaces', () => { + expect(validateWorkstreamName('my feature')).toBe(false); + }); + + it('rejects names with slashes', () => { + expect(validateWorkstreamName('my/feature')).toBe(false); + }); + + it('rejects names with special characters', () => { + expect(validateWorkstreamName('feat@ure')).toBe(false); + expect(validateWorkstreamName('feat!ure')).toBe(false); + expect(validateWorkstreamName('feat#ure')).toBe(false); + }); + + it('rejects path traversal attempts', () => { + expect(validateWorkstreamName('..')).toBe(false); + expect(validateWorkstreamName('../etc')).toBe(false); + }); +}); + +// ─── relPlanningPath helper ───────────────────────────────────────────────── + +import { relPlanningPath } from './workstream-utils.js'; + +describe('relPlanningPath', () => { + it('returns .planning/ in flat mode (no workstream)', () => { + expect(relPlanningPath()).toBe('.planning'); + expect(relPlanningPath(undefined)).toBe('.planning'); + }); + + it('returns .planning/workstreams// with workstream', () => { + expect(relPlanningPath('frontend')).toBe('.planning/workstreams/frontend'); + expect(relPlanningPath('api-v2')).toBe('.planning/workstreams/api-v2'); + }); +}); + +// ─── CLI --ws flag parsing ────────────────────────────────────────────────── + +import { parseCliArgs } from './cli.js'; + +describe('parseCliArgs --ws flag', () => { + it('parses --ws flag', () => { + const result = parseCliArgs(['run', 'build auth', '--ws', 'frontend']); + + expect(result.ws).toBe('frontend'); + }); + + it('ws is undefined when not provided', () => { + const result = parseCliArgs(['run', 'build auth']); + + expect(result.ws).toBeUndefined(); + }); + + it('works with other flags', () => { + const result = parseCliArgs([ + 'run', 'build auth', + '--ws', 'backend', + '--model', 'claude-sonnet-4-6', + '--project-dir', '/tmp/test', + ]); + + expect(result.ws).toBe('backend'); + expect(result.model).toBe('claude-sonnet-4-6'); + expect(result.projectDir).toBe('/tmp/test'); + }); +}); + +// ─── GSDOptions.workstream ────────────────────────────────────────────────── + +describe('GSDOptions.workstream', () => { + it('GSD class accepts workstream option', async () => { + // This is a compile-time check -- if the type is wrong, TS will fail + const { GSD } = await import('./index.js'); + const gsd = new GSD({ + projectDir: '/tmp/test-ws', + workstream: 'frontend', + }); + // If we get here without a type error, the option is accepted + expect(gsd).toBeDefined(); + }); +}); + +// ─── GSDTools workstream injection ────────────────────────────────────────── + +describe('GSDTools workstream injection', () => { + let tmpDir: string; + let fixtureDir: string; + + beforeEach(async () => { + tmpDir = join(tmpdir(), `gsd-ws-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + fixtureDir = join(tmpDir, 'fixtures'); + await mkdir(fixtureDir, { recursive: true }); + await mkdir(join(tmpDir, '.planning'), { recursive: true }); + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + async function createScript(name: string, code: string): Promise { + const scriptPath = join(fixtureDir, name); + await writeFile(scriptPath, code, { mode: 0o755 }); + return scriptPath; + } + + it('passes --ws flag to gsd-tools.cjs when workstream is set', async () => { + const { GSDTools } = await import('./gsd-tools.js'); + + // Script echoes its arguments as JSON + const scriptPath = await createScript( + 'echo-args.cjs', + 'process.stdout.write(JSON.stringify(process.argv.slice(2)));', + ); + + const tools = new GSDTools({ + projectDir: tmpDir, + gsdToolsPath: scriptPath, + workstream: 'frontend', + }); + + const result = await tools.exec('state', ['load']) as string[]; + + // Should contain --ws frontend in the arguments + expect(result).toContain('--ws'); + expect(result).toContain('frontend'); + }); + + it('does not pass --ws when workstream is undefined', async () => { + const { GSDTools } = await import('./gsd-tools.js'); + + const scriptPath = await createScript( + 'echo-args-no-ws.cjs', + 'process.stdout.write(JSON.stringify(process.argv.slice(2)));', + ); + + const tools = new GSDTools({ + projectDir: tmpDir, + gsdToolsPath: scriptPath, + }); + + const result = await tools.exec('state', ['load']) as string[]; + + expect(result).not.toContain('--ws'); + }); +}); + +// ─── Config workstream-aware path ─────────────────────────────────────────── + +import { loadConfig } from './config.js'; + +describe('loadConfig with workstream', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = join(tmpdir(), `gsd-config-ws-${Date.now()}-${Math.random().toString(36).slice(2)}`); + await mkdir(tmpDir, { recursive: true }); + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + it('loads config from workstream path when workstream is provided', async () => { + const wsDir = join(tmpDir, '.planning', 'workstreams', 'frontend'); + await mkdir(wsDir, { recursive: true }); + await writeFile( + join(wsDir, 'config.json'), + JSON.stringify({ model_profile: 'performance' }), + ); + + const config = await loadConfig(tmpDir, 'frontend'); + + expect(config.model_profile).toBe('performance'); + }); + + it('falls back to root config when workstream config is missing', async () => { + // Create root config but no workstream config + await mkdir(join(tmpDir, '.planning'), { recursive: true }); + await writeFile( + join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ model_profile: 'balanced' }), + ); + + const config = await loadConfig(tmpDir, 'frontend'); + + expect(config.model_profile).toBe('balanced'); + }); + + it('loads from root .planning/ when workstream is undefined', async () => { + await mkdir(join(tmpDir, '.planning'), { recursive: true }); + await writeFile( + join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ model_profile: 'economy' }), + ); + + const config = await loadConfig(tmpDir); + + expect(config.model_profile).toBe('economy'); + }); +}); + +// ─── ContextEngine workstream-aware planning dir ──────────────────────────── + +describe('ContextEngine with workstream', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = join(tmpdir(), `gsd-ctx-ws-${Date.now()}-${Math.random().toString(36).slice(2)}`); + await mkdir(tmpDir, { recursive: true }); + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + it('resolves files from workstream planning dir', async () => { + const { ContextEngine } = await import('./context-engine.js'); + const { PhaseType } = await import('./types.js'); + + const wsDir = join(tmpDir, '.planning', 'workstreams', 'backend'); + await mkdir(wsDir, { recursive: true }); + await writeFile(join(wsDir, 'STATE.md'), '# State\nPhase: 01'); + + const engine = new ContextEngine(tmpDir, undefined, undefined, 'backend'); + const files = await engine.resolveContextFiles(PhaseType.Execute); + + expect(files.state).toContain('Phase: 01'); + }); + + it('resolves files from root .planning/ without workstream', async () => { + const { ContextEngine } = await import('./context-engine.js'); + const { PhaseType } = await import('./types.js'); + + await mkdir(join(tmpDir, '.planning'), { recursive: true }); + await writeFile(join(tmpDir, '.planning', 'STATE.md'), '# State\nPhase: 02'); + + const engine = new ContextEngine(tmpDir); + const files = await engine.resolveContextFiles(PhaseType.Execute); + + expect(files.state).toContain('Phase: 02'); + }); +});