diff --git a/sdk/src/gsd-tools.test.ts b/sdk/src/gsd-tools.test.ts index 410cb9427..d98364281 100644 --- a/sdk/src/gsd-tools.test.ts +++ b/sdk/src/gsd-tools.test.ts @@ -43,7 +43,7 @@ describe('GSDTools', () => { `process.stdout.write(JSON.stringify({ status: "ok", count: 42 }));`, ); - const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath }); + const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false }); const result = await tools.exec('state', ['load']); expect(result).toEqual({ status: 'ok', count: 42 }); @@ -61,7 +61,7 @@ describe('GSDTools', () => { `process.stdout.write('@file:${resultFile.replace(/\\/g, '\\\\')}');`, ); - const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath }); + const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false }); const result = await tools.exec('state', ['load']); expect(result).toEqual(bigData); @@ -73,7 +73,7 @@ describe('GSDTools', () => { `// outputs nothing`, ); - const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath }); + const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false }); const result = await tools.exec('state', ['load']); expect(result).toBeNull(); @@ -85,7 +85,7 @@ describe('GSDTools', () => { `process.stderr.write('something went wrong\\n'); process.exit(1);`, ); - const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath }); + const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false }); try { await tools.exec('state', ['load']); @@ -104,6 +104,7 @@ describe('GSDTools', () => { const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: '/nonexistent/path/gsd-tools.cjs', + preferNativeQuery: false, }); await expect(tools.exec('state', ['load'])).rejects.toThrow(GSDToolsError); @@ -115,7 +116,7 @@ describe('GSDTools', () => { `process.stdout.write('Not JSON at all');`, ); - const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath }); + const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false }); try { await tools.exec('state', ['load']); @@ -134,7 +135,7 @@ describe('GSDTools', () => { `process.stdout.write('@file:/tmp/does-not-exist-${Date.now()}.json');`, ); - const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath }); + const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false }); await expect(tools.exec('state', ['load'])).rejects.toThrow(GSDToolsError); }); @@ -149,6 +150,7 @@ describe('GSDTools', () => { projectDir: tmpDir, gsdToolsPath: scriptPath, timeoutMs: 500, + preferNativeQuery: false, }); try { @@ -180,7 +182,7 @@ describe('GSDTools', () => { `, ); - const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath }); + const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false }); const result = await tools.stateLoad(); expect(result).toBe('phase=3\nstatus=executing'); @@ -196,7 +198,7 @@ describe('GSDTools', () => { `, ); - const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath }); + const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false }); const result = await tools.commit('test message', ['file1.md', 'file2.md']); expect(result).toBe('f89ae07'); @@ -215,7 +217,7 @@ describe('GSDTools', () => { `, ); - const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath }); + const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false }); const result = await tools.roadmapAnalyze(); expect(result).toEqual({ phases: [] }); @@ -234,7 +236,7 @@ describe('GSDTools', () => { `, ); - const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath }); + const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false }); const result = await tools.verifySummary('/path/to/SUMMARY.md'); expect(result).toBe('passed'); @@ -257,7 +259,7 @@ describe('GSDTools', () => { `process.stdout.write(${JSON.stringify(largeJson)});`, ); - const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath }); + const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false }); const result = await tools.exec('state', ['load']); expect(Array.isArray(result)).toBe(true); @@ -302,7 +304,7 @@ describe('GSDTools', () => { `, ); - const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath }); + const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false }); const result = await tools.initNewProject(); expect(result.researcher_model).toBe('claude-sonnet-4-6'); @@ -318,7 +320,7 @@ describe('GSDTools', () => { `process.stderr.write('init failed\\n'); process.exit(1);`, ); - const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath }); + const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false }); await expect(tools.initNewProject()).rejects.toThrow(GSDToolsError); }); @@ -359,7 +361,7 @@ describe('GSDTools', () => { { mode: 0o755 }, ); - const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath }); + const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false }); const result = await tools.exec('test', []); expect(result).toEqual({ source: 'local' }); }); @@ -382,7 +384,7 @@ describe('GSDTools', () => { `, ); - const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath }); + const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false }); const result = await tools.configSet('workflow.auto_advance', 'true'); expect(result).toBe('workflow.auto_advance=true'); @@ -398,7 +400,7 @@ describe('GSDTools', () => { `, ); - const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath }); + const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false }); const result = await tools.configSet('mode', 'yolo'); expect(result).toBe('mode=yolo'); diff --git a/sdk/src/gsd-tools.ts b/sdk/src/gsd-tools.ts index 9639c876c..6ddc1aff1 100644 --- a/sdk/src/gsd-tools.ts +++ b/sdk/src/gsd-tools.ts @@ -1,8 +1,13 @@ /** - * GSD Tools Bridge — shells out to `gsd-tools.cjs` for state management. + * GSD Tools Bridge — programmatic access to GSD planning operations. * - * All `.planning/` state operations go through gsd-tools.cjs rather than - * reimplementing 12K+ lines of logic. + * By default routes commands through the SDK **query registry** (same handlers as + * `gsd-sdk query`) so `PhaseRunner`, `InitRunner`, and `GSD` share contracts with + * the typed CLI. Runner hot-path helpers (`initPhaseOp`, `phasePlanIndex`, + * `phaseComplete`, `initNewProject`, `configSet`, `commit`) call + * `registry.dispatch()` with canonical keys when native query is active, avoiding + * repeated argv resolution. When a workstream is set, dispatches to `gsd-tools.cjs` so + * workstream env stays aligned with CJS. */ import { execFile } from 'node:child_process'; @@ -12,6 +17,12 @@ import { join } from 'node:path'; import { homedir } from 'node:os'; import { fileURLToPath } from 'node:url'; import type { InitNewProjectInfo, PhaseOpInfo, PhasePlanIndex, RoadmapAnalysis } from './types.js'; +import type { GSDEventStream } from './event-stream.js'; +import { GSDError, exitCodeFor } from './errors.js'; +import { createRegistry } from './query/index.js'; +import { resolveQueryArgv } from './query/registry.js'; +import { normalizeQueryCommand } from './query/normalize-query-command.js'; +import { formatStateLoadRawStdout } from './query/state-project-load.js'; // ─── Error type ────────────────────────────────────────────────────────────── @@ -22,8 +33,9 @@ export class GSDToolsError extends Error { public readonly args: string[], public readonly exitCode: number | null, public readonly stderr: string, + options?: { cause?: unknown }, ) { - super(message); + super(message, options); this.name = 'GSDToolsError'; } } @@ -35,23 +47,210 @@ const BUNDLED_GSD_TOOLS_PATH = fileURLToPath( new URL('../../get-shit-done/bin/gsd-tools.cjs', import.meta.url), ); +function formatRegistryRawStdout(matchedCmd: string, data: unknown): string { + if (matchedCmd === 'state.load') { + return formatStateLoadRawStdout(data); + } + + if (matchedCmd === 'commit') { + const d = data as Record; + if (d.committed === true) { + return d.hash != null ? String(d.hash) : 'committed'; + } + if (d.committed === false) { + const r = String(d.reason ?? ''); + if ( + r.includes('commit_docs') || + r.includes('skipped') || + r.includes('gitignored') || + r === 'skipped_commit_docs_false' + ) { + return 'skipped'; + } + if (r.includes('nothing') || r.includes('nothing_to_commit')) { + return 'nothing'; + } + return r || 'nothing'; + } + return JSON.stringify(data, null, 2); + } + + if (matchedCmd === 'config-set') { + const d = data as Record; + if (d.set === true && d.key !== undefined) { + const v = d.value; + if (v === null || v === undefined) { + return `${d.key}=`; + } + if (typeof v === 'object') { + return `${d.key}=${JSON.stringify(v)}`; + } + return `${d.key}=${String(v)}`; + } + return JSON.stringify(data, null, 2); + } + + if (matchedCmd === 'state.begin-phase' || matchedCmd === 'state begin-phase') { + const d = data as Record; + const u = d.updated as string[] | undefined; + return Array.isArray(u) && u.length > 0 ? 'true' : 'false'; + } + + if (typeof data === 'string') { + return data; + } + return JSON.stringify(data, null, 2); +} + export class GSDTools { private readonly projectDir: string; private readonly gsdToolsPath: string; private readonly timeoutMs: number; private readonly workstream?: string; + private readonly registry: ReturnType; + private readonly preferNativeQuery: boolean; constructor(opts: { projectDir: string; gsdToolsPath?: string; timeoutMs?: number; workstream?: string; + /** When set, mutation handlers emit the same events as `gsd-sdk query`. */ + eventStream?: GSDEventStream; + /** + * When true (default), route known commands through the SDK query registry. + * Set false in tests that substitute a mock `gsdToolsPath` script. + */ + preferNativeQuery?: boolean; }) { this.projectDir = opts.projectDir; this.gsdToolsPath = opts.gsdToolsPath ?? resolveGsdToolsPath(opts.projectDir); this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; this.workstream = opts.workstream; + this.preferNativeQuery = opts.preferNativeQuery ?? true; + this.registry = createRegistry(opts.eventStream); + } + + private shouldUseNativeQuery(): boolean { + return this.preferNativeQuery && !this.workstream; + } + + private nativeMatch(command: string, args: string[]) { + const [normCmd, normArgs] = normalizeQueryCommand(command, args); + const tokens = [normCmd, ...normArgs]; + return resolveQueryArgv(tokens, this.registry); + } + + private toToolsError(command: string, args: string[], err: unknown): GSDToolsError { + if (err instanceof GSDError) { + return new GSDToolsError( + err.message, + command, + args, + exitCodeFor(err.classification), + '', + { cause: err }, + ); + } + const msg = err instanceof Error ? err.message : String(err); + return new GSDToolsError( + msg, + command, + args, + 1, + '', + err instanceof Error ? { cause: err } : undefined, + ); + } + + /** + * Enforce {@link GSDTools.timeoutMs} for in-process registry dispatches so native + * routing cannot hang indefinitely (subprocess path already uses `execFile` timeout). + */ + private async withRegistryDispatchTimeout( + legacyCommand: string, + legacyArgs: string[], + work: Promise, + ): Promise { + let timeoutId: ReturnType | undefined; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject( + new GSDToolsError( + `gsd-tools timed out after ${this.timeoutMs}ms: ${legacyCommand} ${legacyArgs.join(' ')}`, + legacyCommand, + legacyArgs, + null, + '', + ), + ); + }, this.timeoutMs); + }); + try { + return await Promise.race([work, timeoutPromise]); + } finally { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + } + } + + /** + * Direct registry dispatch for a known handler key — skips `resolveQueryArgv` on the hot path + * used by PhaseRunner / InitRunner (`initPhaseOp`, `phasePlanIndex`, etc.). + * When native query is off (e.g. workstream or tests with `preferNativeQuery: false`), delegates to `exec`. + * + * When native query is on, `registry.dispatch` failures are wrapped as {@link GSDToolsError} and + * **not** retried via the legacy `gsd-tools.cjs` subprocess — callers see the handler error + * explicitly. Only commands with no registry match fall through to subprocess routing in {@link exec}. + */ + private async dispatchNativeJson( + legacyCommand: string, + legacyArgs: string[], + registryCmd: string, + registryArgs: string[], + ): Promise { + if (!this.shouldUseNativeQuery()) { + return this.exec(legacyCommand, legacyArgs); + } + try { + const result = await this.withRegistryDispatchTimeout( + legacyCommand, + legacyArgs, + this.registry.dispatch(registryCmd, registryArgs, this.projectDir), + ); + return result.data; + } catch (err) { + if (err instanceof GSDToolsError) throw err; + throw this.toToolsError(legacyCommand, legacyArgs, err); + } + } + + /** + * Same as {@link dispatchNativeJson} for handlers whose CLI contract is raw stdout (`execRaw`), + * including the same “no silent fallback to CJS on handler failure” behaviour. + */ + private async dispatchNativeRaw( + legacyCommand: string, + legacyArgs: string[], + registryCmd: string, + registryArgs: string[], + ): Promise { + if (!this.shouldUseNativeQuery()) { + return this.execRaw(legacyCommand, legacyArgs); + } + try { + const result = await this.withRegistryDispatchTimeout( + legacyCommand, + legacyArgs, + this.registry.dispatch(registryCmd, registryArgs, this.projectDir), + ); + return formatRegistryRawStdout(registryCmd, result.data).trim(); + } catch (err) { + if (err instanceof GSDToolsError) throw err; + throw this.toToolsError(legacyCommand, legacyArgs, err); + } } // ─── Core exec ─────────────────────────────────────────────────────────── @@ -59,8 +258,28 @@ export class GSDTools { /** * Execute a gsd-tools command and return parsed JSON output. * Handles the `@file:` prefix pattern for large results. + * + * With native query enabled, a matching registry handler runs in-process; + * if that handler throws, the error is surfaced (no automatic fallback to `gsd-tools.cjs`). */ async exec(command: string, args: string[] = []): Promise { + if (this.shouldUseNativeQuery()) { + const matched = this.nativeMatch(command, args); + if (matched) { + try { + const result = await this.withRegistryDispatchTimeout( + command, + args, + this.registry.dispatch(matched.cmd, matched.args, this.projectDir), + ); + return result.data; + } catch (err) { + if (err instanceof GSDToolsError) throw err; + throw this.toToolsError(command, args, err); + } + } + } + const wsArgs = this.workstream ? ['--ws', this.workstream] : []; const fullArgs = [this.gsdToolsPath, command, ...args, ...wsArgs]; @@ -78,7 +297,6 @@ export class GSDTools { const stderrStr = stderr?.toString() ?? ''; if (error) { - // Distinguish timeout from other errors if (error.killed || (error as NodeJS.ErrnoException).code === 'ETIMEDOUT') { reject( new GSDToolsError( @@ -123,7 +341,6 @@ export class GSDTools { }, ); - // Safety net: kill if child doesn't respond to timeout signal child.on('error', (err) => { reject( new GSDToolsError( @@ -169,6 +386,23 @@ export class GSDTools { * Use for commands like `config-set` that return plain text, not JSON. */ async execRaw(command: string, args: string[] = []): Promise { + if (this.shouldUseNativeQuery()) { + const matched = this.nativeMatch(command, args); + if (matched) { + try { + const result = await this.withRegistryDispatchTimeout( + command, + args, + this.registry.dispatch(matched.cmd, matched.args, this.projectDir), + ); + return formatRegistryRawStdout(matched.cmd, result.data).trim(); + } catch (err) { + if (err instanceof GSDToolsError) throw err; + throw this.toToolsError(command, args, err); + } + } + } + const wsArgs = this.workstream ? ['--ws', this.workstream] : []; const fullArgs = [this.gsdToolsPath, command, ...args, ...wsArgs, '--raw']; @@ -217,7 +451,7 @@ export class GSDTools { // ─── Typed convenience methods ───────────────────────────────────────── async stateLoad(): Promise { - return this.execRaw('state', ['load']); + return this.dispatchNativeRaw('state', ['load'], 'state.load', []); } async roadmapAnalyze(): Promise { @@ -225,7 +459,7 @@ export class GSDTools { } async phaseComplete(phase: string): Promise { - return this.execRaw('phase', ['complete', phase]); + return this.dispatchNativeRaw('phase', ['complete', phase], 'phase.complete', [phase]); } async commit(message: string, files?: string[]): Promise { @@ -233,7 +467,7 @@ export class GSDTools { if (files?.length) { args.push('--files', ...files); } - return this.execRaw('commit', args); + return this.dispatchNativeRaw('commit', args, 'commit', args); } async verifySummary(path: string): Promise { @@ -249,15 +483,25 @@ export class GSDTools { * Returns a typed PhaseOpInfo describing what exists on disk for this phase. */ async initPhaseOp(phaseNumber: string): Promise { - const result = await this.exec('init', ['phase-op', phaseNumber]); + const result = await this.dispatchNativeJson( + 'init', + ['phase-op', phaseNumber], + 'init.phase-op', + [phaseNumber], + ); return result as PhaseOpInfo; } /** - * Get a config value from gsd-tools.cjs. + * Get a config value via the `config-get` surface (CJS and registry use the same key path). */ async configGet(key: string): Promise { - const result = await this.exec('config', ['get', key]); + const result = await this.dispatchNativeJson( + 'config-get', + [key], + 'config-get', + [key], + ); return result as string | null; } @@ -273,7 +517,12 @@ export class GSDTools { * Returns typed PhasePlanIndex with wave assignments and completion status. */ async phasePlanIndex(phaseNumber: string): Promise { - const result = await this.exec('phase-plan-index', [phaseNumber]); + const result = await this.dispatchNativeJson( + 'phase-plan-index', + [phaseNumber], + 'phase-plan-index', + [phaseNumber], + ); return result as PhasePlanIndex; } @@ -282,7 +531,7 @@ export class GSDTools { * Returns project metadata, model configs, brownfield detection, etc. */ async initNewProject(): Promise { - const result = await this.exec('init', ['new-project']); + const result = await this.dispatchNativeJson('init', ['new-project'], 'init.new-project', []); return result as InitNewProjectInfo; } @@ -292,7 +541,7 @@ export class GSDTools { * Note: config-set returns `key=value` text, not JSON, so we use execRaw. */ async configSet(key: string, value: string): Promise { - return this.execRaw('config-set', [key, value]); + return this.dispatchNativeRaw('config-set', [key, value], 'config-set', [key, value]); } } diff --git a/sdk/src/index.ts b/sdk/src/index.ts index 6687b9c60..c8288df2c 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -120,6 +120,7 @@ export class GSD { projectDir: this.projectDir, gsdToolsPath: this.gsdToolsPath, workstream: this.workstream, + eventStream: this.eventStream, }); } diff --git a/sdk/src/phase-runner-types.test.ts b/sdk/src/phase-runner-types.test.ts index 8951d3aca..17afef5a1 100644 --- a/sdk/src/phase-runner-types.test.ts +++ b/sdk/src/phase-runner-types.test.ts @@ -325,7 +325,7 @@ describe('GSDTools typed methods', () => { `, ); - const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath }); + const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false }); const result = await tools.initPhaseOp('5'); expect(result.phase_found).toBe(true); @@ -346,7 +346,7 @@ describe('GSDTools typed methods', () => { `, ); - const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath }); + const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false }); const result = await tools.initPhaseOp('7') as { received_args: string[] }; expect(result.received_args).toContain('init'); @@ -363,7 +363,7 @@ describe('GSDTools typed methods', () => { 'config-get.cjs', ` const args = process.argv.slice(2); - if (args[0] === 'config' && args[1] === 'get' && args[2] === 'model_profile') { + if (args[0] === 'config-get' && args[1] === 'model_profile') { process.stdout.write(JSON.stringify('balanced')); } else { process.exit(1); @@ -371,7 +371,7 @@ describe('GSDTools typed methods', () => { `, ); - const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath }); + const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false }); const result = await tools.configGet('model_profile'); expect(result).toBe('balanced'); @@ -382,7 +382,7 @@ describe('GSDTools typed methods', () => { 'config-get-null.cjs', ` const args = process.argv.slice(2); - if (args[0] === 'config' && args[1] === 'get') { + if (args[0] === 'config-get' && args[1] === 'nonexistent_key') { process.stdout.write('null'); } else { process.exit(1); @@ -390,7 +390,7 @@ describe('GSDTools typed methods', () => { `, ); - const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath }); + const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false }); const result = await tools.configGet('nonexistent_key'); expect(result).toBeNull(); @@ -412,7 +412,7 @@ describe('GSDTools typed methods', () => { `, ); - const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath }); + const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false }); const result = await tools.stateBeginPhase('3'); expect(result).toBe('ok'); diff --git a/sdk/src/query/normalize-query-command.ts b/sdk/src/query/normalize-query-command.ts new file mode 100644 index 000000000..78b97f546 --- /dev/null +++ b/sdk/src/query/normalize-query-command.ts @@ -0,0 +1,56 @@ +/** + * Normalize `gsd-sdk query ` command tokens to match `createRegistry()` keys. + * + * `gsd-tools` takes a top-level command plus a subcommand (`state json`, `init execute-phase 9`). + * The SDK CLI originally passed only argv[0] as the registry key, so `query state json` dispatched + * `state` (unknown) instead of `state.json`. This module merges the same prefixes gsd-tools nests + * under `runCommand()` so two-token (and longer) invocations resolve to dotted registry names. + */ + +const MERGE_FIRST_WITH_SUBCOMMAND = new Set([ + 'state', + 'template', + 'frontmatter', + 'verify', + 'phase', + 'phases', + 'roadmap', + 'requirements', + 'validate', + 'init', + 'workstream', + 'intel', + 'learnings', + 'uat', + 'todo', + 'milestone', + 'check', + 'detect', + 'route', +]); + +/** + * @param command - First token after `query` (e.g. `state`, `init`, `config-get`) + * @param args - Remaining tokens (flags like `--pick` should already be stripped) + * @returns Registry command string and handler args + */ +export function normalizeQueryCommand(command: string, args: string[]): [string, string[]] { + if (command === 'scaffold') { + return ['phase.scaffold', args]; + } + + if (command === 'state' && args.length === 0) { + return ['state.load', []]; + } + + if (MERGE_FIRST_WITH_SUBCOMMAND.has(command) && args.length > 0) { + const sub = args[0]; + return [`${command}.${sub}`, args.slice(1)]; + } + + if ((command === 'progress' || command === 'stats') && args.length > 0) { + return [`${command}.${args[0]}`, args.slice(1)]; + } + + return [command, args]; +} diff --git a/sdk/src/query/state-project-load.ts b/sdk/src/query/state-project-load.ts new file mode 100644 index 000000000..39ffa203f --- /dev/null +++ b/sdk/src/query/state-project-load.ts @@ -0,0 +1,109 @@ +/** + * `state load` — full project config + STATE.md raw text (CJS `cmdStateLoad`). + * + * Uses the same `loadConfig(cwd)` as `get-shit-done/bin/lib/state.cjs` by resolving + * `core.cjs` next to a shipped/bundled/user `get-shit-done` install (same probe order + * as `resolveGsdToolsPath`). This keeps JSON output **byte-compatible** with + * `node gsd-tools.cjs state load` for monorepo and standard installs. + * + * Distinct from {@link stateJson} (`state json` / `state.json`) which mirrors + * `cmdStateJson` (rebuilt frontmatter only). + */ + +import { readFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { planningPaths } from './helpers.js'; +import type { QueryHandler } from './utils.js'; +import { GSDError, ErrorClassification } from '../errors.js'; + +const BUNDLED_CORE_CJS = fileURLToPath( + new URL('../../../get-shit-done/bin/lib/core.cjs', import.meta.url), +); + +function resolveCoreCjsPath(projectDir: string): string | null { + const candidates = [ + BUNDLED_CORE_CJS, + join(projectDir, '.claude', 'get-shit-done', 'bin', 'lib', 'core.cjs'), + join(homedir(), '.claude', 'get-shit-done', 'bin', 'lib', 'core.cjs'), + ]; + return candidates.find(p => existsSync(p)) ?? null; +} + +function loadConfigCjs(projectDir: string): Record { + const corePath = resolveCoreCjsPath(projectDir); + if (!corePath) { + throw new GSDError( + 'state load: get-shit-done/bin/lib/core.cjs not found. Install GSD (e.g. npm i -g get-shit-done-cc) or clone with get-shit-done next to the SDK.', + ErrorClassification.Blocked, + ); + } + const req = createRequire(import.meta.url); + const { loadConfig } = req(corePath) as { loadConfig: (cwd: string) => Record }; + return loadConfig(projectDir); +} + +/** + * Query handler for `state load` / bare `state` (normalize → `state.load`). + * + * Port of `cmdStateLoad` from `get-shit-done/bin/lib/state.cjs` lines 44–86. + */ +export const stateProjectLoad: QueryHandler = async (_args, projectDir) => { + const config = loadConfigCjs(projectDir); + const planDir = planningPaths(projectDir).planning; + + let stateRaw = ''; + try { + stateRaw = await readFile(join(planDir, 'STATE.md'), 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + throw err; + } + } + + const configExists = existsSync(join(planDir, 'config.json')); + const roadmapExists = existsSync(join(planDir, 'ROADMAP.md')); + const stateExists = stateRaw.length > 0; + + return { + data: { + config, + state_raw: stateRaw, + state_exists: stateExists, + roadmap_exists: roadmapExists, + config_exists: configExists, + }, + }; +}; + +/** + * `--raw` stdout for `state load` (matches CJS `cmdStateLoad` lines 65–83). + */ +export function formatStateLoadRawStdout(data: unknown): string { + const d = data as Record; + const c = d.config as Record | undefined; + if (!c) { + return typeof data === 'string' ? data : JSON.stringify(data, null, 2); + } + const configExists = d.config_exists; + const roadmapExists = d.roadmap_exists; + const stateExists = d.state_exists; + const lines = [ + `model_profile=${c.model_profile}`, + `commit_docs=${c.commit_docs}`, + `branching_strategy=${c.branching_strategy}`, + `phase_branch_template=${c.phase_branch_template}`, + `milestone_branch_template=${c.milestone_branch_template}`, + `parallelization=${c.parallelization}`, + `research=${c.research}`, + `plan_checker=${c.plan_checker}`, + `verifier=${c.verifier}`, + `config_exists=${configExists}`, + `roadmap_exists=${roadmapExists}`, + `state_exists=${stateExists}`, + ]; + return lines.join('\n'); +}