Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions sdk/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 19 additions & 0 deletions sdk/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export interface ParsedCliArgs {
wsPort: number | undefined;
model: string | undefined;
maxBudget: number | undefined;
/** Workstream name. Routes all .planning/ paths to .planning/workstreams/<name>/. */
workstream: string | undefined;
help: boolean;
version: boolean;
}
Expand All @@ -43,6 +45,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' },
Expand All @@ -60,6 +63,11 @@ export function parseCliArgs(argv: string[]): ParsedCliArgs {
// For 'run' command, it's the prompt. Both use positionals[1+].
const initInput = command === 'init' ? prompt : undefined;

const workstream = values.ws as string | undefined;
if (workstream && !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(workstream)) {
throw new Error('--ws name must start with a letter or digit and contain only letters, digits, hyphens, underscores, or dots.');
}

return {
command,
prompt,
Expand All @@ -69,6 +77,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,
workstream,
help: values.help as boolean,
version: values.version as boolean,
};
Expand All @@ -92,6 +101,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> Workstream name (routes .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
Expand Down Expand Up @@ -226,6 +236,7 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
projectDir: args.projectDir,
model: args.model,
maxBudgetUsd: args.maxBudget,
workstream: args.workstream,
});

// Wire CLI transport
Expand All @@ -241,12 +252,17 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
console.log(`WebSocket transport listening on port ${args.wsPort}`);
}

if (args.workstream) {
console.log(`[init] Workstream: ${args.workstream}`);
}

try {
const tools = gsd.createTools();
const runner = new InitRunner({
projectDir: args.projectDir,
tools,
eventStream: gsd.eventStream,
workstream: args.workstream,
config: {
maxBudgetPerSession: args.maxBudget,
orchestratorModel: args.model,
Expand Down Expand Up @@ -296,6 +312,7 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
model: args.model,
maxBudgetUsd: args.maxBudget,
autoMode: true,
workstream: args.workstream,
});

// Wire CLI transport (always active)
Expand Down Expand Up @@ -327,6 +344,7 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
projectDir: args.projectDir,
tools,
eventStream: gsd.eventStream,
workstream: args.workstream,
config: {
maxBudgetPerSession: args.maxBudget,
orchestratorModel: args.model,
Expand Down Expand Up @@ -384,6 +402,7 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
projectDir: args.projectDir,
model: args.model,
maxBudgetUsd: args.maxBudget,
workstream: args.workstream,
});

// Wire CLI transport (always active)
Expand Down
8 changes: 6 additions & 2 deletions sdk/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,11 +96,15 @@ export const CONFIG_DEFAULTS: GSDConfig = {

/**
* Load project config from `.planning/config.json`, merging with defaults.
* When workstream is provided, reads from `.planning/workstreams/<name>/config.json`.
* 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 planningDir = workstream
? join(projectDir, '.planning', 'workstreams', workstream)
: join(projectDir, '.planning');
const configPath = join(planningDir, 'config.json');

let raw: string;
try {
Expand Down
10 changes: 5 additions & 5 deletions sdk/src/context-engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ describe('ContextEngine', () => {
});

const logger = makeMockLogger();
const engine = new ContextEngine(projectDir, logger);
const engine = new ContextEngine(projectDir, { logger });
const files = await engine.resolveContextFiles(PhaseType.Plan);

// research and requirements are optional for plan — no warning
Expand All @@ -153,7 +153,7 @@ describe('ContextEngine', () => {
await createPlanningDir(projectDir, {});

const logger = makeMockLogger();
const engine = new ContextEngine(projectDir, logger);
const engine = new ContextEngine(projectDir, { logger });
await engine.resolveContextFiles(PhaseType.Execute);

expect(logger.warn).toHaveBeenCalledWith(
Expand Down Expand Up @@ -196,7 +196,7 @@ describe('ContextEngine', () => {
'CONTEXT.md': largeContent,
});

const engine = new ContextEngine(projectDir, undefined, { maxContentLength: 500 });
const engine = new ContextEngine(projectDir, { truncation: { maxContentLength: 500 } });
const files = await engine.resolveContextFiles(PhaseType.Plan);

// CONTEXT.md should be truncated
Expand All @@ -223,7 +223,7 @@ describe('ContextEngine', () => {
'STATE.md': largeState,
});

const engine = new ContextEngine(projectDir, undefined, { maxContentLength: 100 });
const engine = new ContextEngine(projectDir, { truncation: { maxContentLength: 100 } });
const files = await engine.resolveContextFiles(PhaseType.Execute);

expect(files.state).toBe(largeState);
Expand Down Expand Up @@ -262,7 +262,7 @@ Build content.`;
});

// Low threshold forces truncation
const engine = new ContextEngine(projectDir, undefined, { maxContentLength: 50 });
const engine = new ContextEngine(projectDir, { truncation: { maxContentLength: 50 } });
const files = await engine.resolveContextFiles(PhaseType.Plan);
expect(files.context!.length).toBeLessThan(content.length);
});
Expand Down
10 changes: 6 additions & 4 deletions sdk/src/context-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,12 @@ export class ContextEngine {
private readonly logger?: GSDLogger;
private readonly truncation: TruncationOptions;

constructor(projectDir: string, logger?: GSDLogger, truncation?: Partial<TruncationOptions>) {
this.planningDir = join(projectDir, '.planning');
this.logger = logger;
this.truncation = { ...DEFAULT_TRUNCATION_OPTIONS, ...truncation };
constructor(projectDir: string, opts?: { workstream?: string; logger?: GSDLogger; truncation?: Partial<TruncationOptions> }) {
this.planningDir = opts?.workstream
? join(projectDir, '.planning', 'workstreams', opts.workstream)
: join(projectDir, '.planning');
this.logger = opts?.logger;
this.truncation = { ...DEFAULT_TRUNCATION_OPTIONS, ...opts?.truncation };
}

/**
Expand Down
9 changes: 7 additions & 2 deletions sdk/src/gsd-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────────────────────
Expand All @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
11 changes: 8 additions & 3 deletions sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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();
}

Expand All @@ -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();
Expand Down Expand Up @@ -117,6 +119,7 @@ export class GSD {
return new GSDTools({
projectDir: this.projectDir,
gsdToolsPath: this.gsdToolsPath,
workstream: this.workstream,
});
}

Expand All @@ -133,8 +136,10 @@ 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, {
workstream: 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) {
Expand Down
Loading
Loading