feat(sdk): add --ws flag for workstream-aware execution (#1884)
Add a --ws <name> CLI flag that routes all .planning/ paths to .planning/workstreams/<name>/, enabling multi-workstream projects without directory conflicts. Changes: - workstream-utils.ts: validateWorkstreamName() and relPlanningPath() helpers - cli.ts: Parse --ws flag with input validation - types.ts: Add workstream? to GSDOptions - gsd-tools.ts: Inject --ws <name> into all gsd-tools.cjs invocations - config.ts: Resolve workstream-aware config path with root fallback - context-engine.ts: Constructor accepts workstream via positional param - index.ts: GSD class propagates workstream to all subsystems - ws-flag.test.ts: 22 tests covering all workstream functionality Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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/<name>/. */
|
||||
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 <input> Bootstrap from a PRD before running (auto only)
|
||||
Accepts @path/to/prd.md or "description text"
|
||||
--project-dir <dir> Project directory (default: cwd)
|
||||
--ws <name> Route .planning/ to .planning/workstreams/<name>/
|
||||
--ws-port <port> Enable WebSocket transport on <port>
|
||||
--model <model> Override LLM model
|
||||
--max-budget <n> Max budget per step in USD
|
||||
@@ -194,6 +200,13 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate --ws flag if provided
|
||||
if (args.ws !== undefined && !validateWorkstreamName(args.ws)) {
|
||||
console.error(`Error: Invalid workstream name "${args.ws}". Use alphanumeric, hyphens, underscores, or dots only.`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.command !== 'run' && args.command !== 'init' && args.command !== 'auto') {
|
||||
console.error('Error: Expected "gsd-sdk run <prompt>", "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<void
|
||||
projectDir: args.projectDir,
|
||||
model: args.model,
|
||||
maxBudgetUsd: args.maxBudget,
|
||||
workstream: args.ws,
|
||||
});
|
||||
|
||||
// Wire CLI transport
|
||||
@@ -296,6 +310,7 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
|
||||
model: args.model,
|
||||
maxBudgetUsd: args.maxBudget,
|
||||
autoMode: true,
|
||||
workstream: args.ws,
|
||||
});
|
||||
|
||||
// Wire CLI transport (always active)
|
||||
@@ -384,6 +399,7 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
|
||||
projectDir: args.projectDir,
|
||||
model: args.model,
|
||||
maxBudgetUsd: args.maxBudget,
|
||||
workstream: args.ws,
|
||||
});
|
||||
|
||||
// Wire CLI transport (always active)
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { relPlanningPath } from './workstream-utils.js';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -99,15 +100,25 @@ export const CONFIG_DEFAULTS: GSDConfig = {
|
||||
* Returns full defaults when file is missing or empty.
|
||||
* Throws on malformed JSON with a helpful error message.
|
||||
*/
|
||||
export async function loadConfig(projectDir: string): Promise<GSDConfig> {
|
||||
const configPath = join(projectDir, '.planning', 'config.json');
|
||||
export async function loadConfig(projectDir: string, workstream?: string): Promise<GSDConfig> {
|
||||
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();
|
||||
|
||||
@@ -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<TruncationOptions>) {
|
||||
this.planningDir = join(projectDir, '.planning');
|
||||
constructor(projectDir: string, logger?: GSDLogger, truncation?: Partial<TruncationOptions>, workstream?: string) {
|
||||
this.planningDir = join(projectDir, relPlanningPath(workstream));
|
||||
this.logger = logger;
|
||||
this.truncation = { ...DEFAULT_TRUNCATION_OPTIONS, ...truncation };
|
||||
}
|
||||
|
||||
@@ -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<unknown> {
|
||||
const fullArgs = [this.gsdToolsPath, command, ...args];
|
||||
const wsArgs = this.workstream ? ['--ws', this.workstream] : [];
|
||||
const fullArgs = [this.gsdToolsPath, command, ...args, ...wsArgs];
|
||||
|
||||
return new Promise<unknown>((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<string> {
|
||||
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<string>((resolve, reject) => {
|
||||
const child = execFile(
|
||||
|
||||
@@ -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<PhaseRunnerResult> {
|
||||
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';
|
||||
|
||||
@@ -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/<name>/. */
|
||||
workstream?: string;
|
||||
}
|
||||
|
||||
// ─── S02: Event stream types ─────────────────────────────────────────────────
|
||||
|
||||
32
sdk/src/workstream-utils.ts
Normal file
32
sdk/src/workstream-utils.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Workstream utility functions for multi-workstream project support.
|
||||
*
|
||||
* When --ws <name> is provided, all .planning/ paths are routed to
|
||||
* .planning/workstreams/<name>/ 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/<name>`
|
||||
*/
|
||||
export function relPlanningPath(workstream?: string): string {
|
||||
if (!workstream) return '.planning';
|
||||
return join('.planning', 'workstreams', workstream);
|
||||
}
|
||||
285
sdk/src/ws-flag.test.ts
Normal file
285
sdk/src/ws-flag.test.ts
Normal file
@@ -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/<name>/ 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<string> {
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user