feat: GSD SDK — headless CLI with init + auto commands (#1407)

* test: Bootstrapped sdk/ as TypeScript ESM package with full GSD-1 PLAN.…

- "sdk/package.json"
- "sdk/tsconfig.json"
- "sdk/vitest.config.ts"
- "sdk/src/types.ts"
- "sdk/src/plan-parser.ts"
- "sdk/src/plan-parser.test.ts"

GSD-Task: S01/T01

* test: Implemented config reader and gsd-tools bridge with 25 unit tests…

- "sdk/src/config.ts"
- "sdk/src/config.test.ts"
- "sdk/src/gsd-tools.ts"
- "sdk/src/gsd-tools.test.ts"

GSD-Task: S01/T02

* test: Built prompt-builder, session-runner, and GSD class — 85 total un…

- "sdk/src/prompt-builder.ts"
- "sdk/src/prompt-builder.test.ts"
- "sdk/src/session-runner.ts"
- "sdk/src/index.ts"
- "sdk/src/types.ts"

GSD-Task: S01/T03

* test: Created E2E integration test with fixtures proving full SDK pipel…

- "sdk/src/e2e.integration.test.ts"
- "sdk/test-fixtures/sample-plan.md"
- "sdk/test-fixtures/.planning/config.json"
- "sdk/test-fixtures/.planning/STATE.md"
- "vitest.config.ts"
- "tsconfig.json"

GSD-Task: S01/T04

* test: Added PhaseType/GSDEventType enums, 16-variant GSDEvent union, GS…

- "sdk/src/types.ts"
- "sdk/src/event-stream.ts"
- "sdk/src/logger.ts"
- "sdk/src/event-stream.test.ts"
- "sdk/src/logger.test.ts"

GSD-Task: S02/T01

* test: Built ContextEngine for phase-aware context file resolution, getT…

- "sdk/src/context-engine.ts"
- "sdk/src/tool-scoping.ts"
- "sdk/src/phase-prompt.ts"
- "sdk/src/context-engine.test.ts"
- "sdk/src/tool-scoping.test.ts"
- "sdk/src/phase-prompt.test.ts"

GSD-Task: S02/T02

* test: Wired event stream into session runner, added onEvent()/addTransp…

- "sdk/src/session-runner.ts"
- "sdk/src/index.ts"
- "sdk/src/e2e.integration.test.ts"

GSD-Task: S02/T03

* feat: Added PhaseStepType enum, PhaseOpInfo interface, phase lifecycle…

- "sdk/src/types.ts"
- "sdk/src/gsd-tools.ts"
- "sdk/src/session-runner.ts"
- "sdk/src/index.ts"
- "sdk/src/phase-runner-types.test.ts"

GSD-Task: S03/T01

* test: Implemented PhaseRunner state machine with 39 unit tests covering…

- "sdk/src/phase-runner.ts"
- "sdk/src/phase-runner.test.ts"

GSD-Task: S03/T02

* test: Wired PhaseRunner into GSD.runPhase() public API with full re-exp…

- "sdk/src/index.ts"
- "sdk/src/phase-runner.integration.test.ts"
- "sdk/src/phase-runner.ts"

GSD-Task: S03/T03

* test: Expanded runVerifyStep with full gap closure cycle (plan → execut…

- "sdk/src/types.ts"
- "sdk/src/phase-runner.ts"
- "sdk/src/phase-runner.test.ts"

GSD-Task: S04/T02

* fix: Added 3 integration tests proving phasePlanIndex returns correct t…

- "sdk/src/phase-runner.integration.test.ts"
- "sdk/src/index.ts"

GSD-Task: S04/T03

* test: Add milestone-level types, typed roadmapAnalyze(), GSD.run() orch…

- "sdk/src/types.ts"
- "sdk/src/gsd-tools.ts"
- "sdk/src/index.ts"
- "sdk/src/milestone-runner.test.ts"

GSD-Task: S05/T01

* test: Added CLITransport (structured stdout log lines) and WSTransport…

- "sdk/src/cli-transport.ts"
- "sdk/src/cli-transport.test.ts"
- "sdk/src/ws-transport.ts"
- "sdk/src/ws-transport.test.ts"
- "sdk/src/index.ts"
- "sdk/package.json"

GSD-Task: S05/T02

* test: Added gsd-sdk CLI entry point with argument parsing, bin field, p…

- "sdk/src/cli.ts"
- "sdk/src/cli.test.ts"
- "sdk/package.json"

GSD-Task: S05/T03

* feat: Add InitNewProjectInfo type, initNewProject()/configSet() GSDTool…

- "sdk/src/types.ts"
- "sdk/src/gsd-tools.ts"
- "sdk/src/cli.ts"
- "sdk/src/cli.test.ts"
- "sdk/src/gsd-tools.test.ts"

GSD-Task: S01/T01

* chore: Created InitRunner orchestrator with setup → config → PROJECT.md…

- "sdk/src/init-runner.ts"
- "sdk/src/types.ts"
- "sdk/src/index.ts"

GSD-Task: S01/T02

* test: Wired InitRunner into CLI main() for full gsd-sdk init dispatch a…

- "sdk/src/cli.ts"
- "sdk/src/init-runner.test.ts"
- "sdk/src/cli.test.ts"

GSD-Task: S01/T03

* test: Add PlanCheck step, AI self-discuss, and retryOnce wrapper to Pha…

- "sdk/src/types.ts"
- "sdk/src/phase-runner.ts"
- "sdk/src/session-runner.ts"
- "sdk/src/phase-runner.test.ts"
- "sdk/src/phase-runner-types.test.ts"

GSD-Task: S02/T01

* feat: Rewrite CLITransport with ANSI colors, phase banners, spawn indic…

- "sdk/src/cli-transport.ts"
- "sdk/src/cli-transport.test.ts"

GSD-Task: S02/T02

* test: Add `gsd-sdk auto` command with autoMode config override, USAGE t…

- "sdk/src/cli.ts"
- "sdk/src/cli.test.ts"
- "sdk/src/index.ts"
- "sdk/src/types.ts"

GSD-Task: S02/T03

* fix: CLI shebang + gsd-tools non-JSON output handling

Three bugs found during first real gsd-sdk run:

1. cli.ts shebang was commented out — shell executed JS as bash,
   triggering ImageMagick's import command instead of Node

2. configSet() called exec() which JSON.parse()d the output, but
   gsd-tools config-set returns 'key=value' text, not JSON.
   Added execRaw() method for commands that return plain text.

3. Same JSON parse bug affected commit() (returns git SHA),
   stateLoad(), verifySummary(), initExecutePhase(), stateBeginPhase(),
   and phaseComplete(). All switched to execRaw().

Tests updated to match real gsd-tools output format (plain text
instead of mocked JSON). 376/376 tests pass.
This commit is contained in:
TÂCHES
2026-03-26 20:27:51 -06:00
committed by GitHub
parent 604a78b30b
commit 596ce2d252
45 changed files with 17604 additions and 2 deletions

703
sdk/src/init-runner.ts Normal file
View File

@@ -0,0 +1,703 @@
/**
* InitRunner — orchestrates the GSD new-project init workflow.
*
* Workflow: setup → config → PROJECT.md → parallel research (4 sessions)
* → synthesis → requirements → roadmap
*
* Each step calls Agent SDK `query()` via `runPhaseStepSession()` with
* prompts derived from GSD-1 workflow/agent/template files on disk.
*/
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { execFile } from 'node:child_process';
import type {
InitConfig,
InitResult,
InitStepResult,
InitStepName,
InitNewProjectInfo,
GSDInitStartEvent,
GSDInitStepStartEvent,
GSDInitStepCompleteEvent,
GSDInitCompleteEvent,
GSDInitResearchSpawnEvent,
PlanResult,
} from './types.js';
import { GSDEventType, PhaseStepType } from './types.js';
import type { GSDTools } from './gsd-tools.js';
import type { GSDEventStream } from './event-stream.js';
import { loadConfig } from './config.js';
import { runPhaseStepSession } from './session-runner.js';
// ─── Constants ───────────────────────────────────────────────────────────────
const GSD_TEMPLATES_DIR = join(homedir(), '.claude', 'get-shit-done', 'templates');
const GSD_AGENTS_DIR = join(homedir(), '.claude', 'agents');
const RESEARCH_TYPES = ['STACK', 'FEATURES', 'ARCHITECTURE', 'PITFALLS'] as const;
type ResearchType = (typeof RESEARCH_TYPES)[number];
const RESEARCH_STEP_MAP: Record<ResearchType, InitStepName> = {
STACK: 'research-stack',
FEATURES: 'research-features',
ARCHITECTURE: 'research-architecture',
PITFALLS: 'research-pitfalls',
};
/** Default config.json written during init for auto-mode projects. */
const AUTO_MODE_CONFIG = {
mode: 'yolo',
parallelization: true,
depth: 'quick',
workflow: {
research: true,
plan_checker: true,
verifier: true,
auto_advance: true,
skip_discuss: false,
},
};
// ─── InitRunner ──────────────────────────────────────────────────────────────
export interface InitRunnerDeps {
projectDir: string;
tools: GSDTools;
eventStream: GSDEventStream;
config?: Partial<InitConfig>;
}
export class InitRunner {
private readonly projectDir: string;
private readonly tools: GSDTools;
private readonly eventStream: GSDEventStream;
private readonly config: InitConfig;
private readonly sessionId: string;
constructor(deps: InitRunnerDeps) {
this.projectDir = deps.projectDir;
this.tools = deps.tools;
this.eventStream = deps.eventStream;
this.config = {
maxBudgetPerSession: deps.config?.maxBudgetPerSession ?? 3.0,
maxTurnsPerSession: deps.config?.maxTurnsPerSession ?? 30,
researchModel: deps.config?.researchModel,
orchestratorModel: deps.config?.orchestratorModel,
};
this.sessionId = `init-${Date.now()}`;
}
/**
* Run the full init workflow.
*
* @param input - User input: PRD content, project description, etc.
* @returns InitResult with per-step results, artifacts, and totals.
*/
async run(input: string): Promise<InitResult> {
const startTime = Date.now();
const steps: InitStepResult[] = [];
const artifacts: string[] = [];
this.emitEvent<GSDInitStartEvent>({
type: GSDEventType.InitStart,
input: input.slice(0, 200),
projectDir: this.projectDir,
});
try {
// ── Step 1: Setup — get project metadata ──────────────────────────
const setupResult = await this.runStep('setup', async () => {
const info = await this.tools.initNewProject();
if (info.project_exists) {
throw new Error('Project already exists (.planning/PROJECT.md found). Use a fresh directory or delete .planning/ first.');
}
return info;
});
steps.push(setupResult.stepResult);
if (!setupResult.stepResult.success) {
return this.buildResult(false, steps, artifacts, startTime);
}
const projectInfo = setupResult.value as InitNewProjectInfo;
// ── Step 2: Config — write config.json and init git ───────────────
const configResult = await this.runStep('config', async () => {
// Ensure git is initialized
if (!projectInfo.has_git) {
await this.execGit(['init']);
}
// Ensure .planning/ directory exists
const planningDir = join(this.projectDir, '.planning');
await mkdir(planningDir, { recursive: true });
// Write config.json
const configPath = join(planningDir, 'config.json');
await writeFile(configPath, JSON.stringify(AUTO_MODE_CONFIG, null, 2) + '\n', 'utf-8');
artifacts.push('.planning/config.json');
// Persist auto_advance via gsd-tools (validates & updates state)
await this.tools.configSet('workflow.auto_advance', 'true');
// Commit config
if (projectInfo.commit_docs) {
await this.tools.commit('chore: add project config', ['.planning/config.json']);
}
});
steps.push(configResult.stepResult);
if (!configResult.stepResult.success) {
return this.buildResult(false, steps, artifacts, startTime);
}
// ── Step 3: PROJECT.md — synthesize from input ────────────────────
const projectResult = await this.runStep('project', async () => {
const prompt = await this.buildProjectPrompt(input);
const result = await this.runSession(prompt, projectInfo.researcher_model);
if (!result.success) {
throw new Error(`PROJECT.md synthesis failed: ${result.error?.messages.join(', ') ?? 'unknown error'}`);
}
artifacts.push('.planning/PROJECT.md');
if (projectInfo.commit_docs) {
await this.tools.commit('docs: add PROJECT.md', ['.planning/PROJECT.md']);
}
return result;
});
steps.push(projectResult.stepResult);
if (!projectResult.stepResult.success) {
return this.buildResult(false, steps, artifacts, startTime);
}
// ── Step 4: Parallel research (4 sessions) ───────────────────────
const researchSteps = await this.runParallelResearch(input, projectInfo);
steps.push(...researchSteps);
const researchFailed = researchSteps.some(s => !s.success);
// Add artifacts for successful research files
for (const rs of researchSteps) {
if (rs.success && rs.artifacts) {
artifacts.push(...rs.artifacts);
}
}
if (researchFailed) {
// Continue with partial results — synthesis will work with what's available
// but flag the overall result as partial
}
// ── Step 5: Synthesis — combine research into SUMMARY.md ──────────
const synthResult = await this.runStep('synthesis', async () => {
const prompt = await this.buildSynthesisPrompt();
const result = await this.runSession(prompt, projectInfo.synthesizer_model);
if (!result.success) {
throw new Error(`Research synthesis failed: ${result.error?.messages.join(', ') ?? 'unknown error'}`);
}
artifacts.push('.planning/research/SUMMARY.md');
if (projectInfo.commit_docs) {
await this.tools.commit('docs: add research files', ['.planning/research/']);
}
return result;
});
steps.push(synthResult.stepResult);
if (!synthResult.stepResult.success) {
return this.buildResult(false, steps, artifacts, startTime);
}
// ── Step 6: Requirements — derive from PROJECT + research ─────────
const reqResult = await this.runStep('requirements', async () => {
const prompt = await this.buildRequirementsPrompt();
const result = await this.runSession(prompt, projectInfo.synthesizer_model);
if (!result.success) {
throw new Error(`Requirements generation failed: ${result.error?.messages.join(', ') ?? 'unknown error'}`);
}
artifacts.push('.planning/REQUIREMENTS.md');
if (projectInfo.commit_docs) {
await this.tools.commit('docs: add REQUIREMENTS.md', ['.planning/REQUIREMENTS.md']);
}
return result;
});
steps.push(reqResult.stepResult);
if (!reqResult.stepResult.success) {
return this.buildResult(false, steps, artifacts, startTime);
}
// ── Step 7: Roadmap — create phases + STATE.md ────────────────────
const roadmapResult = await this.runStep('roadmap', async () => {
const prompt = await this.buildRoadmapPrompt();
const result = await this.runSession(prompt, projectInfo.roadmapper_model);
if (!result.success) {
throw new Error(`Roadmap generation failed: ${result.error?.messages.join(', ') ?? 'unknown error'}`);
}
artifacts.push('.planning/ROADMAP.md', '.planning/STATE.md');
if (projectInfo.commit_docs) {
await this.tools.commit('docs: add ROADMAP.md and STATE.md', [
'.planning/ROADMAP.md',
'.planning/STATE.md',
]);
}
return result;
});
steps.push(roadmapResult.stepResult);
if (!roadmapResult.stepResult.success) {
return this.buildResult(false, steps, artifacts, startTime);
}
const success = !researchFailed;
return this.buildResult(success, steps, artifacts, startTime);
} catch (err) {
// Unexpected top-level error
steps.push({
step: 'setup',
success: false,
durationMs: 0,
costUsd: 0,
error: err instanceof Error ? err.message : String(err),
});
return this.buildResult(false, steps, artifacts, startTime);
}
}
// ─── Step execution wrapper ────────────────────────────────────────────────
private async runStep<T>(
step: InitStepName,
fn: () => Promise<T>,
): Promise<{ stepResult: InitStepResult; value?: T }> {
const stepStart = Date.now();
this.emitEvent<GSDInitStepStartEvent>({
type: GSDEventType.InitStepStart,
step,
});
try {
const value = await fn();
const durationMs = Date.now() - stepStart;
const costUsd = this.extractCost(value);
const stepResult: InitStepResult = {
step,
success: true,
durationMs,
costUsd,
};
this.emitEvent<GSDInitStepCompleteEvent>({
type: GSDEventType.InitStepComplete,
step,
success: true,
durationMs,
costUsd,
});
return { stepResult, value };
} catch (err) {
const durationMs = Date.now() - stepStart;
const errorMsg = err instanceof Error ? err.message : String(err);
const stepResult: InitStepResult = {
step,
success: false,
durationMs,
costUsd: 0,
error: errorMsg,
};
this.emitEvent<GSDInitStepCompleteEvent>({
type: GSDEventType.InitStepComplete,
step,
success: false,
durationMs,
costUsd: 0,
error: errorMsg,
});
return { stepResult };
}
}
// ─── Parallel research ─────────────────────────────────────────────────────
private async runParallelResearch(
input: string,
projectInfo: InitNewProjectInfo,
): Promise<InitStepResult[]> {
this.emitEvent<GSDInitResearchSpawnEvent>({
type: GSDEventType.InitResearchSpawn,
sessionCount: RESEARCH_TYPES.length,
researchTypes: [...RESEARCH_TYPES],
});
const promises = RESEARCH_TYPES.map(async (researchType) => {
const step = RESEARCH_STEP_MAP[researchType];
const result = await this.runStep(step, async () => {
const prompt = await this.buildResearchPrompt(researchType, input);
const sessionResult = await this.runSession(prompt, projectInfo.researcher_model);
if (!sessionResult.success) {
throw new Error(
`Research (${researchType}) failed: ${sessionResult.error?.messages.join(', ') ?? 'unknown error'}`,
);
}
return sessionResult;
});
// Attach artifact path on success
if (result.stepResult.success) {
result.stepResult.artifacts = [`.planning/research/${researchType}.md`];
}
return result.stepResult;
});
const results = await Promise.allSettled(promises);
return results.map((r, i) => {
if (r.status === 'fulfilled') {
return r.value;
}
// Promise.allSettled rejection — should not happen since runStep catches,
// but handle defensively
return {
step: RESEARCH_STEP_MAP[RESEARCH_TYPES[i]!]!,
success: false,
durationMs: 0,
costUsd: 0,
error: r.reason instanceof Error ? r.reason.message : String(r.reason),
} satisfies InitStepResult;
});
}
// ─── Prompt builders ───────────────────────────────────────────────────────
/**
* Build the PROJECT.md synthesis prompt.
* Reads the project template and combines with user input.
*/
private async buildProjectPrompt(input: string): Promise<string> {
const template = await this.readGSDFile('templates/project.md');
return [
'You are creating the PROJECT.md for a new software project.',
'Write .planning/PROJECT.md based on the template structure below and the user\'s project description.',
'',
'<project_template>',
template,
'</project_template>',
'',
'<user_input>',
input,
'</user_input>',
'',
'Write the file to .planning/PROJECT.md. Follow the template structure but fill in with real content derived from the user input.',
'Be specific and opinionated — make decisions, don\'t list options.',
].join('\n');
}
/**
* Build a research prompt for a specific research type.
* Reads the agent definition and research template.
*/
private async buildResearchPrompt(
researchType: ResearchType,
input: string,
): Promise<string> {
const agentDef = await this.readAgentFile('gsd-project-researcher.md');
const template = await this.readGSDFile(`templates/research-project/${researchType}.md`);
// Read PROJECT.md if it exists (it should by now)
let projectContent = '';
try {
projectContent = await readFile(
join(this.projectDir, '.planning', 'PROJECT.md'),
'utf-8',
);
} catch {
// Fall back to raw input if PROJECT.md not yet written
projectContent = input;
}
return [
'<agent_definition>',
agentDef,
'</agent_definition>',
'',
`You are researching the ${researchType} aspect of this project.`,
`Write your findings to .planning/research/${researchType}.md`,
'',
'<files_to_read>',
'.planning/PROJECT.md',
'</files_to_read>',
'',
'<project_context>',
projectContent,
'</project_context>',
'',
'<research_template>',
template,
'</research_template>',
'',
`Write .planning/research/${researchType}.md following the template structure.`,
'Be comprehensive but opinionated. "Use X because Y" not "Options are X, Y, Z."',
].join('\n');
}
/**
* Build the synthesis prompt.
* Reads synthesizer agent def and all 4 research outputs.
*/
private async buildSynthesisPrompt(): Promise<string> {
const agentDef = await this.readAgentFile('gsd-research-synthesizer.md');
const summaryTemplate = await this.readGSDFile('templates/research-project/SUMMARY.md');
const researchDir = join(this.projectDir, '.planning', 'research');
// Read whatever research files exist
const researchContent: string[] = [];
for (const rt of RESEARCH_TYPES) {
try {
const content = await readFile(join(researchDir, `${rt}.md`), 'utf-8');
researchContent.push(`<research_${rt.toLowerCase()}>\n${content}\n</research_${rt.toLowerCase()}>`);
} catch {
researchContent.push(`<research_${rt.toLowerCase()}>\n(Not available)\n</research_${rt.toLowerCase()}>`);
}
}
return [
'<agent_definition>',
agentDef,
'</agent_definition>',
'',
'<files_to_read>',
'.planning/research/STACK.md',
'.planning/research/FEATURES.md',
'.planning/research/ARCHITECTURE.md',
'.planning/research/PITFALLS.md',
'</files_to_read>',
'',
'Synthesize the research files below into .planning/research/SUMMARY.md',
'',
...researchContent,
'',
'<summary_template>',
summaryTemplate,
'</summary_template>',
'',
'Write .planning/research/SUMMARY.md synthesizing all research findings.',
'Also commit all research files: git add .planning/research/ && git commit.',
].join('\n');
}
/**
* Build the requirements prompt.
* Reads PROJECT.md + FEATURES.md for requirement derivation.
*/
private async buildRequirementsPrompt(): Promise<string> {
const reqTemplate = await this.readGSDFile('templates/requirements.md');
let projectContent = '';
let featuresContent = '';
try {
projectContent = await readFile(
join(this.projectDir, '.planning', 'PROJECT.md'),
'utf-8',
);
} catch {
// Should not happen at this point
}
try {
featuresContent = await readFile(
join(this.projectDir, '.planning', 'research', 'FEATURES.md'),
'utf-8',
);
} catch {
// Research may have partially failed
}
return [
'You are generating REQUIREMENTS.md for this project.',
'Derive requirements from the PROJECT.md and research outputs.',
'Auto-include all table-stakes requirements (auth, error handling, logging, etc.).',
'',
'<project_context>',
projectContent,
'</project_context>',
'',
'<features_research>',
featuresContent || '(Not available)',
'</features_research>',
'',
'<requirements_template>',
reqTemplate,
'</requirements_template>',
'',
'Write .planning/REQUIREMENTS.md following the template structure.',
'Every requirement must be testable and specific. No vague aspirations.',
].join('\n');
}
/**
* Build the roadmap prompt.
* Reads PROJECT.md + REQUIREMENTS.md + research/SUMMARY.md + config.json.
*/
private async buildRoadmapPrompt(): Promise<string> {
const agentDef = await this.readAgentFile('gsd-roadmapper.md');
const roadmapTemplate = await this.readGSDFile('templates/roadmap.md');
const stateTemplate = await this.readGSDFile('templates/state.md');
const filesToRead = [
'.planning/PROJECT.md',
'.planning/REQUIREMENTS.md',
'.planning/research/SUMMARY.md',
'.planning/config.json',
];
const fileContents: string[] = [];
for (const fp of filesToRead) {
try {
const content = await readFile(join(this.projectDir, fp), 'utf-8');
fileContents.push(`<file path="${fp}">\n${content}\n</file>`);
} catch {
fileContents.push(`<file path="${fp}">\n(Not available)\n</file>`);
}
}
return [
'<agent_definition>',
agentDef,
'</agent_definition>',
'',
'<files_to_read>',
...filesToRead,
'</files_to_read>',
'',
...fileContents,
'',
'<roadmap_template>',
roadmapTemplate,
'</roadmap_template>',
'',
'<state_template>',
stateTemplate,
'</state_template>',
'',
'Create .planning/ROADMAP.md and .planning/STATE.md.',
'ROADMAP.md: Transform requirements into phases. Every v1 requirement maps to exactly one phase.',
'STATE.md: Initialize project state tracking.',
].join('\n');
}
// ─── Session execution ─────────────────────────────────────────────────────
/**
* Run a single Agent SDK session via runPhaseStepSession.
*/
private async runSession(prompt: string, modelOverride?: string): Promise<PlanResult> {
const config = await loadConfig(this.projectDir);
return runPhaseStepSession(
prompt,
PhaseStepType.Research, // Research phase gives broadest tool access
config,
{
maxTurns: this.config.maxTurnsPerSession,
maxBudgetUsd: this.config.maxBudgetPerSession,
model: modelOverride ?? this.config.orchestratorModel,
cwd: this.projectDir,
},
this.eventStream,
{ phase: undefined, planName: undefined },
);
}
// ─── File reading helpers ──────────────────────────────────────────────────
/**
* Read a file from the GSD templates directory (~/.claude/get-shit-done/).
*/
private async readGSDFile(relativePath: string): Promise<string> {
const fullPath = join(GSD_TEMPLATES_DIR, '..', relativePath);
try {
return await readFile(fullPath, 'utf-8');
} catch {
// If the template doesn't exist, return a placeholder
return `(Template not found: ${relativePath})`;
}
}
/**
* Read an agent definition from ~/.claude/agents/.
*/
private async readAgentFile(filename: string): Promise<string> {
const fullPath = join(GSD_AGENTS_DIR, filename);
try {
return await readFile(fullPath, 'utf-8');
} catch {
return `(Agent definition not found: ${filename})`;
}
}
// ─── Git helper ────────────────────────────────────────────────────────────
/**
* Execute a git command in the project directory.
*/
private execGit(args: string[]): Promise<string> {
return new Promise((resolve, reject) => {
execFile('git', args, { cwd: this.projectDir }, (error, stdout, stderr) => {
if (error) {
reject(new Error(`git ${args.join(' ')} failed: ${stderr || error.message}`));
return;
}
resolve(stdout.toString());
});
});
}
// ─── Event helpers ─────────────────────────────────────────────────────────
private emitEvent<T extends { type: GSDEventType }>(
partial: Omit<T, 'timestamp' | 'sessionId'> & { type: GSDEventType },
): void {
this.eventStream.emitEvent({
timestamp: new Date().toISOString(),
sessionId: this.sessionId,
...partial,
} as unknown as import('./types.js').GSDEvent);
}
// ─── Result helpers ────────────────────────────────────────────────────────
private buildResult(
success: boolean,
steps: InitStepResult[],
artifacts: string[],
startTime: number,
): InitResult {
const totalCostUsd = steps.reduce((sum, s) => sum + s.costUsd, 0);
const totalDurationMs = Date.now() - startTime;
this.emitEvent<GSDInitCompleteEvent>({
type: GSDEventType.InitComplete,
success,
totalCostUsd,
totalDurationMs,
artifactCount: artifacts.length,
});
return {
success,
steps,
totalCostUsd,
totalDurationMs,
artifacts,
};
}
/**
* Extract cost from a step return value if it's a PlanResult.
*/
private extractCost(value: unknown): number {
if (value && typeof value === 'object' && 'totalCostUsd' in value) {
return (value as PlanResult).totalCostUsd;
}
return 0;
}
}