Deepen query dispatch seam with Command Topology Module (#3078)
* Deepen query dispatch seam with command topology module * Stabilize SDK parity defaults and integration test gating * docs(architecture): record pre-project config policy and e2e gate * refactor(query): stop injecting native adapter in CLI dispatch path * fix(config): align workflow auto-chain typing and docs
This commit is contained in:
@@ -8,7 +8,7 @@
|
||||
* or the persistent user preference is true (`active === true`).
|
||||
*/
|
||||
|
||||
import { CONFIG_DEFAULTS, loadConfig } from '../config.js';
|
||||
import { loadConfig } from '../config.js';
|
||||
import type { QueryHandler } from './utils.js';
|
||||
|
||||
export type AutoModeSource = 'auto_chain' | 'auto_advance' | 'both' | 'none';
|
||||
@@ -32,7 +32,6 @@ function resolveSource(
|
||||
export const checkAutoMode: QueryHandler = async (_args, projectDir) => {
|
||||
const config = await loadConfig(projectDir);
|
||||
const wf: Record<string, unknown> = {
|
||||
...CONFIG_DEFAULTS.workflow,
|
||||
...(config.workflow as unknown as Record<string, unknown>),
|
||||
};
|
||||
const autoAdvance = Boolean(wf.auto_advance ?? false);
|
||||
|
||||
28
sdk/src/query/command-topology.test.ts
Normal file
28
sdk/src/query/command-topology.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createRegistry } from './index.js';
|
||||
import { createCommandTopology } from './command-topology.js';
|
||||
|
||||
describe('command-topology', () => {
|
||||
it('resolves native command with adapter', () => {
|
||||
const registry = createRegistry();
|
||||
const topology = createCommandTopology(registry);
|
||||
|
||||
const out = topology.resolve(['state', 'json']);
|
||||
expect(out.kind).toBe('match');
|
||||
if (out.kind !== 'match') throw new Error('expected match');
|
||||
expect(out.canonical).toBe('state.json');
|
||||
expect(out.args).toEqual([]);
|
||||
expect(typeof out.adapter).toBe('function');
|
||||
});
|
||||
|
||||
it('returns no_match with diagnosis', () => {
|
||||
const registry = createRegistry();
|
||||
const topology = createCommandTopology(registry);
|
||||
|
||||
const out = topology.resolve(['unknown-cmd'], true);
|
||||
expect(out.kind).toBe('no_match');
|
||||
if (out.kind !== 'no_match') throw new Error('expected no_match');
|
||||
expect(out.message).toContain('Unknown command');
|
||||
expect(out.attempted.length).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
80
sdk/src/query/command-topology.ts
Normal file
80
sdk/src/query/command-topology.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { QueryRegistry } from './registry.js';
|
||||
import type { QueryHandler } from './utils.js';
|
||||
import { resolveQueryCommand } from './query-command-resolution-strategy.js';
|
||||
import { diagnoseUnknownCommand } from './query-command-diagnosis.js';
|
||||
import { supportsMutationCommand, supportsRawOutputCommand } from './query-policy-capability.js';
|
||||
|
||||
export type CommandTopologyOutputMode = 'json' | 'text' | 'raw';
|
||||
|
||||
export interface CommandTopologyMatch {
|
||||
kind: 'match';
|
||||
canonical: string;
|
||||
args: string[];
|
||||
output_mode: CommandTopologyOutputMode;
|
||||
mutation: boolean;
|
||||
adapter: QueryHandler;
|
||||
}
|
||||
|
||||
export interface CommandTopologyNoMatch {
|
||||
kind: 'no_match';
|
||||
attempted: string[];
|
||||
normalized?: string;
|
||||
hints: string[];
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type CommandTopologyResult = CommandTopologyMatch | CommandTopologyNoMatch;
|
||||
|
||||
export interface CommandTopology {
|
||||
resolve(tokens: string[], fallbackRestricted?: boolean): CommandTopologyResult;
|
||||
}
|
||||
|
||||
export function createCommandTopology(registry: QueryRegistry): CommandTopology {
|
||||
return {
|
||||
resolve(tokens: string[], fallbackRestricted = false): CommandTopologyResult {
|
||||
const command = tokens[0];
|
||||
const args = tokens.slice(1);
|
||||
if (!command) {
|
||||
return {
|
||||
kind: 'no_match',
|
||||
attempted: [],
|
||||
hints: [],
|
||||
message: 'Error: "gsd-sdk query" requires a command',
|
||||
};
|
||||
}
|
||||
|
||||
const matched = resolveQueryCommand(command, args, registry);
|
||||
if (!matched) {
|
||||
const diagnosis = diagnoseUnknownCommand(command, args, registry, fallbackRestricted);
|
||||
return {
|
||||
kind: 'no_match',
|
||||
normalized: diagnosis.normalized,
|
||||
attempted: diagnosis.attempted,
|
||||
hints: diagnosis.hints,
|
||||
message: diagnosis.message,
|
||||
};
|
||||
}
|
||||
|
||||
const adapter = registry.getHandler(matched.cmd);
|
||||
if (!adapter) {
|
||||
const diagnosis = diagnoseUnknownCommand(command, args, registry, fallbackRestricted);
|
||||
return {
|
||||
kind: 'no_match',
|
||||
normalized: diagnosis.normalized,
|
||||
attempted: diagnosis.attempted,
|
||||
hints: diagnosis.hints,
|
||||
message: diagnosis.message,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'match',
|
||||
canonical: matched.cmd,
|
||||
args: matched.args,
|
||||
output_mode: supportsRawOutputCommand(matched.cmd) ? 'raw' : 'json',
|
||||
mutation: supportsMutationCommand(matched.cmd),
|
||||
adapter,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -26,7 +26,6 @@ function workflowBool(v: unknown, defaultVal: boolean): boolean {
|
||||
export const checkConfigGates: QueryHandler = async (args, projectDir) => {
|
||||
const config = await loadConfig(projectDir);
|
||||
const wf: Record<string, unknown> = {
|
||||
...CONFIG_DEFAULTS.workflow,
|
||||
...(config.workflow as unknown as Record<string, unknown>),
|
||||
};
|
||||
const root = config as Record<string, unknown>;
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { GSDError, ErrorClassification } from '../errors.js';
|
||||
import { loadConfig } from '../config.js';
|
||||
@@ -174,6 +175,8 @@ export const resolveModel: QueryHandler = async (args, projectDir, workstream) =
|
||||
throw new GSDError('agent-type required', ErrorClassification.Validation);
|
||||
}
|
||||
|
||||
const configFilePath = planningPaths(projectDir, workstream).config;
|
||||
const configExists = existsSync(configFilePath);
|
||||
const config = await loadConfig(projectDir, workstream);
|
||||
const profile = String(config.model_profile || 'balanced').toLowerCase();
|
||||
|
||||
@@ -188,9 +191,9 @@ export const resolveModel: QueryHandler = async (args, projectDir, workstream) =
|
||||
return { data: result };
|
||||
}
|
||||
|
||||
// resolve_model_ids: "omit" -- return empty string
|
||||
// No project config (or explicit omit policy) -> return empty model id (CJS parity)
|
||||
const resolveModelIds = (config as Record<string, unknown>).resolve_model_ids;
|
||||
if (resolveModelIds === 'omit') {
|
||||
if (!configExists || resolveModelIds === 'omit') {
|
||||
const agentModels = MODEL_PROFILES[agentType];
|
||||
const result = agentModels
|
||||
? { model: '', profile }
|
||||
|
||||
@@ -68,12 +68,9 @@ afterEach(async () => {
|
||||
// ─── skills.ts ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('agentSkills', () => {
|
||||
it('returns valid QueryResult with skills array', async () => {
|
||||
it('returns empty string when agent_skills config is missing', async () => {
|
||||
const result = await agentSkills(['gsd-executor'], tmpDir);
|
||||
const data = result.data as Record<string, unknown>;
|
||||
expect(Array.isArray(data.skills)).toBe(true);
|
||||
expect(typeof data.skill_count).toBe('number');
|
||||
expect(data.agent_type).toBe('gsd-executor');
|
||||
expect(result.data).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -234,9 +234,10 @@ function checkAgentsInstalled(config?: { runtime?: unknown }): { agents_installe
|
||||
*/
|
||||
export const docsInit: QueryHandler = async (_args, projectDir) => {
|
||||
const config = await loadConfig(projectDir);
|
||||
const configExists = existsSync(join(projectDir, '.planning', 'config.json'));
|
||||
const docModelResult = await resolveModel(['gsd-doc-writer'], projectDir);
|
||||
const docWriterData = docModelResult.data as Record<string, unknown>;
|
||||
const doc_writer_model = (docWriterData?.model as string) || 'sonnet';
|
||||
const doc_writer_model = configExists ? ((docWriterData?.model as string) || '') : '';
|
||||
|
||||
const agentStatus = checkAgentsInstalled(config as { runtime?: unknown });
|
||||
|
||||
|
||||
@@ -5,3 +5,5 @@ export { createRegistry, buildRegistry, decorateRegistryMutations, QUERY_MUTATIO
|
||||
export type { QueryResult, QueryHandler } from './utils.js';
|
||||
export { extractField } from './registry.js';
|
||||
export { normalizeQueryCommand } from './query-command-resolution-strategy.js';
|
||||
export { createCommandTopology } from './command-topology.js';
|
||||
export type { CommandTopology, CommandTopologyResult, CommandTopologyMatch, CommandTopologyNoMatch } from './command-topology.js';
|
||||
|
||||
@@ -284,10 +284,13 @@ export const initExecutePhase: QueryHandler = async (args, projectDir, workstrea
|
||||
const { phaseInfo, roadmapPhase } = await getPhaseInfoWithFallback(phase, projectDir, workstream);
|
||||
const phase_req_ids = extractReqIds(roadmapPhase);
|
||||
|
||||
const [executorModel, verifierModel] = await Promise.all([
|
||||
const configExists = existsSync(join(planningDir, 'config.json'));
|
||||
const [executorModelRaw, verifierModelRaw] = await Promise.all([
|
||||
getModelAlias('gsd-executor', projectDir),
|
||||
getModelAlias('gsd-verifier', projectDir),
|
||||
]);
|
||||
const executorModel = configExists ? executorModelRaw : '';
|
||||
const verifierModel = configExists ? verifierModelRaw : '';
|
||||
|
||||
const milestone = await getMilestoneInfo(projectDir, workstream);
|
||||
|
||||
@@ -336,7 +339,7 @@ export const initExecutePhase: QueryHandler = async (args, projectDir, workstrea
|
||||
milestone_slug: generateSlugInternal(milestone.name),
|
||||
state_exists: existsSync(join(planningDir, 'STATE.md')),
|
||||
roadmap_exists: existsSync(join(planningDir, 'ROADMAP.md')),
|
||||
config_exists: existsSync(join(planningDir, 'config.json')),
|
||||
config_exists: configExists,
|
||||
state_path: toPosixPath(relative(projectDir, join(planningDir, 'STATE.md'))),
|
||||
roadmap_path: toPosixPath(relative(projectDir, join(planningDir, 'ROADMAP.md'))),
|
||||
config_path: toPosixPath(relative(projectDir, join(planningDir, 'config.json'))),
|
||||
@@ -363,11 +366,15 @@ export const initPlanPhase: QueryHandler = async (args, projectDir, workstream)
|
||||
const { phaseInfo, roadmapPhase } = await getPhaseInfoWithFallback(phase, projectDir, workstream);
|
||||
const phase_req_ids = extractReqIds(roadmapPhase);
|
||||
|
||||
const [researcherModel, plannerModel, checkerModel] = await Promise.all([
|
||||
const configExists = existsSync(join(planningDir, 'config.json'));
|
||||
const [researcherModelRaw, plannerModelRaw, checkerModelRaw] = await Promise.all([
|
||||
getModelAlias('gsd-phase-researcher', projectDir),
|
||||
getModelAlias('gsd-planner', projectDir),
|
||||
getModelAlias('gsd-plan-checker', projectDir),
|
||||
]);
|
||||
const researcherModel = configExists ? researcherModelRaw : '';
|
||||
const plannerModel = configExists ? plannerModelRaw : '';
|
||||
const checkerModel = configExists ? checkerModelRaw : '';
|
||||
|
||||
const phaseNumber = (phaseInfo?.phase_number as string) || null;
|
||||
const plans = (phaseInfo?.plans || []) as string[];
|
||||
@@ -384,7 +391,7 @@ export const initPlanPhase: QueryHandler = async (args, projectDir, workstream)
|
||||
commit_docs: config.commit_docs,
|
||||
text_mode: config.workflow.text_mode,
|
||||
auto_advance: !!config.workflow.auto_advance,
|
||||
auto_chain_active: !!cfg._auto_chain_active,
|
||||
auto_chain_active: !!config.workflow._auto_chain_active,
|
||||
mode: cfg.mode ?? 'interactive',
|
||||
phase_found: !!phaseInfo,
|
||||
phase_dir: (phaseInfo?.directory as string) ?? null,
|
||||
@@ -512,12 +519,17 @@ export const initQuick: QueryHandler = async (args, projectDir) => {
|
||||
.replace('{slug}', branchSlug)
|
||||
: null;
|
||||
|
||||
const [plannerModel, executorModel, checkerModel, verifierModel] = await Promise.all([
|
||||
const configExists = existsSync(join(planningDir, 'config.json'));
|
||||
const [plannerModelRaw, executorModelRaw, checkerModelRaw, verifierModelRaw] = await Promise.all([
|
||||
getModelAlias('gsd-planner', projectDir),
|
||||
getModelAlias('gsd-executor', projectDir),
|
||||
getModelAlias('gsd-plan-checker', projectDir),
|
||||
getModelAlias('gsd-verifier', projectDir),
|
||||
]);
|
||||
const plannerModel = configExists ? plannerModelRaw : '';
|
||||
const executorModel = configExists ? executorModelRaw : '';
|
||||
const checkerModel = configExists ? checkerModelRaw : '';
|
||||
const verifierModel = configExists ? verifierModelRaw : '';
|
||||
|
||||
const result: Record<string, unknown> = {
|
||||
planner_model: plannerModel,
|
||||
@@ -586,10 +598,13 @@ export const initVerifyWork: QueryHandler = async (args, projectDir) => {
|
||||
const config = await loadConfig(projectDir);
|
||||
const { phaseInfo } = await getPhaseInfoForVerifyWork(phase, projectDir);
|
||||
|
||||
const [plannerModel, checkerModel] = await Promise.all([
|
||||
const configExists = existsSync(join(projectDir, '.planning', 'config.json'));
|
||||
const [plannerModelRaw, checkerModelRaw] = await Promise.all([
|
||||
getModelAlias('gsd-planner', projectDir),
|
||||
getModelAlias('gsd-plan-checker', projectDir),
|
||||
]);
|
||||
const plannerModel = configExists ? plannerModelRaw : '';
|
||||
const checkerModel = configExists ? checkerModelRaw : '';
|
||||
|
||||
const result: Record<string, unknown> = {
|
||||
planner_model: plannerModel,
|
||||
|
||||
@@ -41,9 +41,11 @@ describe('query-cli-adapter', () => {
|
||||
expect(out.stderrLines.join('\n')).toContain('requires a command');
|
||||
});
|
||||
|
||||
it('forwards ws to registry.dispatch via native adapter', async () => {
|
||||
it('passes ws and topology to dispatch without native adapter', async () => {
|
||||
runQueryDispatchSpy.mockImplementationOnce(async (input: any) => {
|
||||
await input.nativeAdapter.dispatch('state', ['show']);
|
||||
expect(input.ws).toBe('alpha');
|
||||
expect(input.topology).toBeDefined();
|
||||
expect(input.nativeAdapter).toBeUndefined();
|
||||
return { ok: true, exit_code: 0, stdout: '', stderr: [] };
|
||||
});
|
||||
|
||||
@@ -52,7 +54,5 @@ describe('query-cli-adapter', () => {
|
||||
ws: 'alpha',
|
||||
queryArgv: ['state', 'show'],
|
||||
});
|
||||
|
||||
expect(dispatchSpy).toHaveBeenCalledWith('state', ['show'], process.cwd(), 'alpha');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createRegistry } from './index.js';
|
||||
import { runQueryDispatch } from './query-dispatch.js';
|
||||
import { resolveGsdToolsPath } from '../gsd-tools.js';
|
||||
import { resolveQueryRuntimeContext } from './query-runtime-context.js';
|
||||
import { createQueryNativeDispatchAdapter } from './query-native-dispatch-adapter.js';
|
||||
import { createCommandTopology } from './command-topology.js';
|
||||
import { buildQueryCliOutputFromDispatch, buildQueryCliOutputFromError, type QueryCliAdapterOutput } from './query-cli-output.js';
|
||||
|
||||
export interface QueryCliAdapterInput {
|
||||
@@ -22,13 +22,14 @@ export async function runQueryCliCommand(input: QueryCliAdapterInput): Promise<Q
|
||||
try {
|
||||
const runtime = resolveQueryRuntimeContext({ projectDir: input.projectDir, ws: input.ws });
|
||||
const registry = createRegistry();
|
||||
const topology = createCommandTopology(registry);
|
||||
const out = await runQueryDispatch({
|
||||
registry,
|
||||
projectDir: runtime.projectDir,
|
||||
ws: runtime.ws,
|
||||
cjsFallbackEnabled: queryFallbackToCjsEnabled(),
|
||||
resolveGsdToolsPath,
|
||||
nativeAdapter: createQueryNativeDispatchAdapter(registry, runtime.projectDir, runtime.ws),
|
||||
topology,
|
||||
}, input.queryArgv ?? []);
|
||||
|
||||
return buildQueryCliOutputFromDispatch(out);
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createRegistry } from './index.js';
|
||||
import { planQueryDispatch } from './query-dispatch-plan.js';
|
||||
import { createCommandTopology } from './command-topology.js';
|
||||
|
||||
describe('query-dispatch-plan', () => {
|
||||
it('selects native mode for registered commands', () => {
|
||||
const registry = createRegistry();
|
||||
const plan = planQueryDispatch(['state', 'json'], registry, true);
|
||||
const plan = planQueryDispatch(['state', 'json'], createCommandTopology(registry), true);
|
||||
expect(plan.mode).toBe('native');
|
||||
expect(plan.normalized.command).toBe('state.json');
|
||||
});
|
||||
|
||||
it('selects cjs mode for unknown command when fallback enabled', () => {
|
||||
const registry = createRegistry();
|
||||
const plan = planQueryDispatch(['unknown-cmd'], registry, true);
|
||||
const plan = planQueryDispatch(['unknown-cmd'], createCommandTopology(registry), true);
|
||||
expect(plan.mode).toBe('cjs');
|
||||
});
|
||||
|
||||
it('selects error mode for unknown command when fallback disabled', () => {
|
||||
const registry = createRegistry();
|
||||
const plan = planQueryDispatch(['unknown-cmd'], registry, false);
|
||||
const plan = planQueryDispatch(['unknown-cmd'], createCommandTopology(registry), false);
|
||||
expect(plan.mode).toBe('error');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import type { QueryRegistry } from './registry.js';
|
||||
import {
|
||||
normalizeQueryCommand,
|
||||
resolveQueryCommand,
|
||||
type QueryCommandResolution,
|
||||
} from './query-command-resolution-strategy.js';
|
||||
import { normalizeQueryCommand } from './query-command-resolution-strategy.js';
|
||||
import type { CommandTopology, CommandTopologyMatch } from './command-topology.js';
|
||||
|
||||
export type DispatchMode = 'native' | 'cjs' | 'error';
|
||||
|
||||
export interface DispatchPlan {
|
||||
mode: DispatchMode;
|
||||
normalized: { command: string; args: string[]; tokens: string[] };
|
||||
matched: QueryCommandResolution | null;
|
||||
matched: CommandTopologyMatch | null;
|
||||
noMatchMessage?: string;
|
||||
noMatchNormalized?: string;
|
||||
noMatchAttempted?: string[];
|
||||
noMatchHints?: string[];
|
||||
}
|
||||
|
||||
export function planQueryDispatch(
|
||||
queryArgv: string[],
|
||||
registry: QueryRegistry,
|
||||
topology: CommandTopology,
|
||||
cjsFallbackEnabled: boolean,
|
||||
): DispatchPlan {
|
||||
const queryCommand = queryArgv[0];
|
||||
@@ -25,12 +25,23 @@ export function planQueryDispatch(
|
||||
|
||||
const [normCmd, normArgs] = normalizeQueryCommand(queryCommand, queryArgv.slice(1));
|
||||
const normalizedTokens = [normCmd, ...normArgs];
|
||||
const matched = resolveQueryCommand(queryCommand, queryArgv.slice(1), registry);
|
||||
if (matched) {
|
||||
return { mode: 'native', normalized: { command: normCmd, args: normArgs, tokens: normalizedTokens }, matched };
|
||||
const resolved = topology.resolve(queryArgv, !cjsFallbackEnabled);
|
||||
|
||||
if (resolved.kind === 'match') {
|
||||
return { mode: 'native', normalized: { command: normCmd, args: normArgs, tokens: normalizedTokens }, matched: resolved };
|
||||
}
|
||||
|
||||
if (cjsFallbackEnabled) {
|
||||
return { mode: 'cjs', normalized: { command: normCmd, args: normArgs, tokens: normalizedTokens }, matched: null };
|
||||
}
|
||||
return { mode: 'error', normalized: { command: normCmd, args: normArgs, tokens: normalizedTokens }, matched: null };
|
||||
|
||||
return {
|
||||
mode: 'error',
|
||||
normalized: { command: normCmd, args: normArgs, tokens: normalizedTokens },
|
||||
matched: null,
|
||||
noMatchMessage: resolved.message,
|
||||
noMatchNormalized: resolved.normalized,
|
||||
noMatchAttempted: resolved.attempted,
|
||||
noMatchHints: resolved.hints,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { createRegistry } from './index.js';
|
||||
import { runQueryDispatch } from './query-dispatch.js';
|
||||
import { createCommandTopology } from './command-topology.js';
|
||||
|
||||
describe('runQueryDispatch', () => {
|
||||
let tmpDir: string;
|
||||
@@ -33,6 +34,7 @@ describe('runQueryDispatch', () => {
|
||||
cjsFallbackEnabled: true,
|
||||
resolveGsdToolsPath: () => '',
|
||||
dispatchNative: async () => ({ data: { ok: true } }),
|
||||
topology: createCommandTopology(registry),
|
||||
}, ['state', 'json']);
|
||||
|
||||
expect(out.ok).toBe(true);
|
||||
@@ -49,6 +51,7 @@ describe('runQueryDispatch', () => {
|
||||
cjsFallbackEnabled: true,
|
||||
resolveGsdToolsPath: () => '',
|
||||
dispatchNative: async () => ({ data: { nested: { value: 7 } } }),
|
||||
topology: createCommandTopology(registry),
|
||||
}, ['state', 'json', '--pick', 'nested.value']);
|
||||
|
||||
expect(out.ok).toBe(true);
|
||||
@@ -65,6 +68,7 @@ describe('runQueryDispatch', () => {
|
||||
cjsFallbackEnabled: false,
|
||||
resolveGsdToolsPath: () => '',
|
||||
dispatchNative: async () => ({ data: {} }),
|
||||
topology: createCommandTopology(registry),
|
||||
}, ['unknown-cmd']);
|
||||
|
||||
expect(out.ok).toBe(false);
|
||||
@@ -84,6 +88,7 @@ describe('runQueryDispatch', () => {
|
||||
cjsFallbackEnabled: true,
|
||||
resolveGsdToolsPath: () => script,
|
||||
dispatchNative: async () => ({ data: {} }),
|
||||
topology: createCommandTopology(registry),
|
||||
}, ['unknown-cmd', '--help']);
|
||||
|
||||
expect(out.ok).toBe(true);
|
||||
@@ -100,6 +105,7 @@ describe('runQueryDispatch', () => {
|
||||
cjsFallbackEnabled: true,
|
||||
resolveGsdToolsPath: () => { throw new Error('path boom'); },
|
||||
dispatchNative: async () => ({ data: {} }),
|
||||
topology: createCommandTopology(registry),
|
||||
}, ['unknown-cmd']);
|
||||
|
||||
expect(out.ok).toBe(false);
|
||||
@@ -118,6 +124,7 @@ describe('runQueryDispatch', () => {
|
||||
cjsFallbackEnabled: true,
|
||||
resolveGsdToolsPath: () => '',
|
||||
dispatchNative: async () => ({ data: {} }),
|
||||
topology: createCommandTopology(registry),
|
||||
}, []);
|
||||
expect(out.ok).toBe(false);
|
||||
if (out.ok) throw new Error('expected failure');
|
||||
@@ -135,6 +142,7 @@ describe('runQueryDispatch', () => {
|
||||
cjsFallbackEnabled: true,
|
||||
resolveGsdToolsPath: () => '',
|
||||
dispatchNative: async () => { throw new Error('gsd-tools timed out after 30000ms: state load'); },
|
||||
topology: createCommandTopology(registry),
|
||||
}, ['state', 'load']);
|
||||
|
||||
expect(out.ok).toBe(false);
|
||||
@@ -152,6 +160,7 @@ describe('runQueryDispatch', () => {
|
||||
cjsFallbackEnabled: true,
|
||||
resolveGsdToolsPath: () => '',
|
||||
dispatchNative: async () => { throw new Error('boom'); },
|
||||
topology: createCommandTopology(registry),
|
||||
}, ['state', 'json']);
|
||||
|
||||
expect(out.ok).toBe(false);
|
||||
|
||||
@@ -3,9 +3,9 @@ import { runCjsFallbackDispatch } from './query-fallback-executor.js';
|
||||
import type { QueryDispatchResult } from './query-dispatch-contract.js';
|
||||
import type { QueryResult } from './utils.js';
|
||||
import type { QueryNativeDispatchAdapter } from './query-native-dispatch-adapter.js';
|
||||
import type { CommandTopology } from './command-topology.js';
|
||||
import { mapFallbackDispatchError, mapNativeDispatchError, toDispatchFailure } from './query-dispatch-error-mapper.js';
|
||||
import { formatSuccess } from './query-dispatch-formatting.js';
|
||||
import { diagnoseUnknownCommand } from './query-command-diagnosis.js';
|
||||
import { unknownCommandError, validationError } from './query-error-taxonomy.js';
|
||||
import { planQueryDispatch } from './query-dispatch-plan.js';
|
||||
import { validateQueryDispatchInput } from './query-dispatch-input-validation.js';
|
||||
@@ -18,24 +18,24 @@ export interface QueryDispatchDeps {
|
||||
ws?: string;
|
||||
cjsFallbackEnabled: boolean;
|
||||
resolveGsdToolsPath: (projectDir: string) => string;
|
||||
/** @deprecated use nativeAdapter */
|
||||
/** @deprecated use topology */
|
||||
dispatchNative?: (cmd: string, args: string[]) => Promise<QueryResult>;
|
||||
/** @deprecated use topology */
|
||||
nativeAdapter?: QueryNativeDispatchAdapter;
|
||||
topology: CommandTopology;
|
||||
}
|
||||
|
||||
|
||||
function fail(error: ReturnType<typeof validationError> | ReturnType<typeof unknownCommandError>, stderr: string[] = []): QueryDispatchResult {
|
||||
return toDispatchFailure(error, stderr);
|
||||
}
|
||||
|
||||
|
||||
export async function runQueryDispatch(deps: QueryDispatchDeps, queryArgv: string[]): Promise<QueryDispatchResult> {
|
||||
const validated = validateQueryDispatchInput(queryArgv);
|
||||
if (validated.error) return validated.error;
|
||||
|
||||
const { queryArgs, pickField } = validated;
|
||||
|
||||
const plan = planQueryDispatch(queryArgs, deps.registry, deps.cjsFallbackEnabled);
|
||||
const plan = planQueryDispatch(queryArgs, deps.topology, deps.cjsFallbackEnabled);
|
||||
const normCmd = plan.normalized.command;
|
||||
const normArgs = plan.normalized.args;
|
||||
|
||||
@@ -44,12 +44,11 @@ export async function runQueryDispatch(deps: QueryDispatchDeps, queryArgv: strin
|
||||
}
|
||||
|
||||
if (plan.mode === 'error') {
|
||||
const diagnosis = diagnoseUnknownCommand(queryArgs[0] ?? normCmd, queryArgs.slice(1), deps.registry, !deps.cjsFallbackEnabled);
|
||||
return fail(unknownCommandError({
|
||||
message: diagnosis.message,
|
||||
normalized: diagnosis.normalized,
|
||||
attempted: diagnosis.attempted,
|
||||
hints: diagnosis.hints,
|
||||
message: plan.noMatchMessage ?? `Error: Unknown command: "${queryArgs[0] ?? normCmd}"`,
|
||||
normalized: plan.noMatchNormalized ?? [normCmd, ...normArgs].join(' ').trim(),
|
||||
attempted: plan.noMatchAttempted ?? [],
|
||||
hints: plan.noMatchHints ?? [],
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -76,18 +75,17 @@ export async function runQueryDispatch(deps: QueryDispatchDeps, queryArgv: strin
|
||||
if (!matched) {
|
||||
return toDispatchFailure(mapFallbackDispatchError(new Error('No native match in dispatch plan'), normCmd, normArgs));
|
||||
}
|
||||
|
||||
const dispatchNative = deps.nativeAdapter
|
||||
? (cmd: string, args: string[]) => deps.nativeAdapter!.dispatch(cmd, args)
|
||||
: deps.dispatchNative;
|
||||
|
||||
if (!dispatchNative) {
|
||||
return toDispatchFailure(mapNativeDispatchError(new Error('Missing native dispatch adapter'), matched.cmd, matched.args));
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await dispatchNative(matched.cmd, matched.args);
|
||||
const result = dispatchNative
|
||||
? await dispatchNative(matched.canonical, matched.args)
|
||||
: await matched.adapter(matched.args, deps.projectDir, deps.ws);
|
||||
return dispatchSuccess(formatSuccess(result.data, result.format, pickField));
|
||||
} catch (e) {
|
||||
return toDispatchFailure(mapNativeDispatchError(e, matched.cmd, matched.args));
|
||||
return toDispatchFailure(mapNativeDispatchError(e, matched.canonical, matched.args));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,7 +174,7 @@ describe('agentSkills CLI stdout', () => {
|
||||
);
|
||||
|
||||
expect(stdout).toBe(
|
||||
'<agent_skills>\nRead these user-configured skills:\n- @.claude/skills/cli-skill/SKILL.md\n</agent_skills>',
|
||||
'<agent_skills>\nRead these user-configured skills:\n- @.claude/skills/cli-skill/SKILL.md\n</agent_skills>\n',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -318,7 +318,7 @@ describe('stateBeginPhase', () => {
|
||||
|
||||
// Must return the actual values, not the flag names
|
||||
expect(data.phase).toBe('99');
|
||||
expect(data.name).toBe('probe-test');
|
||||
expect(data.phase_name).toBe('probe-test');
|
||||
expect(data.plan_count).toBe(1);
|
||||
|
||||
// STATE.md must contain clean output, not literal "--phase"
|
||||
@@ -336,7 +336,7 @@ describe('stateBeginPhase', () => {
|
||||
const result = await stateBeginPhase(['42', 'Positional Test', '5'], tmpDir);
|
||||
const data = result.data as Record<string, unknown>;
|
||||
expect(data.phase).toBe('42');
|
||||
expect(data.name).toBe('Positional Test');
|
||||
expect(data.phase_name).toBe('Positional Test');
|
||||
expect(data.plan_count).toBe(5);
|
||||
});
|
||||
|
||||
|
||||
@@ -341,7 +341,7 @@ export const stateUpdate: QueryHandler = async (args, projectDir, workstream) =>
|
||||
return content;
|
||||
}, workstream);
|
||||
|
||||
return { data: { updated, field, value: updated ? value : undefined } };
|
||||
return { data: { updated } };
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -1250,9 +1250,15 @@ function parseNamedArgs(
|
||||
const result: Record<string, string | boolean | null> = {};
|
||||
for (const flag of valueFlags) {
|
||||
const idx = args.indexOf(`--${flag}`);
|
||||
result[flag] = idx !== -1 && args[idx + 1] !== undefined && !args[idx + 1].startsWith('--')
|
||||
? args[idx + 1]
|
||||
: null;
|
||||
if (idx === -1) {
|
||||
result[flag] = null;
|
||||
continue;
|
||||
}
|
||||
const value = args[idx + 1];
|
||||
if (value === undefined || value.startsWith('--')) {
|
||||
throw new GSDError(`missing value for --${flag}`, ErrorClassification.Validation);
|
||||
}
|
||||
result[flag] = value;
|
||||
}
|
||||
for (const flag of booleanFlags) {
|
||||
result[flag] = args.includes(`--${flag}`);
|
||||
|
||||
Reference in New Issue
Block a user