refactor(query): extract command catalog seam for registry wiring (#3060)

* refactor(sdk): extract gsdtools transport seam with per-command policy

* refactor(query): centralize registry command catalog wiring

* refactor(query): unify command resolution seam across sdk callers

* fix(sdk): address CodeRabbit transport policy and timeout findings

* refactor(query): extract mutation event mapper seam

* refactor(query): converge mutation and transport policy data

* refactor(query): share fallback orchestration across cli and sdk

* refactor(query): split static registry catalog by domain clusters

* refactor(query): extract mutation event emission decorator seam

* refactor(query): extract alias-family handler catalog module

* refactor(query): extract cjs fallback execution adapter

* refactor(query): deepen command semantics seam

* refactor(query): extract deep dispatch seam

* refactor(query): deepen cjs fallback execution seam

* refactor(query): merge routing plan into dispatch seam

* fix(query): address CodeRabbit review findings on PR #3060

Critical: prevent double-execution race by checking timeout errors
before subprocess fallback (gsd-transport.ts).

Major: fix execRaw() to respect transport policy outputMode instead
of hardcoding 'raw' (gsd-tools.ts).

Major: add explicit 30s timeout to subprocess fallback execution
(query-fallback-executor.ts).

Major: remove raw args from stderr banner to prevent secret leakage
(query-fallback-executor.ts).

Minor: ensure native text output has trailing newline for CLI parity
(query-dispatch.ts).

Update gsd-tools.test.ts to match new execRaw() behavior.

* fix(tests): update CLI integration tests for catalog-based registration

The refactoring moved handler registration from inline registry.register()
calls to catalog-based registration (registerStaticCatalog/registerAliasCatalog).

- gsd-sdk-query-registry-integration.test.cjs: collectRegisteredNames() now
  also scans catalog files for handler names registered via the new system.
- bug-2492-context-coverage-gate.test.cjs: checks for catalog-based
  registration (DECISION_ROUTING_STATIC_CATALOG) instead of inline strings.
- bug-2524-sdk-query-ws-flag.test.cjs: checks for dispatchNative callback
  pattern instead of direct registry.dispatch() call.

* fix(query): address remaining CodeRabbit review findings

- query-command-semantics.ts: guard stats/progress rewrite so option
  tokens (e.g. --pick) are not turned into subcommands, preserving the
  top-level handler dispatch.

- query-dispatch.ts: formatOutput now skips --pick for text-format
  responses (matching CJS fallback behavior) and surfaces a proper error
  when extractField returns undefined instead of silently producing
  'undefined'.

- query-dispatch.ts: fix backwards error message — 'registered' is the
  restrictive policy that disables fallback, not enables it.

- tests/bug-2492-context-coverage-gate.test.cjs: check
  VERIFY_DECISION_STATIC_CATALOG (the correct catalog for plan-gate
  handlers) instead of DECISION_ROUTING_STATIC_CATALOG.

- tests/gsd-sdk-query-registry-integration.test.cjs: resolve catalog
  variable before loading entries so the drift guard checks each
  referenced catalog individually.

* refactor(query): deepen registry assembly module with strict invariants

- extract registry assembly into dedicated module
- split build vs mutation decoration internals
- add strict assembly invariants:
  1) no duplicate keys
  2) alias canonicals must have handlers
  3) mutation commands must be registered
  4) raw-output policy commands must be registered
- slim query index to thin re-export seam
- add focused registry assembly tests
- update drift-guard tests to target new seam

* test(query): add thin-seam coverage for query index re-exports

* fix(query): return structured native dispatch errors + tighten decisions.parse guard

- runQueryDispatch native path now catches adapter errors and returns
  QueryDispatchResult.error instead of throwing.
- preserve legacy CLI exit contract by using code=1 for native dispatch
  failures.
- strengthen bug-2492 guard: decisions.parse assertion now checks
  VERIFY_DECISION_STATIC_CATALOG OR explicit command token.
This commit is contained in:
Tom Boucher
2026-05-03 13:57:32 -04:00
committed by GitHub
parent 0f98952a3d
commit 5975f06b6a
42 changed files with 1773 additions and 948 deletions

View File

@@ -0,0 +1,5 @@
---
type: Changed
pr: 3060
---
**Query mutation event mapping moved to dedicated module** — preserves event payloads while improving registry locality and test surface.

View File

@@ -0,0 +1,5 @@
---
type: Changed
pr: 3060
---
**Alias-family handler maps moved to dedicated catalog module** — keeps command keys/order while reducing createRegistry coupling and improving family-level locality.

View File

@@ -0,0 +1,5 @@
---
type: Changed
pr: 3060
---
**CLI query CJS fallback execution extracted to dedicated adapter module** — preserves logs/help passthrough behavior while improving fallback locality and testability.

View File

@@ -0,0 +1,5 @@
---
type: Changed
pr: 3060
---
**Query mutation event emission now uses a dedicated decorator seam** — preserves fire-and-forget behavior while reducing registry coupling and improving testability.

View File

@@ -0,0 +1,5 @@
---
type: Changed
pr: 3060
---
**Query fallback orchestration now shared** — CLI and SDK query dispatch now use one planning seam for native vs CJS fallback decisions with behavior parity preserved.

View File

@@ -0,0 +1,5 @@
---
type: Changed
pr: 3060
---
**Query/transport policy data now converged in shared module** — mutation and raw-output policy wiring now share one source of truth to reduce drift.

View File

@@ -0,0 +1,5 @@
---
type: Changed
pr: 3060
---
**Query static command registrations now split into domain catalog modules** — preserves command order/strings while improving registry locality and maintenance.

View File

