refactor: deepen GSDTools query execution seams (#3085)

* refactor: deepen gsdtools query execution seams

* docs: add changeset for query seam deepening

* docs: fix changeset summary text

* fix: address coderabbit query seam findings

* test: address remaining coderabbit findings and notes

* refactor: use internal gsdtools error type import
This commit is contained in:
Tom Boucher
2026-05-03 18:56:41 -04:00
committed by GitHub
parent 5e21bf7567
commit 42ed7cee8d
24 changed files with 871 additions and 445 deletions

View File

@@ -10,108 +10,37 @@
* workstream env stays aligned with CJS.
*/
import { execFile } from 'node:child_process';
import { readFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
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 { toGSDToolsError } from './query-tools-error-mapper.js';
import { GSDToolsError } from './gsd-tools-error.js';
import { resolveQueryCommand, type QueryCommandResolution } from './query/query-command-resolution-strategy.js';
import { formatStateLoadRawStdout } from './query/state-project-load.js';
import type { QueryResult } from './query/utils.js';
import { GSDTransport } from './gsd-transport.js';
import { resolveTransportPolicy } from './gsd-transport-policy.js';
import { QueryExecutionPolicy } from './query-execution-policy.js';
import { QueryNativeHotpathAdapter } from './query-native-hotpath-adapter.js';
import { resolveGsdToolsPath } from './query-gsd-tools-path.js';
import { createGSDToolsRuntime } from './query-gsd-tools-runtime.js';
import { QueryCommandExecutor } from './query-command-executor.js';
import { QueryHotpathMethods } from './query-hotpath-methods.js';
// ─── Error type ──────────────────────────────────────────────────────────────
export class GSDToolsError extends Error {
constructor(
message: string,
public readonly command: string,
public readonly args: string[],
public readonly exitCode: number | null,
public readonly stderr: string,
options?: { cause?: unknown },
) {
super(message, options);
this.name = 'GSDToolsError';
}
}
export { GSDToolsError } from './gsd-tools-error.js';
// ─── GSDTools class ──────────────────────────────────────────────────────────
const DEFAULT_TIMEOUT_MS = 30_000;
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<string, unknown>;
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<string, unknown>;
if ((d.updated === true || 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<string, unknown>;
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<typeof createRegistry>;
private readonly registry: ReturnType<typeof createGSDToolsRuntime>['registry'];
private readonly preferNativeQuery: boolean;
private readonly transport: GSDTransport;
private readonly executionPolicy: QueryExecutionPolicy;
private readonly nativeHotpathAdapter: QueryNativeHotpathAdapter;
private readonly commandExecutor: QueryCommandExecutor;
private readonly hotpathMethods: QueryHotpathMethods;
constructor(opts: {
projectDir: string;
@@ -134,16 +63,39 @@ export class GSDTools {
this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
this.workstream = opts.workstream;
this.preferNativeQuery = opts.preferNativeQuery ?? true;
this.registry = createRegistry(opts.eventStream, opts.sessionId);
this.transport = new GSDTransport(this.registry, {
dispatchNative: async (request) => this.withRegistryDispatchTimeout(
request.legacyCommand,
request.legacyArgs,
this.registry.dispatch(request.registryCommand, request.registryArgs, this.projectDir),
) as Promise<QueryResult>,
execSubprocessJson: async (legacyCommand, legacyArgs) => this.execSubprocessJson(legacyCommand, legacyArgs),
execSubprocessRaw: async (legacyCommand, legacyArgs) => this.execSubprocessRaw(legacyCommand, legacyArgs),
formatNativeRaw: (registryCommand, data) => formatRegistryRawStdout(registryCommand, data),
const runtime = createGSDToolsRuntime({
projectDir: this.projectDir,
gsdToolsPath: this.gsdToolsPath,
timeoutMs: this.timeoutMs,
workstream: this.workstream,
eventStream: opts.eventStream,
sessionId: opts.sessionId,
shouldUseNativeQuery: () => this.shouldUseNativeQuery(),
execJsonFallback: (legacyCommand, legacyArgs) => this.exec(legacyCommand, legacyArgs),
execRawFallback: (legacyCommand, legacyArgs) => this.execRaw(legacyCommand, legacyArgs),
});
this.registry = runtime.registry;
this.executionPolicy = runtime.executionPolicy;
this.nativeHotpathAdapter = runtime.nativeHotpathAdapter;
this.commandExecutor = new QueryCommandExecutor({
nativeMatch: (command, args) => this.nativeMatch(command, args),
execute: async (input) => this.executionPolicy.execute({
legacyCommand: input.legacyCommand,
legacyArgs: input.legacyArgs,
registryCommand: input.registryCommand,
registryArgs: input.registryArgs,
mode: input.mode,
projectDir: this.projectDir,
workstream: this.workstream,
preferNativeQuery: this.shouldUseNativeQuery(),
}),
});
this.hotpathMethods = new QueryHotpathMethods({
dispatchNativeHotpath: (legacyCommand, legacyArgs, registryCommand, registryArgs, mode) =>
this.dispatchNativeHotpath(legacyCommand, legacyArgs, registryCommand, registryArgs, mode),
});
}
@@ -156,112 +108,24 @@ export class GSDTools {
}
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,
);
return toGSDToolsError(command, args, err);
}
/**
* Enforce {@link GSDTools.timeoutMs} for in-process registry dispatches so native
* routing cannot hang indefinitely (subprocess path already uses `execFile` timeout).
*/
private async withRegistryDispatchTimeout<T>(
private async dispatchNativeHotpath(
legacyCommand: string,
legacyArgs: string[],
work: Promise<T>,
): Promise<T> {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => {
reject(
new GSDToolsError(
`gsd-tools timed out after ${this.timeoutMs}ms: ${legacyCommand} ${legacyArgs.join(' ')}`,
legacyCommand,
legacyArgs,
null,
'',
),
);
}, this.timeoutMs);
});
try {
// Promise.race rejects when the timeout fires but does not cancel the handler promise;
// native handlers may still run to completion (unlike subprocess + execFile timeout).
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,
registryCommand: string,
registryArgs: string[],
mode: 'json' | 'raw',
): Promise<unknown> {
if (!this.shouldUseNativeQuery()) {
return this.exec(legacyCommand, legacyArgs);
}
try {
const result = await this.withRegistryDispatchTimeout(
return await this.nativeHotpathAdapter.dispatch(
legacyCommand,
legacyArgs,
this.registry.dispatch(registryCmd, registryArgs, this.projectDir),
registryCommand,
registryArgs,
mode,
);
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<string> {
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);
@@ -275,54 +139,14 @@ export class GSDTools {
* Handles the `@file:` prefix pattern for large results.
*/
async exec(command: string, args: string[] = []): Promise<unknown> {
const matched = this.nativeMatch(command, args);
const registryCommand = matched?.cmd ?? command;
const registryArgs = matched?.args ?? args;
const policy = resolveTransportPolicy(registryCommand);
try {
return await this.transport.run({
legacyCommand: command,
legacyArgs: args,
registryCommand,
registryArgs,
mode: 'json',
projectDir: this.projectDir,
workstream: this.workstream,
}, {
preferNative: this.shouldUseNativeQuery() && policy.preferNative,
allowFallbackToSubprocess: policy.allowFallbackToSubprocess,
});
return await this.commandExecutor.exec(command, args, 'json');
} catch (err) {
if (err instanceof GSDToolsError) throw err;
throw this.toToolsError(command, args, err);
}
}
/**
* Parse gsd-tools output, handling `@file:` prefix.
*/
private async parseOutput(raw: string): Promise<unknown> {
const trimmed = raw.trim();
if (trimmed === '') {
return null;
}
let jsonStr = trimmed;
if (jsonStr.startsWith('@file:')) {
const filePath = jsonStr.slice(6).trim();
try {
jsonStr = await readFile(filePath, 'utf-8');
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
throw new Error(`Failed to read gsd-tools @file: indirection at "${filePath}": ${reason}`);
}
}
return JSON.parse(jsonStr);
}
// ─── Raw exec (no JSON parsing) ───────────────────────────────────────
/**
@@ -330,134 +154,14 @@ export class GSDTools {
* Use for commands like `config-set` that return plain text, not JSON.
*/
async execRaw(command: string, args: string[] = []): Promise<string> {
const matched = this.nativeMatch(command, args);
const registryCommand = matched?.cmd ?? command;
const registryArgs = matched?.args ?? args;
const policy = resolveTransportPolicy(registryCommand);
try {
return await this.transport.run({
legacyCommand: command,
legacyArgs: args,
registryCommand,
registryArgs,
mode: 'raw',
projectDir: this.projectDir,
workstream: this.workstream,
}, {
preferNative: this.shouldUseNativeQuery() && policy.preferNative,
allowFallbackToSubprocess: policy.allowFallbackToSubprocess,
}) as string;
return await this.commandExecutor.exec(command, args, 'raw') as string;
} catch (err) {
if (err instanceof GSDToolsError) throw err;
throw this.toToolsError(command, args, err);
}
}
private async execSubprocessJson(command: string, args: string[]): Promise<unknown> {
const wsArgs = this.workstream ? ['--ws', this.workstream] : [];
const fullArgs = [this.gsdToolsPath, command, ...args, ...wsArgs];
return new Promise<unknown>((resolve, reject) => {
const child = execFile(
process.execPath,
fullArgs,
{
cwd: this.projectDir,
maxBuffer: 10 * 1024 * 1024,
timeout: this.timeoutMs,
env: { ...process.env },
},
async (error, stdout, stderr) => {
const stderrStr = stderr?.toString() ?? '';
if (error) {
if (error.killed || (error as NodeJS.ErrnoException).code === 'ETIMEDOUT') {
reject(
new GSDToolsError(
`gsd-tools timed out after ${this.timeoutMs}ms: ${command} ${args.join(' ')}`,
command,
args,
null,
stderrStr,
),
);
return;
}
reject(
new GSDToolsError(
`gsd-tools exited with code ${error.code ?? 'unknown'}: ${command} ${args.join(' ')}${stderrStr ? `\n${stderrStr}` : ''}`,
command,
args,
typeof error.code === 'number' ? error.code : (error as { status?: number }).status ?? 1,
stderrStr,
),
);
return;
}
const raw = stdout?.toString() ?? '';
try {
const parsed = await this.parseOutput(raw);
resolve(parsed);
} catch (parseErr) {
reject(
new GSDToolsError(
`Failed to parse gsd-tools output for "${command}": ${parseErr instanceof Error ? parseErr.message : String(parseErr)}\nRaw output: ${raw.slice(0, 500)}`,
command,
args,
0,
stderrStr,
),
);
}
},
);
child.on('error', (err) => {
reject(new GSDToolsError(`Failed to execute gsd-tools: ${err.message}`, command, args, null, ''));
});
});
}
private async execSubprocessRaw(command: string, args: string[]): Promise<string> {
const wsArgs = this.workstream ? ['--ws', this.workstream] : [];
const fullArgs = [this.gsdToolsPath, command, ...args, ...wsArgs, '--raw'];
return new Promise<string>((resolve, reject) => {
const child = execFile(
process.execPath,
fullArgs,
{
cwd: this.projectDir,
maxBuffer: 10 * 1024 * 1024,
timeout: this.timeoutMs,
env: { ...process.env },
},
(error, stdout, stderr) => {
const stderrStr = stderr?.toString() ?? '';
if (error) {
reject(
new GSDToolsError(
`gsd-tools exited with code ${error.code ?? 'unknown'}: ${command} ${args.join(' ')}${stderrStr ? `\n${stderrStr}` : ''}`,
command,
args,
typeof error.code === 'number' ? error.code : (error as { status?: number }).status ?? 1,
stderrStr,
),
);
return;
}
resolve((stdout?.toString() ?? '').trim());
},
);
child.on('error', (err) => {
reject(new GSDToolsError(`Failed to execute gsd-tools: ${err.message}`, command, args, null, ''));
});
});
}
// ─── Typed convenience methods ─────────────────────────────────────────
@@ -470,15 +174,11 @@ export class GSDTools {
}
async phaseComplete(phase: string): Promise<string> {
return this.dispatchNativeRaw('phase', ['complete', phase], 'phase.complete', [phase]);
return this.hotpathMethods.phaseComplete(phase);
}
async commit(message: string, files?: string[]): Promise<string> {
const args = [message];
if (files?.length) {
args.push('--files', ...files);
}
return this.dispatchNativeRaw('commit', args, 'commit', args);
return this.hotpathMethods.commit(message, files);
}
async verifySummary(path: string): Promise<string> {
@@ -494,26 +194,14 @@ export class GSDTools {
* Returns a typed PhaseOpInfo describing what exists on disk for this phase.
*/
async initPhaseOp(phaseNumber: string): Promise<PhaseOpInfo> {
const result = await this.dispatchNativeJson(
'init',
['phase-op', phaseNumber],
'init.phase-op',
[phaseNumber],
);
return result as PhaseOpInfo;
return this.hotpathMethods.initPhaseOp(phaseNumber);
}
/**
* Get a config value via the `config-get` surface (CJS and registry use the same key path).
*/
async configGet(key: string): Promise<string | null> {
const result = await this.dispatchNativeJson(
'config-get',
[key],
'config-get',
[key],
);
return result as string | null;
return this.hotpathMethods.configGet(key);
}
/**
@@ -528,13 +216,7 @@ export class GSDTools {
* Returns typed PhasePlanIndex with wave assignments and completion status.
*/
async phasePlanIndex(phaseNumber: string): Promise<PhasePlanIndex> {
const result = await this.dispatchNativeJson(
'phase-plan-index',
[phaseNumber],
'phase-plan-index',
[phaseNumber],
);
return result as PhasePlanIndex;
return this.hotpathMethods.phasePlanIndex(phaseNumber);
}
/**
@@ -542,8 +224,7 @@ export class GSDTools {
* Returns project metadata, model configs, brownfield detection, etc.
*/
async initNewProject(): Promise<InitNewProjectInfo> {
const result = await this.dispatchNativeJson('init', ['new-project'], 'init.new-project', []);
return result as InitNewProjectInfo;
return this.hotpathMethods.initNewProject();
}
/**
@@ -552,23 +233,8 @@ export class GSDTools {
* Note: config-set returns `key=value` text, not JSON, so we use execRaw.
*/
async configSet(key: string, value: string): Promise<string> {
return this.dispatchNativeRaw('config-set', [key, value], 'config-set', [key, value]);
return this.hotpathMethods.configSet(key, value);
}
}
// ─── Path resolution ────────────────────────────────────────────────────────
/**
* Resolve gsd-tools.cjs path.
* Probe order: SDK-bundled repo copy → `project/.claude/get-shit-done/` →
* `~/.claude/get-shit-done/`.
*/
export function resolveGsdToolsPath(projectDir: string): string {
const candidates = [
BUNDLED_GSD_TOOLS_PATH,
join(projectDir, '.claude', 'get-shit-done', 'bin', 'gsd-tools.cjs'),
join(homedir(), '.claude', 'get-shit-done', 'bin', 'gsd-tools.cjs'),
];
return candidates.find(candidate => existsSync(candidate)) ?? candidates[candidates.length - 1]!;
}
export { resolveGsdToolsPath } from './query-gsd-tools-path.js';