mirror of
https://github.com/glittercowboy/get-shit-done
synced 2026-04-25 17:25:23 +02:00
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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<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.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 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<T>(
|
||||
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 {
|
||||
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<unknown> {
|
||||
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<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);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 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<unknown> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
return this.execRaw('state', ['load']);
|
||||
return this.dispatchNativeRaw('state', ['load'], 'state.load', []);
|
||||
}
|
||||
|
||||
async roadmapAnalyze(): Promise<RoadmapAnalysis> {
|
||||
@@ -225,7 +459,7 @@ export class GSDTools {
|
||||
}
|
||||
|
||||
async phaseComplete(phase: string): Promise<string> {
|
||||
return this.execRaw('phase', ['complete', phase]);
|
||||
return this.dispatchNativeRaw('phase', ['complete', phase], 'phase.complete', [phase]);
|
||||
}
|
||||
|
||||
async commit(message: string, files?: string[]): Promise<string> {
|
||||
@@ -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<string> {
|
||||
@@ -249,15 +483,25 @@ 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.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<string | null> {
|
||||
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<PhasePlanIndex> {
|
||||
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<InitNewProjectInfo> {
|
||||
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<string> {
|
||||
return this.execRaw('config-set', [key, value]);
|
||||
return this.dispatchNativeRaw('config-set', [key, value], 'config-set', [key, value]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -120,6 +120,7 @@ export class GSD {
|
||||
projectDir: this.projectDir,
|
||||
gsdToolsPath: this.gsdToolsPath,
|
||||
workstream: this.workstream,
|
||||
eventStream: this.eventStream,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
56
sdk/src/query/normalize-query-command.ts
Normal file
56
sdk/src/query/normalize-query-command.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Normalize `gsd-sdk query <argv...>` 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<string>([
|
||||
'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];
|
||||
}
|
||||
109
sdk/src/query/state-project-load.ts
Normal file
109
sdk/src/query/state-project-load.ts
Normal file
@@ -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<string, unknown> {
|
||||
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<string, unknown> };
|
||||
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<string, unknown>;
|
||||
const c = d.config as Record<string, unknown> | 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');
|
||||
}
|
||||
Reference in New Issue
Block a user