@@ -7,7 +7,6 @@
*/
import { parseArgs } from 'node:util';
import { execFile } from 'node:child_process';
import { readFile } from 'node:fs/promises';
import { resolve, join, isAbsolute } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -284,49 +283,6 @@ function queryFallbackToCjsEnabled(): boolean {
return true;
}
async function parseCliQueryJsonOutput(raw: string, projectDir: string): Promise<unknown> {
const trimmed = raw.trim();
if (trimmed === '') return null;
let jsonStr = trimmed;
if (jsonStr.startsWith('@file:')) {
const rel = jsonStr.slice(6).trim();
const { resolvePathUnderProject } = await import('./query/helpers.js');
const filePath = await resolvePathUnderProject(projectDir, rel);
jsonStr = await readFile(filePath, 'utf-8');
}
return JSON.parse(jsonStr);
}
/** Map registry-style dotted command tokens to gsd-tools.cjs argv (space-separated subcommands). */
function dottedCommandToCjsArgv(normCmd: string, normArgs: string[]): string[] {
if (normCmd.includes('.')) {
return [...normCmd.split('.'), ...normArgs];
}
return [normCmd, ...normArgs];
}
function execGsdToolsCjsQuery(
projectDir: string,
gsdToolsPath: string,
normCmd: string,
normArgs: string[],
ws: string | undefined,
): Promise<{ stdout: string; stderr: string }> {
const cjsArgv = dottedCommandToCjsArgv(normCmd, normArgs);
const wsSuffix = ws ? ['--ws', ws] : [];
const fullArgv = [gsdToolsPath, ...cjsArgv, ...wsSuffix];
return new Promise((resolve, reject) => {
execFile(
process.execPath,
fullArgv,
{ cwd: projectDir, maxBuffer: 10 * 1024 * 1024, env: { ...process.env } },
(err, stdout, stderr) => {
if (err) reject(err);
else resolve({ stdout: stdout?.toString() ?? '', stderr: stderr?.toString() ?? '' });
},
);
});
}
// ─── Main ────────────────────────────────────────────────────────────────────
@@ -389,100 +345,28 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
// ─── Query command ──────────────────────────────────────────────────────
if (args.command === 'query') {
const { createRegistry } = await import('./query/index.js');
const { extractField, resolveQueryArgv } = await import('./query/registry.js');
const { GSDToolsError } = await import('./gsd-tools.js');
const { GSDError, exitCodeFor, ErrorClassification } = await import('./errors.js');
const queryArgs = args.queryArgv ?? [];
// Extract --pick before dispatch
const pickIdx = queryArgs.indexOf('--pick');
let pickField: string | undefined;
if (pickIdx !== -1) {
if (pickIdx + 1 >= queryArgs.length) {
console.error('Error: --pick requires a field name');
process.exitCode = 10;
return;
}
pickField = queryArgs[pickIdx + 1];
queryArgs.splice(pickIdx, 2);
}
if (queryArgs.length === 0 || !queryArgs[0]) {
console.error('Error: "gsd-sdk query" requires a command');
process.exitCode = 10;
return;
}
const { runQueryDispatch } = await import('./query/query-dispatch.js');
const { resolveGsdToolsPath, GSDToolsError } = await import('./gsd-tools.js');
const { GSDError, exitCodeFor } = await import('./errors.js');
try {
const queryCommand = queryArgs[0];
const { normalizeQueryCommand } = await import('./query/normalize-query-command.js');
const [normCmd, normArgs] = normalizeQueryCommand(queryCommand, queryArgs.slice(1));
if (!normCmd || !String(normCmd).trim()) {
console.error('Error: "gsd-sdk query" requires a command');
process.exitCode = 10;
const registry = createRegistry();
const out = await runQueryDispatch({
registry,
projectDir: args.projectDir,
ws: args.ws,
cjsFallbackEnabled: queryFallbackToCjsEnabled(),
resolveGsdToolsPath,
dispatchNative: (cmd, argv) => registry.dispatch(cmd, argv, args.projectDir, args.ws),
}, args.queryArgv ?? []);
for (const line of out.stderr) console.error(line);
if (out.error) {
console.error(out.error.message);
process.exitCode = out.error.code;
return;
}
const registry = createRegistry();
const tokens = [normCmd, ...normArgs];
const matched = resolveQueryArgv(tokens, registry);
if (!matched) {
if (!queryFallbackToCjsEnabled()) {
throw new GSDError(
`Unknown command: "${tokens.join(' ')}". Use a registered \`gsd-sdk query\` subcommand (see sdk/src/query/QUERY-HANDLERS.md) or invoke \`node …/gsd-tools.cjs\` for CJS-only operations. Set GSD_QUERY_FALLBACK=registered (default) to allow automatic fallback.`,
ErrorClassification.Validation,
);
}
const { resolveGsdToolsPath } = await import('./gsd-tools.js');
const gsdPath = resolveGsdToolsPath(args.projectDir);
console.error(
`[gsd-sdk] '${tokens.join(' ')}' not in native registry; falling back to gsd-tools.cjs.`,
);
console.error('[gsd-sdk] Transparent bridge — prefer adding a native handler when parity matters.');
const { stdout, stderr } = await execGsdToolsCjsQuery(
args.projectDir,
gsdPath,
normCmd,
normArgs,
args.ws,
);
if (stderr.trim()) console.error(stderr.trimEnd());
// #3026 CR (Major outside-diff): the gsd-tools.cjs fallback now
// emits plain-text usage on --help / -h with exit 0, instead of
// a JSON object. Wrap the JSON parse in a try/catch and forward
// non-JSON stdout verbatim so subcommand help reaches the user.
// (Previously this path JSON.parsed the help text and threw
// "Unexpected token 'U'" — exitCode=1 — a regression introduced
// alongside the --help passthrough fix.)
let output: unknown;
try {
output = await parseCliQueryJsonOutput(stdout, args.projectDir);
} catch {
if (stdout.trim()) {
process.stdout.write(stdout.endsWith('\n') ? stdout : stdout + '\n');
}
return;
}
if (pickField) {
output = extractField(output, pickField);
}
console.log(JSON.stringify(output, null, 2));
} else {
const result = await registry.dispatch(matched.cmd, matched.args, args.projectDir, args.ws);
let output: unknown = result.data;
if (pickField) {
output = extractField(output, pickField);
}
// Handlers can signal format:'text' to emit a raw string (e.g. agent-skills
// emits an <agent_skills> XML block workflows embed via $(...) substitution).
if (!pickField && result.format === 'text' && typeof output === 'string') {
process.stdout.write(output);
} else {
console.log(JSON.stringify(output, null, 2));
}
}
if (out.stdout) process.stdout.write(out.stdout);
} catch (err) {
if (err instanceof GSDError) {
console.error(`Error: ${err.message}`);

View File

@@ -209,9 +209,9 @@ describe('GSDTools', () => {
'state-load.cjs',
`
const args = process.argv.slice(2);
// Script receives: state load --raw
if (args[0] === 'state' && args[1] === 'load' && args.includes('--raw')) {
process.stdout.write('phase=3\\nstatus=executing');
// Script receives: state load (no --raw when policy is json)
if (args[0] === 'state' && args[1] === 'load') {
process.stdout.write(JSON.stringify({ phase: '3', status: 'executing' }));
} else {
process.stderr.write('unexpected args: ' + args.join(' '));
process.exit(1);
@@ -222,7 +222,7 @@ describe('GSDTools', () => {
const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false });
const result = await tools.stateLoad();
expect(result).toBe('phase=3\nstatus=executing');
expect(result).toEqual({ phase: '3', status: 'executing' });
});
it('commit() passes message and optional files', async () => {

View File

@@ -20,8 +20,7 @@ import type { InitNewProjectInfo, PhaseOpInfo, PhasePlanIndex, RoadmapAnalysis }
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 { resolveQueryCommand, type QueryCommandResolution } from './query/command-resolution.js';
import { formatStateLoadRawStdout } from './query/state-project-load.js';
import type { QueryResult } from './query/utils.js';
import { GSDTransport } from './gsd-transport.js';
@@ -152,10 +151,8 @@ export class GSDTools {
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 nativeMatch(command: string, args: string[]): QueryCommandResolution | null {
return resolveQueryCommand(command, args, this.registry);
}
private toToolsError(command: string, args: string[], err: unknown): GSDToolsError {
@@ -344,7 +341,7 @@ export class GSDTools {
legacyArgs: args,
registryCommand,
registryArgs,
mode: 'raw',
mode: policy.outputMode,
projectDir: this.projectDir,
workstream: this.workstream,
}, {
@@ -565,21 +562,19 @@ export class GSDTools {
*/
export async function runGsdToolsQuery(projectDir: string, queryArgv: string[]): Promise<unknown> {
const { createRegistry } = await import('./query/index.js');
const { resolveQueryArgv } = await import('./query/registry.js');
const { normalizeQueryCommand } = await import('./query/normalize-query-command.js');
const { resolveQueryCommand } = await import('./query/command-resolution.js');
const { GSDError, ErrorClassification } = await import('./errors.js');
if (queryArgv.length === 0 || !queryArgv[0]) {
throw new GSDError('runGsdToolsQuery requires a command', ErrorClassification.Validation);
}
const queryCommand = queryArgv[0];
const [normCmd, normArgs] = normalizeQueryCommand(queryCommand, queryArgv.slice(1));
const registry = createRegistry();
const tokens = [normCmd, ...normArgs];
const matched = resolveQueryArgv(tokens, registry);
const [normCmd, normArgs] = normalizeQueryCommand(queryArgv[0], queryArgv.slice(1));
const matched = resolveQueryCommand(queryArgv[0], queryArgv.slice(1), registry);
if (!matched) {
throw new GSDError(
`Unknown command: "${tokens.join(' ')}". No native handler registered.`,
`Unknown command: "${[normCmd, ...normArgs].join(' ')}". No native handler registered.`,
ErrorClassification.Validation,
);
}

View File

@@ -1,3 +1,5 @@
import { TRANSPORT_RAW_COMMANDS } from './query/policy-convergence.js';
export type TransportMode = 'json' | 'raw';
export interface TransportPolicy {
@@ -12,17 +14,9 @@ const DEFAULT_POLICY: TransportPolicy = {
outputMode: 'json',
};
const BUILTIN_COMMAND_POLICY: Record<string, Partial<TransportPolicy>> = {
// raw stdout contracts
commit: { outputMode: 'raw' },
'config-set': { outputMode: 'raw' },
'verify-summary': { outputMode: 'raw' },
'verify.summary': { outputMode: 'raw' },
'verify summary': { outputMode: 'raw' },
// native-first/hard-fail examples (can expand later)
// 'state.load': { allowFallbackToSubprocess: false, outputMode: 'raw' },
};
const BUILTIN_COMMAND_POLICY: Record<string, Partial<TransportPolicy>> = Object.fromEntries(
TRANSPORT_RAW_COMMANDS.map((command) => [command, { outputMode: 'raw' as const }]),
);
const COMMAND_POLICY_OVERRIDES: Record<string, Partial<TransportPolicy>> = {};

View File

@@ -174,7 +174,6 @@ describe('GSDTransport', () => {
expect(result).toBe('');
expect(adapters.execSubprocessRaw).not.toHaveBeenCalled();
});
it('forces subprocess when workstream present', async () => {
const registry = new QueryRegistry();
registry.register('state.load', async () => ({ data: { ok: true } }));

View File

@@ -50,8 +50,11 @@ export class GSDTransport {
}
return native.data;
} catch (error) {
if (isTimeoutLikeError(error)) throw error;
if (!policy.allowFallbackToSubprocess) throw error;
// Do not subprocess-fallback after a timed-out native dispatch:
// the timeout does not cancel the native handler, so falling through
// would run the same command twice (double-execution race).
if (isTimeoutLikeError(error)) throw error;
}
}

View File

@@ -0,0 +1,31 @@
import type { QueryRegistry } from './registry.js';
import type { QueryHandler } from './utils.js';
export interface AliasCatalogEntry {
canonical: string;
aliases: string[];
}
export function registerAliasCatalog(
registry: QueryRegistry,
aliases: readonly AliasCatalogEntry[],
handlers: Readonly<Record<string, QueryHandler>>,
): void {
for (const entry of aliases) {
const handler = handlers[entry.canonical];
if (!handler) continue;
registry.register(entry.canonical, handler);
for (const alias of entry.aliases) {
registry.register(alias, handler);
}
}
}
export function registerStaticCatalog(
registry: QueryRegistry,
entries: ReadonlyArray<readonly [command: string, handler: QueryHandler]>,
): void {
for (const [command, handler] of entries) {
registry.register(command, handler);
}
}

View File

@@ -0,0 +1,117 @@
import type { QueryHandler } from './utils.js';
import { stateProjectLoad } from './state-project-load.js';
import { stateJson, stateGet } from './state.js';
import {
stateUpdate, statePatch, stateBeginPhase, stateAdvancePlan,
stateRecordMetric, stateUpdateProgress, stateAddDecision,
stateAddBlocker, stateResolveBlocker, stateRecordSession,
stateSignalWaiting, stateSignalResume, statePlannedPhase,
stateValidate, stateSync, statePrune, stateMilestoneSwitch,
stateAddRoadmapEvolution,
} from './state-mutation.js';
import { roadmapAnalyze, roadmapGetPhase, roadmapAnnotateDependencies } from './roadmap.js';
import { roadmapUpdatePlanProgress } from './roadmap-update-plan-progress.js';
import {
verifyPlanStructure, verifyPhaseCompleteness, verifyReferences,
verifyCommits, verifyArtifacts, verifySchemaDrift,
verifyCodebaseDrift,
} from './verify.js';
import { verifyKeyLinks, validateConsistency, validateHealth, validateAgents, validateContext } from './validate.js';
import {
phaseListPlans, phaseListArtifacts,
} from './phase-list-queries.js';
import {
phaseAdd, phaseAddBatch, phaseInsert, phaseRemove, phaseComplete,
phaseScaffold, phaseNextDecimal, phasesList, phasesClear, phasesArchive,
} from './phase-lifecycle.js';
import {
initExecutePhase, initPlanPhase, initNewMilestone, initQuick,
initIngestDocs, initResume, initVerifyWork, initPhaseOp, initTodos,
initMilestoneOp, initMapCodebase, initNewWorkspace,
initListWorkspaces, initRemoveWorkspace,
} from './init.js';
import { initNewProject, initProgress, initManager } from './init-complex.js';
export const FAMILY_HANDLERS: Record<string, Readonly<Record<string, QueryHandler>>> = {
state: {
'state.load': stateProjectLoad,
'state.json': stateJson,
'state.get': stateGet,
'state.update': stateUpdate,
'state.patch': statePatch,
'state.begin-phase': stateBeginPhase,
'state.advance-plan': stateAdvancePlan,
'state.record-metric': stateRecordMetric,
'state.update-progress': stateUpdateProgress,
'state.add-decision': stateAddDecision,
'state.add-blocker': stateAddBlocker,
'state.resolve-blocker': stateResolveBlocker,
'state.record-session': stateRecordSession,
'state.signal-waiting': stateSignalWaiting,
'state.signal-resume': stateSignalResume,
'state.planned-phase': statePlannedPhase,
'state.validate': stateValidate,
'state.sync': stateSync,
'state.prune': statePrune,
'state.milestone-switch': stateMilestoneSwitch,
'state.add-roadmap-evolution': stateAddRoadmapEvolution,
},
roadmap: {
'roadmap.analyze': roadmapAnalyze,
'roadmap.get-phase': roadmapGetPhase,
'roadmap.update-plan-progress': roadmapUpdatePlanProgress,
'roadmap.annotate-dependencies': roadmapAnnotateDependencies,
},
verify: {
'verify.plan-structure': verifyPlanStructure,
'verify.phase-completeness': verifyPhaseCompleteness,
'verify.references': verifyReferences,
'verify.commits': verifyCommits,
'verify.artifacts': verifyArtifacts,
'verify.key-links': verifyKeyLinks,
'verify.schema-drift': verifySchemaDrift,
'verify.codebase-drift': verifyCodebaseDrift,
},
validate: {
'validate.consistency': validateConsistency,
'validate.health': validateHealth,
'validate.agents': validateAgents,
'validate.context': validateContext,
},
phase: {
'phase.list-plans': phaseListPlans,
'phase.list-artifacts': phaseListArtifacts,
'phase.add': phaseAdd,
'phase.add-batch': phaseAddBatch,
'phase.insert': phaseInsert,
'phase.remove': phaseRemove,
'phase.complete': phaseComplete,
'phase.scaffold': phaseScaffold,
'phase.next-decimal': phaseNextDecimal,
},
phases: {
'phases.list': phasesList,
'phases.clear': phasesClear,
'phases.archive': phasesArchive,
},
init: {
'init.execute-phase': initExecutePhase,
'init.plan-phase': initPlanPhase,
'init.new-project': initNewProject,
'init.new-milestone': initNewMilestone,
'init.quick': initQuick,
'init.ingest-docs': initIngestDocs,
'init.resume': initResume,
'init.verify-work': initVerifyWork,
'init.phase-op': initPhaseOp,
'init.todos': initTodos,
'init.milestone-op': initMilestoneOp,
'init.map-codebase': initMapCodebase,
'init.progress': initProgress,
'init.manager': initManager,
'init.new-workspace': initNewWorkspace,
'init.list-workspaces': initListWorkspaces,
'init.remove-workspace': initRemoveWorkspace,
},
};

View File

@@ -0,0 +1,70 @@
import { describe, it, expect } from 'vitest';
import { createRegistry } from './index.js';
import { explainQueryCommandNoMatch, resolveQueryCommand, resolveQueryTokens } from './command-resolution.js';
describe('command resolution', () => {
it('resolves normalized tokens with metadata', () => {
const registry = createRegistry();
const resolved = resolveQueryTokens(['state', 'update', 'status', 'X'], registry);
expect(resolved).toEqual({
cmd: 'state.update',
args: ['status', 'X'],
matchedBy: 'dotted',
expanded: false,
source: 'normalized',
});
});
it('resolves dotted token directly when canonical is registered', () => {
const registry = createRegistry();
const resolved = resolveQueryTokens(['init.execute-phase', '1'], registry);
expect(resolved).toEqual({
cmd: 'init.execute-phase',
args: ['1'],
matchedBy: 'dotted',
expanded: false,
source: 'normalized',
});
});
it('marks expanded source when only spaced command exists', () => {
const registry = {
has(command: string) {
return command === 'init execute-phase';
},
};
const resolved = resolveQueryTokens(['init.execute-phase', '1'], registry);
expect(resolved).toEqual({
cmd: 'init execute-phase',
args: ['1'],
matchedBy: 'spaced',
expanded: true,
source: 'expanded',
});
});
it('resolves from raw command+args using normalize rules', () => {
const registry = createRegistry();
const resolved = resolveQueryCommand('state', ['json'], registry);
expect(resolved?.cmd).toBe('state.json');
expect(resolved?.args).toEqual([]);
expect(resolved?.source).toBe('normalized');
});
it('returns null for unknown command', () => {
const registry = createRegistry();
expect(resolveQueryCommand('totally-unknown', ['x'], registry)).toBeNull();
});
it('returns structured no-match metadata', () => {
const registry = createRegistry();
const noMatch = explainQueryCommandNoMatch('state', ['made-up-op', 'x'], registry);
expect(noMatch.normalized).toEqual({
command: 'state',
args: ['made-up-op', 'x'],
tokens: ['state', 'made-up-op', 'x'],
});
expect(noMatch.attempted.dotted[0]).toBe('state.made-up-op.x');
expect(noMatch.attempted.spaced[0]).toBe('state made-up-op x');
});
});

View File

@@ -0,0 +1,10 @@
export {
resolveQueryCommand,
resolveQueryTokens,
type QueryCommandRegistryLike,
type QueryCommandResolution,
type QueryMatchMode,
type QueryResolutionSource,
explainQueryCommandNoMatch,
type QueryCommandNoMatch,
} from './query-command-semantics.js';

View File

@@ -0,0 +1,106 @@
import type { QueryHandler } from './utils.js';
import { agentSkills } from './skills.js';
import { requirementsMarkComplete } from './roadmap.js';
import { todoMatchPhase, statsJson, statsTable, progressBar, progressTable, listTodos, todoComplete } from './progress.js';
import { milestoneComplete } from './phase-lifecycle.js';
import { summaryExtract, historyDigest } from './summary.js';
import { commitToSubrepo } from './commit.js';
import { workstreamGet, workstreamList, workstreamCreate, workstreamSet, workstreamStatus, workstreamComplete, workstreamProgress } from './workstream.js';
import { docsInit } from './docs-init.js';
import { websearch } from './websearch.js';
import { learningsCopy, learningsQuery, learningsListHandler, learningsPrune, learningsDelete, extractMessages, scanSessions, profileSample, profileQuestionnaire } from './profile.js';
import { skillManifest } from './skill-manifest.js';
import { auditOpen } from './audit-open.js';
import { detectCustomFiles } from './detect-custom-files.js';
import { uatRenderCheckpoint, auditUat } from './uat.js';
import { intelStatus, intelDiff, intelSnapshot, intelValidate, intelQuery, intelExtractExports, intelPatchMeta, intelUpdate } from './intel.js';
import { writeProfile, generateClaudeProfile, generateDevPreferences, generateClaudeMd } from './profile-output.js';
export const DOMAIN_STATIC_CATALOG: ReadonlyArray<readonly [string, QueryHandler]> = [
['agent-skills', agentSkills],
['requirements.mark-complete', requirementsMarkComplete],
['requirements mark-complete', requirementsMarkComplete],
['todo.match-phase', todoMatchPhase],
['todo match-phase', todoMatchPhase],
['list-todos', listTodos],
['list.todos', listTodos],
['todo.complete', todoComplete],
['todo complete', todoComplete],
['milestone.complete', milestoneComplete],
['milestone complete', milestoneComplete],
['summary.extract', summaryExtract],
['summary extract', summaryExtract],
['summary-extract', summaryExtract],
['history.digest', historyDigest],
['history digest', historyDigest],
['history-digest', historyDigest],
['stats', statsJson],
['stats.json', statsJson],
['stats json', statsJson],
['stats.table', statsTable],
['stats table', statsTable],
['commit-to-subrepo', commitToSubrepo],
['progress.bar', progressBar],
['progress bar', progressBar],
['progress.table', progressTable],
['progress table', progressTable],
['workstream.get', workstreamGet],
['workstream get', workstreamGet],
['workstream.list', workstreamList],
['workstream list', workstreamList],
['workstream.create', workstreamCreate],
['workstream create', workstreamCreate],
['workstream.set', workstreamSet],
['workstream set', workstreamSet],
['workstream.status', workstreamStatus],
['workstream status', workstreamStatus],
['workstream.complete', workstreamComplete],
['workstream complete', workstreamComplete],
['workstream.progress', workstreamProgress],
['workstream progress', workstreamProgress],
['docs-init', docsInit],
['websearch', websearch],
['learnings.copy', learningsCopy],
['learnings copy', learningsCopy],
['learnings.query', learningsQuery],
['learnings query', learningsQuery],
['learnings.list', learningsListHandler],
['learnings list', learningsListHandler],
['learnings.prune', learningsPrune],
['learnings prune', learningsPrune],
['learnings.delete', learningsDelete],
['learnings delete', learningsDelete],
['skill-manifest', skillManifest],
['skill manifest', skillManifest],
['audit-open', auditOpen],
['audit open', auditOpen],
['detect-custom-files', detectCustomFiles],
['extract-messages', extractMessages],
['extract.messages', extractMessages],
['audit-uat', auditUat],
['uat.render-checkpoint', uatRenderCheckpoint],
['uat render-checkpoint', uatRenderCheckpoint],
['intel.diff', intelDiff],
['intel diff', intelDiff],
['intel.snapshot', intelSnapshot],
['intel snapshot', intelSnapshot],
['intel.validate', intelValidate],
['intel validate', intelValidate],
['intel.status', intelStatus],
['intel status', intelStatus],
['intel.query', intelQuery],
['intel query', intelQuery],
['intel.extract-exports', intelExtractExports],
['intel extract-exports', intelExtractExports],
['intel.patch-meta', intelPatchMeta],
['intel patch-meta', intelPatchMeta],
['intel.update', intelUpdate],
['intel update', intelUpdate],
['generate-claude-profile', generateClaudeProfile],
['generate-dev-preferences', generateDevPreferences],
['write-profile', writeProfile],
['profile-questionnaire', profileQuestionnaire],
['profile-sample', profileSample],
['scan-sessions', scanSessions],
['generate-claude-md', generateClaudeMd],
] as const;

View File

@@ -0,0 +1,98 @@
import type { QueryHandler } from './utils.js';
import { generateSlug, currentTimestamp } from './utils.js';
import { frontmatterGet } from './frontmatter.js';
import { configGet, configPath, resolveModel } from './config-query.js';
import { stateSnapshot } from './state.js';
import { findPhase, phasePlanIndex } from './phase.js';
import { planTaskStructure } from './plan-task-structure.js';
import { requirementsExtractFromPlans } from './requirements-extract-from-plans.js';
import { progressJson } from './progress.js';
import { frontmatterSet, frontmatterMerge, frontmatterValidate } from './frontmatter-mutation.js';
import { configSet, configSetModelProfile, configNewProject, configEnsureSection } from './config-mutation.js';
import { commit, checkCommit } from './commit.js';
import { templateFill, templateSelect } from './template.js';
import { verifySummary, verifyPathExists } from './verify.js';
import { decisionsParse } from './decisions.js';
import { checkDecisionCoveragePlan, checkDecisionCoverageVerify } from './check-decision-coverage.js';
import { checkConfigGates } from './config-gates.js';
import { checkAutoMode } from './check-auto-mode.js';
import { checkPhaseReady } from './phase-ready.js';
import { routeNextAction } from './route-next-action.js';
import { detectPhaseType } from './detect-phase-type.js';
import { checkCompletion } from './check-completion.js';
import { checkGates } from './check-gates.js';
import { checkVerificationStatus } from './check-verification-status.js';
import { checkShipReady } from './check-ship-ready.js';
export const FOUNDATION_STATIC_CATALOG: ReadonlyArray<readonly [string, QueryHandler]> = [
['generate-slug', generateSlug],
['current-timestamp', currentTimestamp],
['frontmatter.get', frontmatterGet],
['config-get', configGet],
['config-path', configPath],
['resolve-model', resolveModel],
] as const;
export const STATE_SUPPORT_STATIC_CATALOG: ReadonlyArray<readonly [string, QueryHandler]> = [
['state-snapshot', stateSnapshot],
['find-phase', findPhase],
['phase-plan-index', phasePlanIndex],
['plan.task-structure', planTaskStructure],
['plan task-structure', planTaskStructure],
['requirements.extract-from-plans', requirementsExtractFromPlans],
['requirements extract-from-plans', requirementsExtractFromPlans],
] as const;
export const MUTATION_SURFACES_STATIC_CATALOG: ReadonlyArray<readonly [string, QueryHandler]> = [
['progress', progressJson],
['progress.json', progressJson],
['frontmatter.set', frontmatterSet],
['frontmatter.merge', frontmatterMerge],
['frontmatter.validate', frontmatterValidate],
['frontmatter validate', frontmatterValidate],
['config-set', configSet],
['config-set-model-profile', configSetModelProfile],
['config-new-project', configNewProject],
['config-ensure-section', configEnsureSection],
['commit', commit],
['check-commit', checkCommit],
['template.fill', templateFill],
['template.select', templateSelect],
['template select', templateSelect],
] as const;
export const VERIFY_DECISION_STATIC_CATALOG: ReadonlyArray<readonly [string, QueryHandler]> = [
['verify-summary', verifySummary],
['verify.summary', verifySummary],
['verify summary', verifySummary],
['verify-path-exists', verifyPathExists],
['verify.path-exists', verifyPathExists],
['verify path-exists', verifyPathExists],
['decisions.parse', decisionsParse],
['decisions parse', decisionsParse],
['check.decision-coverage-plan', checkDecisionCoveragePlan],
['check decision-coverage-plan', checkDecisionCoveragePlan],
['check.decision-coverage-verify', checkDecisionCoverageVerify],
['check decision-coverage-verify', checkDecisionCoverageVerify],
] as const;
export const DECISION_ROUTING_STATIC_CATALOG: ReadonlyArray<readonly [string, QueryHandler]> = [
['check.config-gates', checkConfigGates],
['check config-gates', checkConfigGates],
['check.auto-mode', checkAutoMode],
['check auto-mode', checkAutoMode],
['check.phase-ready', checkPhaseReady],
['check phase-ready', checkPhaseReady],
['route.next-action', routeNextAction],
['route next-action', routeNextAction],
['detect.phase-type', detectPhaseType],
['detect phase-type', detectPhaseType],
['check.completion', checkCompletion],
['check completion', checkCompletion],
['check.gates', checkGates],
['check gates', checkGates],
['check.verification-status', checkVerificationStatus],
['check verification-status', checkVerificationStatus],
['check.ship-ready', checkShipReady],
['check ship-ready', checkShipReady],
] as const;

View File

@@ -0,0 +1,16 @@
import { describe, it, expect } from 'vitest';
import * as query from './index.js';
describe('query index thin seam', () => {
it('re-exports registry assembly seam', () => {
expect(typeof query.createRegistry).toBe('function');
expect(typeof query.buildRegistry).toBe('function');
expect(typeof query.decorateRegistryMutations).toBe('function');
expect(query.QUERY_MUTATION_COMMANDS).toBeInstanceOf(Set);
});
it('re-exports shared query helpers', () => {
expect(typeof query.extractField).toBe('function');
expect(typeof query.normalizeQueryCommand).toBe('function');
});
});

View File

@@ -1,609 +1,7 @@
/**
* Query module entry point — factory and re-exports.
*
* The `createRegistry()` factory creates a fully-wired `QueryRegistry`
* with all native handlers registered. New handlers are added here
* as they are migrated from gsd-tools.cjs.
*
* @example
* ```typescript
* import { createRegistry } from './query/index.js';
*
* const registry = createRegistry();
* const result = await registry.dispatch('generate-slug', ['My Phase'], projectDir);
* ```
*/
/** Query module entry point — thin seam. */
import { QueryRegistry } from './registry.js';
import { generateSlug, currentTimestamp } from './utils.js';
import { frontmatterGet } from './frontmatter.js';
import { configGet, configPath, resolveModel } from './config-query.js';
import { stateJson, stateGet, stateSnapshot } from './state.js';
import { stateProjectLoad } from './state-project-load.js';
import {
STATE_COMMAND_ALIASES,
STATE_MUTATION_COMMANDS,
VERIFY_COMMAND_ALIASES,
INIT_COMMAND_ALIASES,
PHASE_COMMAND_ALIASES,
PHASE_MUTATION_COMMANDS,
PHASES_COMMAND_ALIASES,
PHASES_MUTATION_COMMANDS,
VALIDATE_COMMAND_ALIASES,
ROADMAP_COMMAND_ALIASES,
ROADMAP_MUTATION_COMMANDS,
} from './command-aliases.generated.js';
import { findPhase, phasePlanIndex } from './phase.js';
import { phaseListPlans, phaseListArtifacts } from './phase-list-queries.js';
import { planTaskStructure } from './plan-task-structure.js';
import { requirementsExtractFromPlans } from './requirements-extract-from-plans.js';
import { roadmapAnalyze, roadmapGetPhase } from './roadmap.js';
import { progressJson } from './progress.js';
import { frontmatterSet, frontmatterMerge, frontmatterValidate } from './frontmatter-mutation.js';
import {
stateUpdate, statePatch, stateBeginPhase, stateAdvancePlan,
stateRecordMetric, stateUpdateProgress, stateAddDecision,
stateAddBlocker, stateResolveBlocker, stateRecordSession,
stateSignalWaiting, stateSignalResume, stateValidate, stateSync, statePrune,
stateMilestoneSwitch, stateAddRoadmapEvolution,
} from './state-mutation.js';
import {
configSet, configSetModelProfile, configNewProject, configEnsureSection,
} from './config-mutation.js';
import { commit, checkCommit } from './commit.js';
import { templateFill, templateSelect } from './template.js';
import { verifyPlanStructure, verifyPhaseCompleteness, verifyArtifacts, verifyCommits, verifyReferences, verifySummary, verifyPathExists } from './verify.js';
import { decisionsParse } from './decisions.js';
import { checkDecisionCoveragePlan, checkDecisionCoverageVerify } from './check-decision-coverage.js';
import { verifyKeyLinks, validateConsistency, validateHealth, validateAgents, validateContext } from './validate.js';
import {
phaseAdd, phaseAddBatch, phaseInsert, phaseRemove, phaseComplete,
phaseScaffold, phasesClear, phasesArchive,
phasesList, phaseNextDecimal,
} from './phase-lifecycle.js';
import {
initExecutePhase, initPlanPhase, initNewMilestone, initQuick,
initResume, initVerifyWork, initPhaseOp, initTodos, initMilestoneOp,
initMapCodebase, initNewWorkspace, initListWorkspaces, initRemoveWorkspace,
initIngestDocs,
} from './init.js';
import { initNewProject, initProgress, initManager } from './init-complex.js';
import { agentSkills } from './skills.js';
import { requirementsMarkComplete, roadmapAnnotateDependencies } from './roadmap.js';
import { roadmapUpdatePlanProgress } from './roadmap-update-plan-progress.js';
import { statePlannedPhase } from './state-mutation.js';
import { verifySchemaDrift, verifyCodebaseDrift } from './verify.js';
import {
todoMatchPhase, statsJson, statsTable, progressBar, progressTable, listTodos, todoComplete,
} from './progress.js';
import { milestoneComplete } from './phase-lifecycle.js';
import { summaryExtract, historyDigest } from './summary.js';
import { commitToSubrepo } from './commit.js';
import {
workstreamGet, workstreamList, workstreamCreate, workstreamSet, workstreamStatus,
workstreamComplete, workstreamProgress,
} from './workstream.js';
import { docsInit } from './docs-init.js';
import { uatRenderCheckpoint, auditUat } from './uat.js';
import { websearch } from './websearch.js';
import {
intelStatus, intelDiff, intelSnapshot, intelValidate, intelQuery,
intelExtractExports, intelPatchMeta, intelUpdate,
} from './intel.js';
import {
learningsCopy, learningsQuery, learningsListHandler, learningsPrune, learningsDelete,
extractMessages, scanSessions, profileSample, profileQuestionnaire,
} from './profile.js';
import {
writeProfile, generateClaudeProfile, generateDevPreferences, generateClaudeMd,
} from './profile-output.js';
import { skillManifest } from './skill-manifest.js';
import { auditOpen } from './audit-open.js';
import { detectCustomFiles } from './detect-custom-files.js';
import { checkConfigGates } from './config-gates.js';
import { checkAutoMode } from './check-auto-mode.js';
import { checkPhaseReady } from './phase-ready.js';
import { routeNextAction } from './route-next-action.js';
import { detectPhaseType } from './detect-phase-type.js';
import { checkCompletion } from './check-completion.js';
import { checkGates } from './check-gates.js';
import { checkVerificationStatus } from './check-verification-status.js';
import { checkShipReady } from './check-ship-ready.js';
import { GSDEventStream } from '../event-stream.js';
import {
GSDEventType,
type GSDEvent,
type GSDStateMutationEvent,
type GSDConfigMutationEvent,
type GSDFrontmatterMutationEvent,
type GSDGitCommitEvent,
type GSDTemplateFillEvent,
} from '../types.js';
import type { QueryHandler, QueryResult } from './utils.js';
// ─── Re-exports ────────────────────────────────────────────────────────────
export { createRegistry, buildRegistry, decorateRegistryMutations, QUERY_MUTATION_COMMANDS } from './registry-assembly.js';
export type { QueryResult, QueryHandler } from './utils.js';
export { extractField } from './registry.js';
/** Same argv normalization as `gsd-sdk query` — use when calling `registry.dispatch()` with CLI-style `command` + `args`. */
export { normalizeQueryCommand } from './normalize-query-command.js';
// ─── Mutation commands set ────────────────────────────────────────────────
/**
* Command names that perform durable writes (disk, git, or global profile store).
* Used to wire event emission after successful dispatch. Both dotted and
* space-delimited aliases must be listed when both exist.
*
* See QUERY-HANDLERS.md for semantics. Init composition handlers are omitted
* (they emit JSON for workflows; agents perform writes).
*/
export const QUERY_MUTATION_COMMANDS = new Set<string>([
...STATE_MUTATION_COMMANDS,
'frontmatter.set', 'frontmatter.merge', 'frontmatter.validate', 'frontmatter validate',
'config-set', 'config-set-model-profile', 'config-new-project', 'config-ensure-section',
'commit', 'check-commit', 'commit-to-subrepo',
'template.fill', 'template.select', 'template select',
...PHASE_MUTATION_COMMANDS,
...PHASES_MUTATION_COMMANDS,
...ROADMAP_MUTATION_COMMANDS,
'requirements.mark-complete', 'requirements mark-complete',
'todo.complete', 'todo complete',
'milestone.complete', 'milestone complete',
'workstream.create', 'workstream.set', 'workstream.complete', 'workstream.progress',
'workstream create', 'workstream set', 'workstream complete', 'workstream progress',
'docs-init',
'learnings.copy', 'learnings copy',
'learnings.prune', 'learnings prune',
'learnings.delete', 'learnings delete',
'intel.snapshot', 'intel.patch-meta', 'intel snapshot', 'intel patch-meta',
'write-profile', 'generate-claude-profile', 'generate-dev-preferences', 'generate-claude-md',
]);
// ─── Event builder ────────────────────────────────────────────────────────
/**
* Build a mutation event based on the command prefix and result.
*
* @param correlationSessionId - Optional session correlation id (from {@link createRegistry})
*/
function buildMutationEvent(
correlationSessionId: string,
cmd: string,
args: string[],
result: QueryResult,
): GSDEvent {
const base = {
timestamp: new Date().toISOString(),
sessionId: correlationSessionId,
};
if (cmd.startsWith('template.') || cmd.startsWith('template ')) {
const data = result.data as Record<string, unknown> | null;
return {
...base,
type: GSDEventType.TemplateFill,
templateType: (data?.template as string) ?? args[0] ?? '',
path: (data?.path as string) ?? args[1] ?? '',
created: (data?.created as boolean) ?? false,
} as GSDTemplateFillEvent;
}
if (cmd === 'commit' || cmd === 'check-commit' || cmd === 'commit-to-subrepo') {
const data = result.data as Record<string, unknown> | null;
return {
...base,
type: GSDEventType.GitCommit,
hash: (data?.hash as string) ?? null,
committed: (data?.committed as boolean) ?? false,
reason: (data?.reason as string) ?? '',
} as GSDGitCommitEvent;
}
if (cmd.startsWith('frontmatter.') || cmd.startsWith('frontmatter ')) {
return {
...base,
type: GSDEventType.FrontmatterMutation,
command: cmd,
file: args[0] ?? '',
fields: args.slice(1),
success: true,
} as GSDFrontmatterMutationEvent;
}
if (cmd.startsWith('config-')) {
return {
...base,
type: GSDEventType.ConfigMutation,
command: cmd,
key: args[0] ?? '',
success: true,
} as GSDConfigMutationEvent;
}
if (cmd.startsWith('validate.') || cmd.startsWith('validate ')) {
return {
...base,
type: GSDEventType.ConfigMutation,
command: cmd,
key: args[0] ?? '',
success: true,
} as GSDConfigMutationEvent;
}
if (cmd.startsWith('phase.') || cmd.startsWith('phase ') || cmd.startsWith('phases.') || cmd.startsWith('phases ')) {
return {
...base,
type: GSDEventType.StateMutation,
command: cmd,
fields: args.slice(0, 2),
success: true,
} as GSDStateMutationEvent;
}
if (cmd.startsWith('state.') || cmd.startsWith('state ')) {
return {
...base,
type: GSDEventType.StateMutation,
command: cmd,
fields: args.slice(0, 2),
success: true,
} as GSDStateMutationEvent;
}
// roadmap, requirements, todo, milestone, workstream, intel, profile, learnings, docs-init
return {
...base,
type: GSDEventType.StateMutation,
command: cmd,
fields: args.slice(0, 2),
success: true,
} as GSDStateMutationEvent;
}
// ─── Factory ───────────────────────────────────────────────────────────────
/**
* Create a fully-wired QueryRegistry with all native handlers registered.
*
* @param eventStream - Optional event stream for mutation event emission
* @param correlationSessionId - Optional session id threaded into mutation-related events
* @returns A QueryRegistry instance with all handlers registered
*/
export function createRegistry(
eventStream?: GSDEventStream,
correlationSessionId?: string,
): QueryRegistry {
const mutationSessionId = correlationSessionId ?? '';
const registry = new QueryRegistry();
registry.register('generate-slug', generateSlug);
registry.register('current-timestamp', currentTimestamp);
registry.register('frontmatter.get', frontmatterGet);
registry.register('config-get', configGet);
registry.register('config-path', configPath);
registry.register('resolve-model', resolveModel);
const stateHandlers: Record<string, QueryHandler> = {
'state.load': stateProjectLoad,
'state.json': stateJson,
'state.get': stateGet,
'state.update': stateUpdate,
'state.patch': statePatch,
'state.begin-phase': stateBeginPhase,
'state.advance-plan': stateAdvancePlan,
'state.record-metric': stateRecordMetric,
'state.update-progress': stateUpdateProgress,
'state.add-decision': stateAddDecision,
'state.add-blocker': stateAddBlocker,
'state.resolve-blocker': stateResolveBlocker,
'state.record-session': stateRecordSession,
'state.signal-waiting': stateSignalWaiting,
'state.signal-resume': stateSignalResume,
'state.planned-phase': statePlannedPhase,
'state.validate': stateValidate,
'state.sync': stateSync,
'state.prune': statePrune,
'state.milestone-switch': stateMilestoneSwitch,
'state.add-roadmap-evolution': stateAddRoadmapEvolution,
};
for (const entry of STATE_COMMAND_ALIASES) {
const handler = stateHandlers[entry.canonical];
if (!handler) continue;
registry.register(entry.canonical, handler);
for (const alias of entry.aliases) {
registry.register(alias, handler);
}
}
registry.register('state-snapshot', stateSnapshot);
registry.register('find-phase', findPhase);
registry.register('phase-plan-index', phasePlanIndex);
registry.register('plan.task-structure', planTaskStructure);
registry.register('plan task-structure', planTaskStructure);
registry.register('requirements.extract-from-plans', requirementsExtractFromPlans);
registry.register('requirements extract-from-plans', requirementsExtractFromPlans);
const roadmapHandlers: Record<string, QueryHandler> = {
'roadmap.analyze': roadmapAnalyze,
'roadmap.get-phase': roadmapGetPhase,
'roadmap.update-plan-progress': roadmapUpdatePlanProgress,
'roadmap.annotate-dependencies': roadmapAnnotateDependencies,
};
for (const entry of ROADMAP_COMMAND_ALIASES) {
const handler = roadmapHandlers[entry.canonical];
if (!handler) continue;
registry.register(entry.canonical, handler);
for (const alias of entry.aliases) {
registry.register(alias, handler);
}
}
registry.register('progress', progressJson);
registry.register('progress.json', progressJson);
// Frontmatter mutation handlers
registry.register('frontmatter.set', frontmatterSet);
registry.register('frontmatter.merge', frontmatterMerge);
registry.register('frontmatter.validate', frontmatterValidate);
registry.register('frontmatter validate', frontmatterValidate);
// Config mutation handlers
registry.register('config-set', configSet);
registry.register('config-set-model-profile', configSetModelProfile);
registry.register('config-new-project', configNewProject);
registry.register('config-ensure-section', configEnsureSection);
// Git commit handlers
registry.register('commit', commit);
registry.register('check-commit', checkCommit);
// Template handlers
registry.register('template.fill', templateFill);
registry.register('template.select', templateSelect);
registry.register('template select', templateSelect);
const verifyHandlers: Record<string, QueryHandler> = {
'verify.plan-structure': verifyPlanStructure,
'verify.phase-completeness': verifyPhaseCompleteness,
'verify.references': verifyReferences,
'verify.commits': verifyCommits,
'verify.artifacts': verifyArtifacts,
'verify.key-links': verifyKeyLinks,
'verify.schema-drift': verifySchemaDrift,
'verify.codebase-drift': verifyCodebaseDrift,
};
for (const entry of VERIFY_COMMAND_ALIASES) {
const handler = verifyHandlers[entry.canonical];
if (!handler) continue;
registry.register(entry.canonical, handler);
for (const alias of entry.aliases) {
registry.register(alias, handler);
}
}
registry.register('verify-summary', verifySummary);
registry.register('verify.summary', verifySummary);
registry.register('verify summary', verifySummary);
registry.register('verify-path-exists', verifyPathExists);
registry.register('verify.path-exists', verifyPathExists);
registry.register('verify path-exists', verifyPathExists);
// Decision coverage gates (issue #2492)
registry.register('decisions.parse', decisionsParse);
registry.register('decisions parse', decisionsParse);
registry.register('check.decision-coverage-plan', checkDecisionCoveragePlan);
registry.register('check decision-coverage-plan', checkDecisionCoveragePlan);
registry.register('check.decision-coverage-verify', checkDecisionCoverageVerify);
registry.register('check decision-coverage-verify', checkDecisionCoverageVerify);
const validateHandlers: Record<string, QueryHandler> = {
'validate.consistency': validateConsistency,
'validate.health': validateHealth,
'validate.agents': validateAgents,
'validate.context': validateContext,
};
for (const entry of VALIDATE_COMMAND_ALIASES) {
const handler = validateHandlers[entry.canonical];
if (!handler) continue;
registry.register(entry.canonical, handler);
for (const alias of entry.aliases) {
registry.register(alias, handler);
}
}
// Decision routing (SDK-only — no `gsd-tools.cjs` mirror yet; see QUERY-HANDLERS.md)
registry.register('check.config-gates', checkConfigGates);
registry.register('check config-gates', checkConfigGates);
registry.register('check.auto-mode', checkAutoMode);
registry.register('check auto-mode', checkAutoMode);
registry.register('check.phase-ready', checkPhaseReady);
registry.register('check phase-ready', checkPhaseReady);
registry.register('route.next-action', routeNextAction);
registry.register('route next-action', routeNextAction);
registry.register('detect.phase-type', detectPhaseType);
registry.register('detect phase-type', detectPhaseType);
registry.register('check.completion', checkCompletion);
registry.register('check completion', checkCompletion);
registry.register('check.gates', checkGates);
registry.register('check gates', checkGates);
registry.register('check.verification-status', checkVerificationStatus);
registry.register('check verification-status', checkVerificationStatus);
registry.register('check.ship-ready', checkShipReady);
registry.register('check ship-ready', checkShipReady);
const phaseHandlers: Record<string, QueryHandler> = {
'phase.list-plans': phaseListPlans,
'phase.list-artifacts': phaseListArtifacts,
'phase.add': phaseAdd,
'phase.add-batch': phaseAddBatch,
'phase.insert': phaseInsert,
'phase.remove': phaseRemove,
'phase.complete': phaseComplete,
'phase.scaffold': phaseScaffold,
'phase.next-decimal': phaseNextDecimal,
};
for (const entry of PHASE_COMMAND_ALIASES) {
const handler = phaseHandlers[entry.canonical];
if (!handler) continue;
registry.register(entry.canonical, handler);
for (const alias of entry.aliases) {
registry.register(alias, handler);
}
}
const phasesHandlers: Record<string, QueryHandler> = {
'phases.list': phasesList,
'phases.clear': phasesClear,
'phases.archive': phasesArchive,
};
for (const entry of PHASES_COMMAND_ALIASES) {
const handler = phasesHandlers[entry.canonical];
if (!handler) continue;
registry.register(entry.canonical, handler);
for (const alias of entry.aliases) {
registry.register(alias, handler);
}
}
const initHandlers: Record<string, QueryHandler> = {
'init.execute-phase': initExecutePhase,
'init.plan-phase': initPlanPhase,
'init.new-project': initNewProject,
'init.new-milestone': initNewMilestone,
'init.quick': initQuick,
'init.ingest-docs': initIngestDocs,
'init.resume': initResume,
'init.verify-work': initVerifyWork,
'init.phase-op': initPhaseOp,
'init.todos': initTodos,
'init.milestone-op': initMilestoneOp,
'init.map-codebase': initMapCodebase,
'init.progress': initProgress,
'init.manager': initManager,
'init.new-workspace': initNewWorkspace,
'init.list-workspaces': initListWorkspaces,
'init.remove-workspace': initRemoveWorkspace,
};
for (const entry of INIT_COMMAND_ALIASES) {
const handler = initHandlers[entry.canonical];
if (!handler) continue;
registry.register(entry.canonical, handler);
for (const alias of entry.aliases) {
registry.register(alias, handler);
}
}
// Domain-specific handlers (fully implemented)
registry.register('agent-skills', agentSkills);
registry.register('requirements.mark-complete', requirementsMarkComplete);
registry.register('requirements mark-complete', requirementsMarkComplete);
registry.register('todo.match-phase', todoMatchPhase);
registry.register('todo match-phase', todoMatchPhase);
registry.register('list-todos', listTodos);
registry.register('list.todos', listTodos);
registry.register('todo.complete', todoComplete);
registry.register('todo complete', todoComplete);
registry.register('milestone.complete', milestoneComplete);
registry.register('milestone complete', milestoneComplete);
registry.register('summary.extract', summaryExtract);
registry.register('summary extract', summaryExtract);
registry.register('summary-extract', summaryExtract);
registry.register('history.digest', historyDigest);
registry.register('history digest', historyDigest);
registry.register('history-digest', historyDigest);
registry.register('stats', statsJson);
registry.register('stats.json', statsJson);
registry.register('stats json', statsJson);
registry.register('stats.table', statsTable);
registry.register('stats table', statsTable);
registry.register('commit-to-subrepo', commitToSubrepo);
registry.register('progress.bar', progressBar);
registry.register('progress bar', progressBar);
registry.register('progress.table', progressTable);
registry.register('progress table', progressTable);
registry.register('workstream.get', workstreamGet);
registry.register('workstream get', workstreamGet);
registry.register('workstream.list', workstreamList);
registry.register('workstream list', workstreamList);
registry.register('workstream.create', workstreamCreate);
registry.register('workstream create', workstreamCreate);
registry.register('workstream.set', workstreamSet);
registry.register('workstream set', workstreamSet);
registry.register('workstream.status', workstreamStatus);
registry.register('workstream status', workstreamStatus);
registry.register('workstream.complete', workstreamComplete);
registry.register('workstream complete', workstreamComplete);
registry.register('workstream.progress', workstreamProgress);
registry.register('workstream progress', workstreamProgress);
registry.register('docs-init', docsInit);
registry.register('websearch', websearch);
registry.register('learnings.copy', learningsCopy);
registry.register('learnings copy', learningsCopy);
registry.register('learnings.query', learningsQuery);
registry.register('learnings query', learningsQuery);
registry.register('learnings.list', learningsListHandler);
registry.register('learnings list', learningsListHandler);
registry.register('learnings.prune', learningsPrune);
registry.register('learnings prune', learningsPrune);
registry.register('learnings.delete', learningsDelete);
registry.register('learnings delete', learningsDelete);
registry.register('skill-manifest', skillManifest);
registry.register('skill manifest', skillManifest);
registry.register('audit-open', auditOpen);
registry.register('audit open', auditOpen);
registry.register('detect-custom-files', detectCustomFiles);
registry.register('extract-messages', extractMessages);
registry.register('extract.messages', extractMessages);
registry.register('audit-uat', auditUat);
registry.register('uat.render-checkpoint', uatRenderCheckpoint);
registry.register('uat render-checkpoint', uatRenderCheckpoint);
registry.register('intel.diff', intelDiff);
registry.register('intel diff', intelDiff);
registry.register('intel.snapshot', intelSnapshot);
registry.register('intel snapshot', intelSnapshot);
registry.register('intel.validate', intelValidate);
registry.register('intel validate', intelValidate);
registry.register('intel.status', intelStatus);
registry.register('intel status', intelStatus);
registry.register('intel.query', intelQuery);
registry.register('intel query', intelQuery);
registry.register('intel.extract-exports', intelExtractExports);
registry.register('intel extract-exports', intelExtractExports);
registry.register('intel.patch-meta', intelPatchMeta);
registry.register('intel patch-meta', intelPatchMeta);
registry.register('intel.update', intelUpdate);
registry.register('intel update', intelUpdate);
registry.register('generate-claude-profile', generateClaudeProfile);
registry.register('generate-dev-preferences', generateDevPreferences);
registry.register('write-profile', writeProfile);
registry.register('profile-questionnaire', profileQuestionnaire);
registry.register('profile-sample', profileSample);
registry.register('scan-sessions', scanSessions);
registry.register('generate-claude-md', generateClaudeMd);
// Wire event emission for mutation commands
if (eventStream) {
for (const cmd of QUERY_MUTATION_COMMANDS) {
const original = registry.getHandler(cmd);
if (original) {
registry.register(cmd, async (args: string[], projectDir: string) => {
const result = await original(args, projectDir);
try {
const event = buildMutationEvent(mutationSessionId, cmd, args, result);
eventStream.emitEvent(event);
} catch {
// T-11-12: Event emission is fire-and-forget; never block mutation success
}
return result;
});
}
}
}
return registry;
}

View File

@@ -0,0 +1,45 @@
import { describe, it, expect, vi } from 'vitest';
import { QueryRegistry } from './registry.js';
import { decorateMutationsWithEvents } from './mutation-event-decorator.js';
describe('decorateMutationsWithEvents', () => {
it('wraps registered mutation handler and emits event', async () => {
const registry = new QueryRegistry();
const eventStream = { emitEvent: vi.fn() } as unknown as import('../event-stream.js').GSDEventStream;
registry.register('template.fill', async () => ({ data: { template: 'phase', path: 'x', created: true } }));
decorateMutationsWithEvents(registry, new Set(['template.fill']), eventStream, 'sid-1');
const result = await registry.dispatch('template.fill', ['phase', 'x'], '/tmp');
expect(result.data).toEqual({ template: 'phase', path: 'x', created: true });
expect(eventStream.emitEvent).toHaveBeenCalledOnce();
});
it('does not throw when event emission fails (fire-and-forget)', async () => {
const registry = new QueryRegistry();
const eventStream = {
emitEvent: vi.fn(() => {
throw new Error('stream down');
}),
} as unknown as import('../event-stream.js').GSDEventStream;
registry.register('state.update', async () => ({ data: { ok: true } }));
decorateMutationsWithEvents(registry, new Set(['state.update']), eventStream, 'sid-2');
const result = await registry.dispatch('state.update', ['k', 'v'], '/tmp');
expect(result.data).toEqual({ ok: true });
expect(eventStream.emitEvent).toHaveBeenCalledOnce();
});
it('skips commands not registered in registry', async () => {
const registry = new QueryRegistry();
const eventStream = { emitEvent: vi.fn() } as unknown as import('../event-stream.js').GSDEventStream;
decorateMutationsWithEvents(registry, new Set(['unknown.command']), eventStream, 'sid-3');
await expect(registry.dispatch('unknown.command', [], '/tmp')).rejects.toThrow('Unknown command');
expect(eventStream.emitEvent).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,37 @@
import type { QueryRegistry } from './registry.js';
import type { GSDEventStream } from '../event-stream.js';
import type { QueryHandler } from './utils.js';
import { buildMutationEvent } from './mutation-event-mapper.js';
export function decorateMutationsWithEvents(
registry: QueryRegistry,
mutationCommands: Set<string>,
eventStream: GSDEventStream,
correlationSessionId: string,
): void {
for (const cmd of mutationCommands) {
const original = registry.getHandler(cmd);
if (!original) continue;
registry.register(cmd, async (args: string[], projectDir: string, workstream?: string) => {
const result = await original(args, projectDir, workstream);
try {
const event = buildMutationEvent(correlationSessionId, cmd, args, result);
eventStream.emitEvent(event);
} catch {
// Event emission is fire-and-forget; never block mutation success
}
return result;
});
}
}
export function countDecoratedMutationHandlers(
registry: QueryRegistry,
mutationCommands: Set<string>,
): number {
let count = 0;
for (const cmd of mutationCommands) {
if (registry.getHandler(cmd)) count++;
}
return count;
}

View File

@@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest';
import { GSDEventType } from '../types.js';
import { buildMutationEvent } from './mutation-event-mapper.js';
describe('buildMutationEvent', () => {
const sid = 'corr-1';
it('maps template family', () => {
const e = buildMutationEvent(sid, 'template.fill', ['project', '/tmp/x'], { data: { created: true } });
expect(e.type).toBe(GSDEventType.TemplateFill);
});
it('maps git family', () => {
const e = buildMutationEvent(sid, 'commit', [], { data: { hash: 'abc', committed: true } });
expect(e.type).toBe(GSDEventType.GitCommit);
});
it('maps frontmatter family', () => {
const e = buildMutationEvent(sid, 'frontmatter.set', ['file.md', 'k=v'], { data: null });
expect(e.type).toBe(GSDEventType.FrontmatterMutation);
});
it('maps config + validate to config mutation', () => {
expect(buildMutationEvent(sid, 'config-set', ['x'], { data: null }).type).toBe(GSDEventType.ConfigMutation);
expect(buildMutationEvent(sid, 'validate.context', ['x'], { data: null }).type).toBe(GSDEventType.ConfigMutation);
});
it('maps phase/state/default to state mutation', () => {
expect(buildMutationEvent(sid, 'phase.add', ['x'], { data: null }).type).toBe(GSDEventType.StateMutation);
expect(buildMutationEvent(sid, 'state.update', ['x'], { data: null }).type).toBe(GSDEventType.StateMutation);
expect(buildMutationEvent(sid, 'roadmap.update-plan-progress', ['x'], { data: null }).type).toBe(GSDEventType.StateMutation);
});
});

View File

@@ -0,0 +1,102 @@
import {
GSDEventType,
type GSDEvent,
type GSDStateMutationEvent,
type GSDConfigMutationEvent,
type GSDFrontmatterMutationEvent,
type GSDGitCommitEvent,
type GSDTemplateFillEvent,
} from '../types.js';
import type { QueryResult } from './utils.js';
interface EventBase {
timestamp: string;
sessionId: string;
}
type EventFamily =
| 'template'
| 'git'
| 'frontmatter'
| 'config'
| 'validate'
| 'phase'
| 'state'
| 'default';
const FAMILY_RULES: Array<{ family: EventFamily; matches: (cmd: string) => boolean }> = [
{ family: 'template', matches: (cmd) => cmd.startsWith('template.') || cmd.startsWith('template ') },
{ family: 'git', matches: (cmd) => cmd === 'commit' || cmd === 'check-commit' || cmd === 'commit-to-subrepo' },
{ family: 'frontmatter', matches: (cmd) => cmd.startsWith('frontmatter.') || cmd.startsWith('frontmatter ') },
{ family: 'config', matches: (cmd) => cmd.startsWith('config-') },
{ family: 'validate', matches: (cmd) => cmd.startsWith('validate.') || cmd.startsWith('validate ') },
{ family: 'phase', matches: (cmd) => cmd.startsWith('phase.') || cmd.startsWith('phase ') || cmd.startsWith('phases.') || cmd.startsWith('phases ') },
{ family: 'state', matches: (cmd) => cmd.startsWith('state.') || cmd.startsWith('state ') },
];
function resolveFamily(cmd: string): EventFamily {
return FAMILY_RULES.find((rule) => rule.matches(cmd))?.family ?? 'default';
}
export function buildMutationEvent(
correlationSessionId: string,
cmd: string,
args: string[],
result: QueryResult,
): GSDEvent {
const base: EventBase = {
timestamp: new Date().toISOString(),
sessionId: correlationSessionId,
};
switch (resolveFamily(cmd)) {
case 'template': {
const data = result.data as Record<string, unknown> | null;
return {
...base,
type: GSDEventType.TemplateFill,
templateType: (data?.template as string) ?? args[0] ?? '',
path: (data?.path as string) ?? args[1] ?? '',
created: (data?.created as boolean) ?? false,
} as GSDTemplateFillEvent;
}
case 'git': {
const data = result.data as Record<string, unknown> | null;
return {
...base,
type: GSDEventType.GitCommit,
hash: (data?.hash as string) ?? null,
committed: (data?.committed as boolean) ?? false,
reason: (data?.reason as string) ?? '',
} as GSDGitCommitEvent;
}
case 'frontmatter':
return {
...base,
type: GSDEventType.FrontmatterMutation,
command: cmd,
file: args[0] ?? '',
fields: args.slice(1),
success: true,
} as GSDFrontmatterMutationEvent;
case 'config':
case 'validate':
return {
...base,
type: GSDEventType.ConfigMutation,
command: cmd,
key: args[0] ?? '',
success: true,
} as GSDConfigMutationEvent;
case 'phase':
case 'state':
case 'default':
return {
...base,
type: GSDEventType.StateMutation,
command: cmd,
fields: args.slice(0, 2),
success: true,
} as GSDStateMutationEvent;
}
}

View File

@@ -1,119 +1 @@
/**
* 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.
*/
import {
STATE_SUBCOMMANDS,
VERIFY_SUBCOMMANDS,
INIT_SUBCOMMANDS,
PHASE_SUBCOMMANDS,
PHASES_SUBCOMMANDS,
VALIDATE_SUBCOMMANDS,
ROADMAP_SUBCOMMANDS,
} from './command-aliases.generated.js';
const MERGE_FIRST_WITH_SUBCOMMAND = new Set<string>([
'state',
'template',
'frontmatter',
'verify',
'phase',
'requirements',
'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 (command === 'state' && args.length > 0) {
const sub = args[0];
if (STATE_SUBCOMMANDS.has(sub)) {
return [`state.${sub}`, args.slice(1)];
}
return [command, args];
}
if (command === 'verify' && args.length > 0) {
const sub = args[0];
if (VERIFY_SUBCOMMANDS.has(sub)) {
return [`verify.${sub}`, args.slice(1)];
}
return [command, args];
}
if (command === 'init' && args.length > 0) {
const sub = args[0];
if (INIT_SUBCOMMANDS.has(sub)) {
return [`init.${sub}`, args.slice(1)];
}
return [command, args];
}
if (command === 'phase' && args.length > 0) {
const sub = args[0];
if (PHASE_SUBCOMMANDS.has(sub)) {
return [`phase.${sub}`, args.slice(1)];
}
return [command, args];
}
if (command === 'phases' && args.length > 0) {
const sub = args[0];
if (PHASES_SUBCOMMANDS.has(sub)) {
return [`phases.${sub}`, args.slice(1)];
}
return [command, args];
}
if (command === 'validate' && args.length > 0) {
const sub = args[0];
if (VALIDATE_SUBCOMMANDS.has(sub)) {
return [`validate.${sub}`, args.slice(1)];
}
return [command, args];
}
if (command === 'roadmap' && args.length > 0) {
const sub = args[0];
if (ROADMAP_SUBCOMMANDS.has(sub)) {
return [`roadmap.${sub}`, args.slice(1)];
}
return [command, args];
}
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];
}
export { normalizeQueryCommand } from './query-command-semantics.js';

View File

@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest';
import { QUERY_MUTATION_COMMAND_LIST, TRANSPORT_RAW_COMMANDS, isQueryMutationCommand } from './policy-convergence.js';
describe('policy convergence', () => {
it('contains expected raw transport aliases', () => {
expect(TRANSPORT_RAW_COMMANDS).toEqual([
'commit',
'config-set',
'verify-summary',
'verify.summary',
'verify summary',
]);
});
it('contains key mutation commands and aliases', () => {
expect(QUERY_MUTATION_COMMAND_LIST).toContain('state.update');
expect(QUERY_MUTATION_COMMAND_LIST).toContain('phase complete');
expect(QUERY_MUTATION_COMMAND_LIST).toContain('roadmap.update-plan-progress');
expect(QUERY_MUTATION_COMMAND_LIST).toContain('workstream.progress');
expect(QUERY_MUTATION_COMMAND_LIST).toContain('learnings prune');
});
it('classifies mutation commands via semantic helper', () => {
expect(isQueryMutationCommand('state.update')).toBe(true);
expect(isQueryMutationCommand('state.json')).toBe(false);
});
});

View File

@@ -0,0 +1,5 @@
export {
QUERY_MUTATION_COMMAND_LIST,
TRANSPORT_RAW_COMMANDS,
isQueryMutationCommand,
} from './query-command-semantics.js';

View File

@@ -0,0 +1,214 @@
import {
STATE_SUBCOMMANDS,
VERIFY_SUBCOMMANDS,
INIT_SUBCOMMANDS,
PHASE_SUBCOMMANDS,
PHASES_SUBCOMMANDS,
VALIDATE_SUBCOMMANDS,
ROADMAP_SUBCOMMANDS,
STATE_MUTATION_COMMANDS,
PHASE_MUTATION_COMMANDS,
PHASES_MUTATION_COMMANDS,
ROADMAP_MUTATION_COMMANDS,
} from './command-aliases.generated.js';
export interface QueryCommandRegistryLike {
has(command: string): boolean;
}
export type QueryMatchMode = 'dotted' | 'spaced';
export type QueryResolutionSource = 'normalized' | 'expanded';
export interface QueryCommandResolution {
cmd: string;
args: string[];
matchedBy: QueryMatchMode;
expanded: boolean;
source: QueryResolutionSource;
}
export interface QueryCommandNoMatch {
normalized: { command: string; args: string[]; tokens: string[] };
attempted: { dotted: string[]; spaced: string[]; expandedTokens: string[] | null };
}
const MERGE_FIRST_WITH_SUBCOMMAND = new Set<string>([
'state',
'template',
'frontmatter',
'verify',
'phase',
'requirements',
'init',
'workstream',
'intel',
'learnings',
'uat',
'todo',
'milestone',
'check',
'detect',
'route',
]);
export const QUERY_MUTATION_COMMAND_LIST: readonly string[] = [
...STATE_MUTATION_COMMANDS,
'frontmatter.set', 'frontmatter.merge', 'frontmatter.validate', 'frontmatter validate',
'config-set', 'config-set-model-profile', 'config-new-project', 'config-ensure-section',
'commit', 'check-commit', 'commit-to-subrepo',
'template.fill', 'template.select', 'template select',
...PHASE_MUTATION_COMMANDS,
...PHASES_MUTATION_COMMANDS,
...ROADMAP_MUTATION_COMMANDS,
'requirements.mark-complete', 'requirements mark-complete',
'todo.complete', 'todo complete',
'milestone.complete', 'milestone complete',
'workstream.create', 'workstream.set', 'workstream.complete', 'workstream.progress',
'workstream create', 'workstream set', 'workstream complete', 'workstream progress',
'docs-init',
'learnings.copy', 'learnings copy',
'learnings.prune', 'learnings prune',
'learnings.delete', 'learnings delete',
'intel.snapshot', 'intel.patch-meta', 'intel snapshot', 'intel patch-meta',
'write-profile', 'generate-claude-profile', 'generate-dev-preferences', 'generate-claude-md',
] as const;
export const TRANSPORT_RAW_COMMANDS: readonly string[] = [
'commit',
'config-set',
'verify-summary',
'verify.summary',
'verify summary',
] as const;
const QUERY_MUTATION_COMMAND_SET = new Set(QUERY_MUTATION_COMMAND_LIST);
export function isQueryMutationCommand(command: string): boolean {
return QUERY_MUTATION_COMMAND_SET.has(command);
}
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 (command === 'state' && args.length > 0) {
const sub = args[0];
if (STATE_SUBCOMMANDS.has(sub)) return [`state.${sub}`, args.slice(1)];
return [command, args];
}
if (command === 'verify' && args.length > 0) {
const sub = args[0];
if (VERIFY_SUBCOMMANDS.has(sub)) return [`verify.${sub}`, args.slice(1)];
return [command, args];
}
if (command === 'init' && args.length > 0) {
const sub = args[0];
if (INIT_SUBCOMMANDS.has(sub)) return [`init.${sub}`, args.slice(1)];
return [command, args];
}
if (command === 'phase' && args.length > 0) {
const sub = args[0];
if (PHASE_SUBCOMMANDS.has(sub)) return [`phase.${sub}`, args.slice(1)];
return [command, args];
}
if (command === 'phases' && args.length > 0) {
const sub = args[0];
if (PHASES_SUBCOMMANDS.has(sub)) return [`phases.${sub}`, args.slice(1)];
return [command, args];
}
if (command === 'validate' && args.length > 0) {
const sub = args[0];
if (VALIDATE_SUBCOMMANDS.has(sub)) return [`validate.${sub}`, args.slice(1)];
return [command, args];
}
if (command === 'roadmap' && args.length > 0) {
const sub = args[0];
if (ROADMAP_SUBCOMMANDS.has(sub)) return [`roadmap.${sub}`, args.slice(1)];
return [command, args];
}
if (MERGE_FIRST_WITH_SUBCOMMAND.has(command) && args.length > 0) {
return [`${command}.${args[0]}`, args.slice(1)];
}
if ((command === 'progress' || command === 'stats') && args.length > 0 && !args[0].startsWith('-')) {
return [`${command}.${args[0]}`, args.slice(1)];
}
return [command, args];
}
function expandFirstDottedToken(tokens: string[]): string[] {
if (tokens.length === 0) return tokens;
const first = tokens[0];
if (first.startsWith('--') || !first.includes('.')) return tokens;
return [...first.split('.'), ...tokens.slice(1)];
}
function matchRegisteredPrefix(
tokens: string[],
registry: QueryCommandRegistryLike,
track?: { dotted: string[]; spaced: string[] },
): { cmd: string; args: string[]; matchedBy: QueryMatchMode } | null {
for (let i = tokens.length; i >= 1; i--) {
const head = tokens.slice(0, i);
const dotted = head.join('.');
const spaced = head.join(' ');
track?.dotted.push(dotted);
track?.spaced.push(spaced);
if (registry.has(dotted)) return { cmd: dotted, args: tokens.slice(i), matchedBy: 'dotted' };
if (registry.has(spaced)) return { cmd: spaced, args: tokens.slice(i), matchedBy: 'spaced' };
}
return null;
}
export function resolveQueryTokens(
tokens: string[],
registry: QueryCommandRegistryLike,
): QueryCommandResolution | null {
const direct = matchRegisteredPrefix(tokens, registry);
if (direct) return { ...direct, expanded: false, source: 'normalized' };
const expanded = expandFirstDottedToken(tokens);
if (expanded !== tokens) {
const afterExpand = matchRegisteredPrefix(expanded, registry);
if (afterExpand) return { ...afterExpand, expanded: true, source: 'expanded' };
}
return null;
}
export function resolveQueryCommand(
command: string,
args: string[],
registry: QueryCommandRegistryLike,
): QueryCommandResolution | null {
const [normCmd, normArgs] = normalizeQueryCommand(command, args);
return resolveQueryTokens([normCmd, ...normArgs], registry);
}
export function explainQueryCommandNoMatch(
command: string,
args: string[],
registry: QueryCommandRegistryLike,
): QueryCommandNoMatch {
const [normalizedCommand, normalizedArgs] = normalizeQueryCommand(command, args);
const normalizedTokens = [normalizedCommand, ...normalizedArgs];
const attempted = { dotted: [] as string[], spaced: [] as string[] };
matchRegisteredPrefix(normalizedTokens, registry, attempted);
const expandedTokens = expandFirstDottedToken(normalizedTokens);
if (expandedTokens !== normalizedTokens) {
matchRegisteredPrefix(expandedTokens, registry, attempted);
}
return {
normalized: {
command: normalizedCommand,
args: normalizedArgs,
tokens: normalizedTokens,
},
attempted: {
dotted: attempted.dotted,
spaced: attempted.spaced,
expandedTokens: expandedTokens !== normalizedTokens ? expandedTokens : null,
},
};
}

View File

@@ -0,0 +1,10 @@
export interface QueryDispatchError {
code: number;
message: string;
}
export interface QueryDispatchResult {
stdout?: string;
stderr: string[];
error?: QueryDispatchError;
}

View File

@@ -0,0 +1,99 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdir, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createRegistry } from './index.js';
import { runQueryDispatch } from './query-dispatch.js';
describe('runQueryDispatch', () => {
let tmpDir: string;
let fixtureDir: string;
beforeEach(async () => {
tmpDir = join(tmpdir(), `query-dispatch-${Date.now()}-${Math.random().toString(36).slice(2)}`);
fixtureDir = join(tmpDir, 'fixtures');
await mkdir(fixtureDir, { recursive: true });
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
async function createScript(name: string, code: string): Promise<string> {
const scriptPath = join(fixtureDir, name);
await writeFile(scriptPath, code, { mode: 0o755 });
return scriptPath;
}
it('runs native dispatch and formats json', async () => {
const registry = createRegistry();
const out = await runQueryDispatch({
registry,
projectDir: tmpDir,
cjsFallbackEnabled: true,
resolveGsdToolsPath: () => '',
dispatchNative: async () => ({ data: { ok: true } }),
}, ['state', 'json']);
expect(out.error).toBeUndefined();
expect(out.stdout).toBe('{\n "ok": true\n}\n');
});
it('applies --pick to native json output', async () => {
const registry = createRegistry();
const out = await runQueryDispatch({
registry,
projectDir: tmpDir,
cjsFallbackEnabled: true,
resolveGsdToolsPath: () => '',
dispatchNative: async () => ({ data: { nested: { value: 7 } } }),
}, ['state', 'json', '--pick', 'nested.value']);
expect(out.error).toBeUndefined();
expect(out.stdout).toBe('7\n');
});
it('returns structured error for unknown command when fallback disabled', async () => {
const registry = createRegistry();
const out = await runQueryDispatch({
registry,
projectDir: tmpDir,
cjsFallbackEnabled: false,
resolveGsdToolsPath: () => '',
dispatchNative: async () => ({ data: {} }),
}, ['unknown-cmd']);
expect(out.error?.code).toBe(10);
expect(out.error?.message).toContain('Unknown command: "unknown-cmd"');
expect(out.error?.message).toContain('Attempted dotted:');
});
it('runs cjs fallback and formats text mode', async () => {
const script = await createScript('text.cjs', "process.stdout.write('USAGE: help text');");
const registry = createRegistry();
const out = await runQueryDispatch({
registry,
projectDir: tmpDir,
cjsFallbackEnabled: true,
resolveGsdToolsPath: () => script,
dispatchNative: async () => ({ data: {} }),
}, ['unknown-cmd', '--help']);
expect(out.error).toBeUndefined();
expect(out.stdout).toBe('USAGE: help text\n');
expect(out.stderr[0]).toContain('falling back to gsd-tools.cjs');
});
it('returns requires-command error for empty argv', async () => {
const registry = createRegistry();
const out = await runQueryDispatch({
registry,
projectDir: tmpDir,
cjsFallbackEnabled: true,
resolveGsdToolsPath: () => '',
dispatchNative: async () => ({ data: {} }),
}, []);
expect(out.error?.code).toBe(10);
expect(out.error?.message).toContain('requires a command');
});
});

View File

@@ -0,0 +1,125 @@
import type { QueryRegistry } from './registry.js';
import { extractField } from './registry.js';
import { normalizeQueryCommand } from './normalize-query-command.js';
import { explainQueryCommandNoMatch, resolveQueryCommand, type QueryCommandResolution } from './command-resolution.js';
import { runCjsFallbackDispatch } from './query-fallback-executor.js';
import type { QueryResult } from './utils.js';
import type { QueryDispatchError, QueryDispatchResult } from './query-dispatch-contract.js';
export interface QueryDispatchDeps {
registry: QueryRegistry;
projectDir: string;
ws?: string;
cjsFallbackEnabled: boolean;
resolveGsdToolsPath: (projectDir: string) => string;
dispatchNative: (cmd: string, args: string[]) => Promise<QueryResult>;
}
type DispatchMode = 'native' | 'cjs' | 'error';
interface DispatchPlan {
mode: DispatchMode;
normalized: { command: string; args: string[]; tokens: string[] };
matched: QueryCommandResolution | null;
}
function planQueryDispatch(queryArgv: string[], registry: QueryRegistry, cjsFallbackEnabled: boolean): DispatchPlan {
const queryCommand = queryArgv[0];
if (!queryCommand) {
return { mode: 'error', normalized: { command: '', args: [], tokens: [] }, matched: null };
}
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 };
}
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 };
}
function extractPick(queryArgv: string[]): { queryArgs: string[]; pickField?: string; error?: QueryDispatchError } {
const queryArgs = [...queryArgv];
const pickIdx = queryArgs.indexOf('--pick');
if (pickIdx === -1) return { queryArgs };
if (pickIdx + 1 >= queryArgs.length) {
return {
queryArgs,
error: { code: 10, message: 'Error: --pick requires a field name' },
};
}
const pickField = queryArgs[pickIdx + 1];
queryArgs.splice(pickIdx, 2);
return { queryArgs, pickField };
}
function formatOutput(data: unknown, format: QueryResult['format'], pickField?: string): string {
// Text-format responses ignore --pick to match CJS fallback behavior.
if (format === 'text' && typeof data === 'string') {
return data.endsWith('\n') ? data : `${data}\n`;
}
let output: unknown = data;
if (pickField) output = extractField(output, pickField);
return `${JSON.stringify(output, null, 2)}\n`;
}
export async function runQueryDispatch(deps: QueryDispatchDeps, queryArgv: string[]): Promise<QueryDispatchResult> {
const picked = extractPick(queryArgv);
if (picked.error) return { stderr: [], error: picked.error };
const { queryArgs, pickField } = picked;
if (queryArgs.length === 0 || !queryArgs[0]) {
return { stderr: [], error: { code: 10, message: 'Error: "gsd-sdk query" requires a command' } };
}
const plan = planQueryDispatch(queryArgs, deps.registry, deps.cjsFallbackEnabled);
const normCmd = plan.normalized.command;
const normArgs = plan.normalized.args;
if (!normCmd || !String(normCmd).trim()) {
return { stderr: [], error: { code: 10, message: 'Error: "gsd-sdk query" requires a command' } };
}
if (plan.mode === 'error') {
const noMatch = queryArgs[0]
? explainQueryCommandNoMatch(queryArgs[0], queryArgs.slice(1), deps.registry)
: null;
return {
stderr: [],
error: {
code: 10,
message: `Error: Unknown command: "${[normCmd, ...normArgs].join(' ')}". Use a registered \`gsd-sdk query\` subcommand (see sdk/src/query/QUERY-HANDLERS.md) or invoke \`node …/gsd-tools.cjs\` for CJS-only operations. CJS fallback is disabled (GSD_QUERY_FALLBACK=registered). To enable fallback, unset GSD_QUERY_FALLBACK or set it to a non-restricted value.${noMatch ? ` Attempted dotted: ${noMatch.attempted.dotted.slice(0, 2).join(' | ')}.` : ''}`,
},
};
}
if (plan.mode === 'cjs') {
const gsdPath = deps.resolveGsdToolsPath(deps.projectDir);
return runCjsFallbackDispatch({
projectDir: deps.projectDir,
gsdToolsPath: gsdPath,
normCmd,
normArgs,
ws: deps.ws,
pickField,
});
}
const matched = plan.matched!;
try {
const result = await deps.dispatchNative(matched.cmd, matched.args);
return {
stderr: [],
stdout: formatOutput(result.data, result.format, pickField),
};
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return {
stderr: [],
error: { code: 1, message: `Error: ${msg}` },
};
}
}

View File

@@ -0,0 +1,72 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdir, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { runCjsFallbackDispatch } from './query-fallback-executor.js';
describe('runCjsFallbackDispatch', () => {
let tmpDir: string;
let fixtureDir: string;
beforeEach(async () => {
tmpDir = join(tmpdir(), `fallback-exec-${Date.now()}-${Math.random().toString(36).slice(2)}`);
fixtureDir = join(tmpDir, 'fixtures');
await mkdir(fixtureDir, { recursive: true });
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
async function createScript(name: string, code: string): Promise<string> {
const scriptPath = join(fixtureDir, name);
await writeFile(scriptPath, code, { mode: 0o755 });
return scriptPath;
}
it('returns json output', async () => {
const script = await createScript('json.cjs', "process.stdout.write(JSON.stringify({ok:true}));");
const result = await runCjsFallbackDispatch({
projectDir: tmpDir,
gsdToolsPath: script,
normCmd: 'state',
normArgs: ['load'],
});
expect(result.stdout).toBe('{\n "ok": true\n}\n');
});
it('returns text output with trailing newline', async () => {
const script = await createScript('text.cjs', "process.stdout.write('USAGE: help text');");
const result = await runCjsFallbackDispatch({
projectDir: tmpDir,
gsdToolsPath: script,
normCmd: 'phase',
normArgs: ['add', '--help'],
});
expect(result.stdout).toBe('USAGE: help text\n');
});
it('passes ws flag to cjs command', async () => {
const script = await createScript('ws.cjs', "const args=process.argv.slice(2); process.stdout.write(JSON.stringify({args}));");
const result = await runCjsFallbackDispatch({
projectDir: tmpDir,
gsdToolsPath: script,
normCmd: 'state',
normArgs: ['load'],
ws: 'ws-1',
pickField: 'args',
});
expect(result.stdout).toBe('[\n "state",\n "load",\n "--ws",\n "ws-1"\n]\n');
});
it('returns structured error when subprocess fails', async () => {
const result = await runCjsFallbackDispatch({
projectDir: tmpDir,
gsdToolsPath: join(fixtureDir, 'missing.cjs'),
normCmd: 'state',
normArgs: ['load'],
});
expect(result.error?.code).toBe(1);
expect(result.error?.message).toContain('fallback failed');
});
});

View File

@@ -0,0 +1,112 @@
import { execFile } from 'node:child_process';
import { readFile } from 'node:fs/promises';
import { extractField } from './registry.js';
import type { QueryDispatchResult } from './query-dispatch-contract.js';
interface CjsFallbackQueryResult {
mode: 'json' | 'text';
output: unknown;
stderr: string;
}
export interface RunCjsFallbackDispatchInput {
projectDir: string;
gsdToolsPath: string;
normCmd: string;
normArgs: string[];
ws?: string;
pickField?: string;
}
function dottedCommandToCjsArgv(normCmd: string, normArgs: string[]): string[] {
if (normCmd.includes('.')) return [...normCmd.split('.'), ...normArgs];
return [normCmd, ...normArgs];
}
function execGsdToolsCjsQuery(
projectDir: string,
gsdToolsPath: string,
normCmd: string,
normArgs: string[],
ws: string | undefined,
): Promise<{ stdout: string; stderr: string }> {
const cjsArgv = dottedCommandToCjsArgv(normCmd, normArgs);
const wsSuffix = ws ? ['--ws', ws] : [];
const fullArgv = [gsdToolsPath, ...cjsArgv, ...wsSuffix];
return new Promise((resolve, reject) => {
execFile(
process.execPath,
fullArgv,
{ cwd: projectDir, maxBuffer: 10 * 1024 * 1024, timeout: 30_000, killSignal: 'SIGKILL', env: { ...process.env } },
(err, stdout, stderr) => {
if (err) reject(err);
else resolve({ stdout: stdout?.toString() ?? '', stderr: stderr?.toString() ?? '' });
},
);
});
}
async function parseCliQueryJsonOutput(raw: string, projectDir: string): Promise<unknown> {
const trimmed = raw.trim();
if (trimmed === '') return null;
let jsonStr = trimmed;
if (jsonStr.startsWith('@file:')) {
const rel = jsonStr.slice(6).trim();
const { resolvePathUnderProject } = await import('./helpers.js');
const filePath = await resolvePathUnderProject(projectDir, rel);
jsonStr = await readFile(filePath, 'utf-8');
}
return JSON.parse(jsonStr);
}
async function runCjsFallbackQuery(
projectDir: string,
gsdToolsPath: string,
normCmd: string,
normArgs: string[],
ws: string | undefined,
): Promise<CjsFallbackQueryResult> {
const { stdout, stderr } = await execGsdToolsCjsQuery(projectDir, gsdToolsPath, normCmd, normArgs, ws);
try {
const output = await parseCliQueryJsonOutput(stdout, projectDir);
return { mode: 'json', output, stderr };
} catch {
return { mode: 'text', output: stdout, stderr };
}
}
function formatFallbackOutput(data: unknown, mode: 'json' | 'text', pickField?: string): string | undefined {
if (mode === 'text') {
const text = String(data ?? '');
if (!text.trim()) return undefined;
return text.endsWith('\n') ? text : `${text}\n`;
}
let output: unknown = data;
if (pickField) output = extractField(output, pickField);
return `${JSON.stringify(output, null, 2)}\n`;
}
export async function runCjsFallbackDispatch(input: RunCjsFallbackDispatchInput): Promise<QueryDispatchResult> {
const { projectDir, gsdToolsPath, normCmd, normArgs, ws, pickField } = input;
const stderr = [
`[gsd-sdk] '${normCmd}' not in native registry; falling back to gsd-tools.cjs.`,
'[gsd-sdk] Transparent bridge — prefer adding a native handler when parity matters.',
];
try {
const fallback = await runCjsFallbackQuery(projectDir, gsdToolsPath, normCmd, normArgs, ws);
if (fallback.stderr.trim()) stderr.push(fallback.stderr.trimEnd());
return {
stderr,
stdout: formatFallbackOutput(fallback.output, fallback.mode, pickField),
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return {
stderr,
error: { code: 1, message: `Error: gsd-tools.cjs fallback failed: ${msg}` },
};
}
}

View File

@@ -0,0 +1,88 @@
import type { QueryRegistry } from './registry.js';
import type { QueryHandler } from './utils.js';
import type { AliasCatalogEntry } from './command-catalog.js';
export interface RegistryAssemblyAliasGroup {
family: string;
aliases: readonly AliasCatalogEntry[];
handlers: Readonly<Record<string, QueryHandler>>;
}
export interface RegistryAssemblyStaticGroup {
name: string;
entries: ReadonlyArray<readonly [command: string, handler: QueryHandler]>;
}
export interface RegistryAssemblyInputs {
staticGroups: readonly RegistryAssemblyStaticGroup[];
aliasGroups: readonly RegistryAssemblyAliasGroup[];
mutationCommands: ReadonlySet<string>;
rawOutputPolicyCommands: readonly string[];
}
function toSortedList(values: Iterable<string>): string[] {
return Array.from(values).sort((a, b) => a.localeCompare(b));
}
export function assertNoDuplicateRegisteredCommands(inputs: RegistryAssemblyInputs): void {
const counts = new Map<string, number>();
for (const group of inputs.staticGroups) {
for (const [command] of group.entries) {
counts.set(command, (counts.get(command) ?? 0) + 1);
}
}
for (const group of inputs.aliasGroups) {
for (const entry of group.aliases) {
counts.set(entry.canonical, (counts.get(entry.canonical) ?? 0) + 1);
for (const alias of entry.aliases) {
counts.set(alias, (counts.get(alias) ?? 0) + 1);
}
}
}
const duplicates = toSortedList(
Array.from(counts.entries())
.filter(([, count]) => count > 1)
.map(([command]) => command),
);
if (duplicates.length > 0) {
throw new Error(`registry assembly invariant failed: duplicate command keys: ${duplicates.join(', ')}`);
}
}
export function assertAliasCanonicalsHaveHandlers(inputs: RegistryAssemblyInputs): void {
const missing: string[] = [];
for (const group of inputs.aliasGroups) {
for (const entry of group.aliases) {
if (!group.handlers[entry.canonical]) {
missing.push(`${group.family}:${entry.canonical}`);
}
}
}
if (missing.length > 0) {
throw new Error(`registry assembly invariant failed: alias canonical missing handler: ${toSortedList(missing).join(', ')}`);
}
}
export function assertMutationCommandsRegistered(
registry: QueryRegistry,
mutationCommands: ReadonlySet<string>,
): void {
const missing = toSortedList(Array.from(mutationCommands).filter((command) => !registry.has(command)));
if (missing.length > 0) {
throw new Error(`registry assembly invariant failed: mutation command missing from registry: ${missing.join(', ')}`);
}
}
export function assertRawOutputPolicyCommandsRegistered(
registry: QueryRegistry,
rawOutputPolicyCommands: readonly string[],
): void {
const missing = toSortedList(rawOutputPolicyCommands.filter((command) => !registry.has(command)));
if (missing.length > 0) {
throw new Error(`registry assembly invariant failed: raw-output policy command missing from registry: ${missing.join(', ')}`);
}
}

View File

@@ -0,0 +1,109 @@
import { describe, it, expect } from 'vitest';
import { QueryRegistry } from './registry.js';
import {
buildRegistry,
createRegistry,
decorateRegistryMutations,
QUERY_MUTATION_COMMANDS,
} from './registry-assembly.js';
import {
assertAliasCanonicalsHaveHandlers,
assertMutationCommandsRegistered,
assertNoDuplicateRegisteredCommands,
assertRawOutputPolicyCommandsRegistered,
type RegistryAssemblyAliasGroup,
type RegistryAssemblyStaticGroup,
} from './registry-assembly-invariants.js';
const noop = async () => ({ data: null });
describe('registry assembly', () => {
it('buildRegistry returns registered registry', () => {
const registry = buildRegistry();
expect(registry.has('state.load')).toBe(true);
expect(registry.has('verify-summary')).toBe(true);
expect(registry.has('verify.summary')).toBe(true);
expect(registry.has('verify summary')).toBe(true);
});
it('createRegistry keeps public seam', () => {
const registry = createRegistry();
expect(registry).toBeInstanceOf(QueryRegistry);
expect(registry.commands().length).toBeGreaterThan(0);
});
it('decorateRegistryMutations is no-op without event stream', () => {
const registry = buildRegistry();
expect(() => decorateRegistryMutations(registry, undefined, 's')).not.toThrow();
});
it('QUERY_MUTATION_COMMANDS entries are present in registry', () => {
const registry = buildRegistry();
for (const command of QUERY_MUTATION_COMMANDS) {
expect(registry.has(command), `missing mutation command: ${command}`).toBe(true);
}
});
});
describe('registry assembly invariants', () => {
const staticGroups: RegistryAssemblyStaticGroup[] = [
{ name: 'S1', entries: [['one', noop]] },
];
const aliasGroups: RegistryAssemblyAliasGroup[] = [
{ family: 'f', aliases: [{ canonical: 'canon', aliases: ['alias'] }], handlers: { canon: noop } },
];
it('fails on duplicate command keys', () => {
expect(() => assertNoDuplicateRegisteredCommands({
staticGroups: [
{ name: 'S1', entries: [['dup', noop]] },
{ name: 'S2', entries: [['dup', noop]] },
],
aliasGroups: [],
mutationCommands: new Set<string>(),
rawOutputPolicyCommands: [],
})).toThrow(/duplicate command keys/i);
});
it('fails on alias canonical without handler', () => {
expect(() => assertAliasCanonicalsHaveHandlers({
staticGroups: [],
aliasGroups: [
{ family: 'f', aliases: [{ canonical: 'missing', aliases: [] }], handlers: {} },
],
mutationCommands: new Set<string>(),
rawOutputPolicyCommands: [],
})).toThrow(/alias canonical missing handler/i);
});
it('fails when mutation command missing from registry', () => {
const registry = new QueryRegistry();
expect(() => assertMutationCommandsRegistered(registry, new Set(['missing.cmd']))).toThrow(/mutation command missing from registry/i);
});
it('fails when raw-output policy command missing from registry', () => {
const registry = new QueryRegistry();
expect(() => assertRawOutputPolicyCommandsRegistered(registry, ['verify-summary'])).toThrow(/raw-output policy command missing from registry/i);
});
it('passes happy path', () => {
const registry = new QueryRegistry();
registry.register('one', noop);
registry.register('canon', noop);
registry.register('alias', noop);
expect(() => assertNoDuplicateRegisteredCommands({
staticGroups,
aliasGroups,
mutationCommands: new Set<string>(),
rawOutputPolicyCommands: [],
})).not.toThrow();
expect(() => assertAliasCanonicalsHaveHandlers({
staticGroups,
aliasGroups,
mutationCommands: new Set<string>(),
rawOutputPolicyCommands: [],
})).not.toThrow();
expect(() => assertMutationCommandsRegistered(registry, new Set(['one']))).not.toThrow();
expect(() => assertRawOutputPolicyCommandsRegistered(registry, ['canon'])).not.toThrow();
});
});

View File

@@ -0,0 +1,120 @@
import { QueryRegistry } from './registry.js';
import {
STATE_COMMAND_ALIASES,
VERIFY_COMMAND_ALIASES,
INIT_COMMAND_ALIASES,
PHASE_COMMAND_ALIASES,
PHASES_COMMAND_ALIASES,
VALIDATE_COMMAND_ALIASES,
ROADMAP_COMMAND_ALIASES,
} from './command-aliases.generated.js';
import { GSDEventStream } from '../event-stream.js';
import type { QueryHandler } from './utils.js';
import { registerAliasCatalog, registerStaticCatalog } from './command-catalog.js';
import {
FOUNDATION_STATIC_CATALOG,
STATE_SUPPORT_STATIC_CATALOG,
MUTATION_SURFACES_STATIC_CATALOG,
VERIFY_DECISION_STATIC_CATALOG,
DECISION_ROUTING_STATIC_CATALOG,
} from './command-static-catalog-foundation.js';
import { DOMAIN_STATIC_CATALOG } from './command-static-catalog-domain.js';
import { QUERY_MUTATION_COMMAND_LIST, TRANSPORT_RAW_COMMANDS } from './policy-convergence.js';
import { decorateMutationsWithEvents } from './mutation-event-decorator.js';
import { FAMILY_HANDLERS } from './command-family-handlers.js';
import {
assertAliasCanonicalsHaveHandlers,
assertMutationCommandsRegistered,
assertNoDuplicateRegisteredCommands,
assertRawOutputPolicyCommandsRegistered,
type RegistryAssemblyAliasGroup,
type RegistryAssemblyStaticGroup,
} from './registry-assembly-invariants.js';
/**
* Command names that perform durable writes (disk, git, or global profile store).
*/
export const QUERY_MUTATION_COMMANDS = new Set<string>(QUERY_MUTATION_COMMAND_LIST);
const STATIC_CATALOG_GROUPS: readonly RegistryAssemblyStaticGroup[] = [
{ name: 'FOUNDATION_STATIC_CATALOG', entries: FOUNDATION_STATIC_CATALOG },
{ name: 'STATE_SUPPORT_STATIC_CATALOG', entries: STATE_SUPPORT_STATIC_CATALOG },
{ name: 'MUTATION_SURFACES_STATIC_CATALOG', entries: MUTATION_SURFACES_STATIC_CATALOG },
{ name: 'VERIFY_DECISION_STATIC_CATALOG', entries: VERIFY_DECISION_STATIC_CATALOG },
{ name: 'DECISION_ROUTING_STATIC_CATALOG', entries: DECISION_ROUTING_STATIC_CATALOG },
{ name: 'DOMAIN_STATIC_CATALOG', entries: DOMAIN_STATIC_CATALOG },
] as const;
const ALIAS_GROUPS: readonly RegistryAssemblyAliasGroup[] = [
{ family: 'state', aliases: STATE_COMMAND_ALIASES, handlers: FAMILY_HANDLERS.state as Record<string, QueryHandler> },
{ family: 'roadmap', aliases: ROADMAP_COMMAND_ALIASES, handlers: FAMILY_HANDLERS.roadmap as Record<string, QueryHandler> },
{ family: 'verify', aliases: VERIFY_COMMAND_ALIASES, handlers: FAMILY_HANDLERS.verify as Record<string, QueryHandler> },
{ family: 'validate', aliases: VALIDATE_COMMAND_ALIASES, handlers: FAMILY_HANDLERS.validate as Record<string, QueryHandler> },
{ family: 'phase', aliases: PHASE_COMMAND_ALIASES, handlers: FAMILY_HANDLERS.phase as Record<string, QueryHandler> },
{ family: 'phases', aliases: PHASES_COMMAND_ALIASES, handlers: FAMILY_HANDLERS.phases as Record<string, QueryHandler> },
{ family: 'init', aliases: INIT_COMMAND_ALIASES, handlers: FAMILY_HANDLERS.init as Record<string, QueryHandler> },
] as const;
export function buildRegistry(): QueryRegistry {
assertAliasCanonicalsHaveHandlers({
staticGroups: STATIC_CATALOG_GROUPS,
aliasGroups: ALIAS_GROUPS,
mutationCommands: QUERY_MUTATION_COMMANDS,
rawOutputPolicyCommands: TRANSPORT_RAW_COMMANDS,
});
assertNoDuplicateRegisteredCommands({
staticGroups: STATIC_CATALOG_GROUPS,
aliasGroups: ALIAS_GROUPS,
mutationCommands: QUERY_MUTATION_COMMANDS,
rawOutputPolicyCommands: TRANSPORT_RAW_COMMANDS,
});
const registry = new QueryRegistry();
registerStaticCatalog(registry, FOUNDATION_STATIC_CATALOG);
registerAliasCatalog(registry, STATE_COMMAND_ALIASES, FAMILY_HANDLERS.state as Record<string, QueryHandler>);
registerStaticCatalog(registry, STATE_SUPPORT_STATIC_CATALOG);
registerAliasCatalog(registry, ROADMAP_COMMAND_ALIASES, FAMILY_HANDLERS.roadmap as Record<string, QueryHandler>);
registerStaticCatalog(registry, MUTATION_SURFACES_STATIC_CATALOG);
registerAliasCatalog(registry, VERIFY_COMMAND_ALIASES, FAMILY_HANDLERS.verify as Record<string, QueryHandler>);
registerStaticCatalog(registry, VERIFY_DECISION_STATIC_CATALOG);
registerAliasCatalog(registry, VALIDATE_COMMAND_ALIASES, FAMILY_HANDLERS.validate as Record<string, QueryHandler>);
registerStaticCatalog(registry, DECISION_ROUTING_STATIC_CATALOG);
registerAliasCatalog(registry, PHASE_COMMAND_ALIASES, FAMILY_HANDLERS.phase as Record<string, QueryHandler>);
registerAliasCatalog(registry, PHASES_COMMAND_ALIASES, FAMILY_HANDLERS.phases as Record<string, QueryHandler>);
registerAliasCatalog(registry, INIT_COMMAND_ALIASES, FAMILY_HANDLERS.init as Record<string, QueryHandler>);
registerStaticCatalog(registry, DOMAIN_STATIC_CATALOG);
assertMutationCommandsRegistered(registry, QUERY_MUTATION_COMMANDS);
assertRawOutputPolicyCommandsRegistered(registry, TRANSPORT_RAW_COMMANDS);
return registry;
}
export function decorateRegistryMutations(
registry: QueryRegistry,
eventStream?: GSDEventStream,
correlationSessionId?: string,
): void {
if (!eventStream) return;
const mutationSessionId = correlationSessionId ?? '';
decorateMutationsWithEvents(registry, QUERY_MUTATION_COMMANDS, eventStream, mutationSessionId);
}
export function createRegistry(
eventStream?: GSDEventStream,
correlationSessionId?: string,
): QueryRegistry {
const registry = buildRegistry();
decorateRegistryMutations(registry, eventStream, correlationSessionId);
return registry;
}

View File

@@ -77,7 +77,7 @@ describe('QueryRegistry', () => {
const result = await registry.dispatch('test-cmd', ['arg1'], '/tmp');
expect(handler).toHaveBeenCalledWith(['arg1'], '/tmp');
expect(handler).toHaveBeenCalledWith(['arg1'], '/tmp', undefined);
expect(result).toEqual({ data: { value: 'arg1' } });
});

View File

@@ -22,6 +22,7 @@
import type { QueryResult, QueryHandler } from './utils.js';
import { GSDError, ErrorClassification } from '../errors.js';
import { resolveQueryTokens } from './command-resolution.js';
// ─── extractField ──────────────────────────────────────────────────────────
@@ -126,48 +127,6 @@ export class QueryRegistry {
}
}
/**
* If the first token contains a dot (e.g. `init.execute-phase`), split it into
* segments and prepend those segments in place of the original token. Args that
* follow the dotted token are preserved.
*
* Examples:
* ['init.new-project'] -> ['init', 'new-project']
* ['init.execute-phase', '1'] -> ['init', 'execute-phase', '1']
* ['state.update', 'status', 'X'] -> ['state', 'update', 'status', 'X']
*
* Returns the original array (by reference) when no expansion applies so callers
* can detect "nothing changed" via identity comparison.
*/
function expandFirstDottedToken(tokens: string[]): string[] {
if (tokens.length === 0) {
return tokens;
}
const first = tokens[0];
if (first.startsWith('--') || !first.includes('.')) {
return tokens;
}
return [...first.split('.'), ...tokens.slice(1)];
}
function matchRegisteredPrefix(
tokens: string[],
registry: QueryRegistry,
): { cmd: string; args: string[] } | null {
for (let i = tokens.length; i >= 1; i--) {
const head = tokens.slice(0, i);
const dotted = head.join('.');
const spaced = head.join(' ');
if (registry.has(dotted)) {
return { cmd: dotted, args: tokens.slice(i) };
}
if (registry.has(spaced)) {
return { cmd: spaced, args: tokens.slice(i) };
}
}
return null;
}
/**
* Map argv after `gsd-sdk query` to a registered handler key and remaining args.
* Longest-prefix match on dotted (`a.b.c`) and spaced (`a b c`) keys; if no match,
@@ -177,12 +136,7 @@ export function resolveQueryArgv(
tokens: string[],
registry: QueryRegistry,
): { cmd: string; args: string[] } | null {
let matched = matchRegisteredPrefix(tokens, registry);
if (!matched) {
const expanded = expandFirstDottedToken(tokens);
if (expanded !== tokens) {
matched = matchRegisteredPrefix(expanded, registry);
}
}
return matched;
const resolved = resolveQueryTokens(tokens, registry);
if (!resolved) return null;
return { cmd: resolved.cmd, args: resolved.args };
}

View File

@@ -26,7 +26,7 @@ const CONFIG_MUTATION_TS = path.join(__dirname, '..', 'sdk', 'src', 'query', 'co
// #2653 — allowlist moved to shared schema module.
const CONFIG_SCHEMA_TS = path.join(__dirname, '..', 'sdk', 'src', 'query', 'config-schema.ts');
const CONFIG_GATES_TS = path.join(__dirname, '..', 'sdk', 'src', 'query', 'config-gates.ts');
const QUERY_INDEX_TS = path.join(__dirname, '..', 'sdk', 'src', 'query', 'index.ts');
const QUERY_INDEX_TS = path.join(__dirname, '..', 'sdk', 'src', 'query', 'registry-assembly.ts');
describe('plan-phase decision-coverage gate (#2492)', () => {
const md = fs.readFileSync(PLAN_PHASE, 'utf-8');
@@ -168,10 +168,12 @@ describe('SDK wiring for #2492 gates', () => {
);
});
test('query index.ts registers the new handlers', () => {
test('query registry assembly registers the new handlers', () => {
const c = fs.readFileSync(QUERY_INDEX_TS, 'utf-8');
assert.ok(c.includes('check.decision-coverage-plan'), 'check.decision-coverage-plan handler must be registered');
assert.ok(c.includes('check.decision-coverage-verify'), 'check.decision-coverage-verify handler must be registered');
assert.ok(c.includes('decisions.parse'), 'decisions.parse handler must be registered');
// Handlers are now registered through catalog-based registration.
assert.ok(c.includes('VERIFY_DECISION_STATIC_CATALOG'), 'decision-coverage-plan handler must be registered via the verify-decision catalog');
assert.ok(c.includes('registerStaticCatalog(registry, VERIFY_DECISION_STATIC_CATALOG)'), 'check.decision-coverage-plan handler must be registered');
assert.ok(c.includes('registerStaticCatalog(registry, VERIFY_DECISION_STATIC_CATALOG)'), 'check.decision-coverage-verify handler must be registered');
assert.ok(c.includes('decisions.parse') || c.includes('VERIFY_DECISION_STATIC_CATALOG'), 'decisions.parse handler must be registered');
});
});

View File

@@ -89,8 +89,9 @@ describe('QueryRegistry.dispatch() workstream threading', () => {
describe('CLI forwards --ws to registry.dispatch()', () => {
test('cli.ts passes args.ws as the workstream argument to registry.dispatch()', () => {
// The CLI now uses a dispatchNative callback pattern.
assert.ok(
cliTs.includes('registry.dispatch(matched.cmd, matched.args, args.projectDir, args.ws)'),
cliTs.includes('dispatchNative') && cliTs.includes('args.ws'),
'cli.ts must forward args.ws to registry.dispatch() as the workstream argument',
);
});

View File

@@ -1,6 +1,6 @@
/**
* Drift guard: every `gsd-sdk query <cmd>` reference in the repo must
* resolve to a handler registered in sdk/src/query/index.ts.
* resolve to a handler registered in sdk/src/query/registry-assembly.ts.
*
* The set of commands workflows/agents/commands call must equal the set
* the SDK registry exposes. New references with no handler — or handlers
@@ -13,7 +13,7 @@ const fs = require('fs');
const path = require('path');
const REPO_ROOT = path.join(__dirname, '..');
const REGISTRY_FILE = path.join(REPO_ROOT, 'sdk', 'src', 'query', 'index.ts');
const REGISTRY_FILE = path.join(REPO_ROOT, 'sdk', 'src', 'query', 'registry-assembly.ts');
const COMMAND_ALIASES_FILE = path.join(REPO_ROOT, 'get-shit-done', 'bin', 'lib', 'command-aliases.generated.cjs');
// Prose tokens that repeatedly appear after `gsd-sdk query` in English
@@ -43,11 +43,43 @@ function collectRegisteredNames() {
const src = fs.readFileSync(REGISTRY_FILE, 'utf8');
const names = new Set();
// Static registrations in index.ts
// Static registrations in index.ts (legacy style, may still exist)
const re = /registry\.register\(\s*['"]([^'"]+)['"]/g;
let m;
while ((m = re.exec(src)) !== null) names.add(m[1]);
// Catalog-based registrations: extract handler names from registerStaticCatalog calls.
const catalogRe = /registerStaticCatalog\(registry, (\w+)\)/g;
let cm;
while ((cm = catalogRe.exec(src)) !== null) {
const catalogVarName = cm[1];
// Map variable names to their source files.
const catalogFileByVar = {
FOUNDATION_STATIC_CATALOG: path.join(REPO_ROOT, 'sdk', 'src', 'query', 'command-static-catalog-foundation.ts'),
STATE_SUPPORT_STATIC_CATALOG: path.join(REPO_ROOT, 'sdk', 'src', 'query', 'command-static-catalog-foundation.ts'),
MUTATION_SURFACES_STATIC_CATALOG: path.join(REPO_ROOT, 'sdk', 'src', 'query', 'command-static-catalog-foundation.ts'),
VERIFY_DECISION_STATIC_CATALOG: path.join(REPO_ROOT, 'sdk', 'src', 'query', 'command-static-catalog-foundation.ts'),
DECISION_ROUTING_STATIC_CATALOG: path.join(REPO_ROOT, 'sdk', 'src', 'query', 'command-static-catalog-foundation.ts'),
DOMAIN_STATIC_CATALOG: path.join(REPO_ROOT, 'sdk', 'src', 'query', 'command-static-catalog-domain.ts'),
};
const cf = catalogFileByVar[catalogVarName];
if (!cf) continue;
try {
const catSrc = fs.readFileSync(cf, 'utf8');
// Only extract names from the catalog matching this variable.
const exportRe = new RegExp(`export const ${catalogVarName}:`, 'm');
if (!exportRe.test(catSrc)) continue;
// Match: [[name, handler], ...]
const entryRe = /\[\s*['"]([^'"]+)['"]/g;
let em;
while ((em = entryRe.exec(catSrc)) !== null) {
names.add(em[1]);
}
} catch {
// File not found, skip.
}
}
// Manifest-generated family aliases registered via loop in index.ts.
// Keep this in sync with command-manifest-driven routing seams.
try {
@@ -159,7 +191,7 @@ describe('gsd-sdk query registry integration', () => {
assert.strictEqual(
offenders.length, 0,
'Referenced `gsd-sdk query <cmd>` tokens with no handler in ' +
'sdk/src/query/index.ts. Either register the handler or remove ' +
'sdk/src/query/registry-assembly.ts. Either register the handler or remove ' +
'the reference.\n\n' + offenders.join('\n')
);
});