diff --git a/CONTEXT.md b/CONTEXT.md index e22e6362d..38add6c7b 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -69,10 +69,11 @@ guarantees are whole-matrix decisions, not local edits. The perfect-shape refactor is complete and merged. Its end-state: - Two derivation registries. One `CommandDescriptor` per command - (`src/core/command-descriptor/registry.ts`) is the single declaration site from which the public - catalog, capability matrix, daemon command registry, batch allowlist, MCP tools, CLI schema, and - the Node client surface are *derived* by parity-tested projection; the dispatch `switch` became a - total map keyed on the command-name union (a missing handler is a compile error). One + (`src/core/command-descriptor/registry.ts`) is the single declaration site from which the + capability matrix, daemon command registry, batch allowlist, timeout policy, MCP exposure list, and + capability-checked CLI command list are *derived* by parity-tested projection. The command catalog + still owns identity, command families still own surface metadata/CLI schema, and the Node client + surface remains a deferred derivation target. One `PlatformPlugin` per platform family (`src/core/platform-plugin/`) stops core/daemon from branching on platform, with the Apple plugin the first instance. See [ADR 0008](docs/adr/0008-command-descriptor-registry.md). diff --git a/docs/adr/0008-command-descriptor-registry.md b/docs/adr/0008-command-descriptor-registry.md index ed664dd75..a7dc6d60d 100644 --- a/docs/adr/0008-command-descriptor-registry.md +++ b/docs/adr/0008-command-descriptor-registry.md @@ -72,7 +72,11 @@ Each derived table must be asserted **byte-for-byte equivalent** to the hand-aut **before** the hand table is deleted. The principal risk is the import-cycle inversion: `command-catalog.ts` has ~95 importers and the family facet currently imports `AgentDeviceClient`, so the descriptor module must own the `Input`/`Result` types and the client must be derived as a view type, enforced by a lint boundary. -Until this lands and the registry tests pin it, the hand-authored tables remain the source of truth. +As of 2026-07, the descriptor is live for the daemon registry, capability matrix, structured-batch +allowlist, daemon-client timeout policy, MCP exposure list, and capability-checked CLI command list. +The catalog still owns command identity and the command family facet still owns surface metadata, so +future migration steps should keep deleting one live hand-maintained table at a time rather than +introducing a parallel manifest. This ADR owns the decision and its constraints; the roadmap that prototyped it has been retired, with the delivered end-state recorded in [CONTEXT.md](../../CONTEXT.md) (Architecture). diff --git a/src/command-catalog.ts b/src/command-catalog.ts index 843288e3a..5d051c575 100644 --- a/src/command-catalog.ts +++ b/src/command-catalog.ts @@ -98,49 +98,6 @@ export type ClientBackedCliCommandName = | typeof LOCAL_CLI_COMMANDS.metro | typeof LOCAL_CLI_COMMANDS.session; -const MCP_UNEXPOSED_CLI_COMMANDS = commandSet( - LOCAL_CLI_COMMANDS.auth, - LOCAL_CLI_COMMANDS.cdp, - LOCAL_CLI_COMMANDS.connect, - LOCAL_CLI_COMMANDS.connection, - LOCAL_CLI_COMMANDS.disconnect, - LOCAL_CLI_COMMANDS.mcp, - LOCAL_CLI_COMMANDS.proxy, - LOCAL_CLI_COMMANDS.reactDevtools, - LOCAL_CLI_COMMANDS.web, - PUBLIC_COMMANDS.prepare, -); - -const CAPABILITY_EXEMPT_CLI_COMMANDS = commandSet( - LOCAL_CLI_COMMANDS.auth, - LOCAL_CLI_COMMANDS.cdp, - LOCAL_CLI_COMMANDS.connect, - LOCAL_CLI_COMMANDS.connection, - LOCAL_CLI_COMMANDS.debug, - LOCAL_CLI_COMMANDS.disconnect, - LOCAL_CLI_COMMANDS.mcp, - LOCAL_CLI_COMMANDS.metro, - LOCAL_CLI_COMMANDS.proxy, - LOCAL_CLI_COMMANDS.reactDevtools, - LOCAL_CLI_COMMANDS.session, - LOCAL_CLI_COMMANDS.web, - PUBLIC_COMMANDS.artifacts, - PUBLIC_COMMANDS.appState, - PUBLIC_COMMANDS.prepare, - PUBLIC_COMMANDS.batch, - PUBLIC_COMMANDS.capabilities, - PUBLIC_COMMANDS.devices, - PUBLIC_COMMANDS.doctor, - PUBLIC_COMMANDS.gesture, - PUBLIC_COMMANDS.replay, - PUBLIC_COMMANDS.test, - PUBLIC_COMMANDS.trace, -); - -function commandSet(...commands: readonly string[]): ReadonlySet { - return new Set(commands); -} - export function listCliCommandNames(): CliCommandName[] { return [...Object.values(PUBLIC_COMMANDS), ...Object.values(LOCAL_CLI_COMMANDS)].sort(); } @@ -161,11 +118,3 @@ export function isClientBackedCliCommandName( command === LOCAL_CLI_COMMANDS.session ); } - -export function listMcpExposedCommandNames(): CliCommandName[] { - return listCliCommandNames().filter((command) => !MCP_UNEXPOSED_CLI_COMMANDS.has(command)); -} - -export function listCapabilityCheckedCommandNames(): CliCommandName[] { - return listCliCommandNames().filter((command) => !CAPABILITY_EXEMPT_CLI_COMMANDS.has(command)); -} diff --git a/src/commands/__tests__/command-surface-metadata.test.ts b/src/commands/__tests__/command-surface-metadata.test.ts index 74feb6aee..186e6a240 100644 --- a/src/commands/__tests__/command-surface-metadata.test.ts +++ b/src/commands/__tests__/command-surface-metadata.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; -import { listMcpExposedCommandNames } from '../../command-catalog.ts'; +import { listMcpExposedCommandNames } from '../../core/command-descriptor/registry.ts'; import { listCommandMetadata, listCommandMetadataNames, diff --git a/src/commands/command-metadata.ts b/src/commands/command-metadata.ts index 94dc676d3..e78c52f97 100644 --- a/src/commands/command-metadata.ts +++ b/src/commands/command-metadata.ts @@ -1,4 +1,4 @@ -import { listMcpExposedCommandNames } from '../command-catalog.ts'; +import { listMcpExposedCommandNames } from '../core/command-descriptor/registry.ts'; import type { CommandMetadata } from './command-contract.ts'; import { listCommandFamilyMetadata, type CommandFamilyCommandName } from './family/registry.ts'; diff --git a/src/core/command-descriptor/__tests__/parity.test.ts b/src/core/command-descriptor/__tests__/parity.test.ts index 6c7e162b4..2d75b1a89 100644 --- a/src/core/command-descriptor/__tests__/parity.test.ts +++ b/src/core/command-descriptor/__tests__/parity.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; import { STRUCTURED_BATCH_COMMAND_NAMES } from '../../../batch-policy.ts'; -import { PUBLIC_COMMANDS } from '../../../command-catalog.ts'; +import { listCliCommandNames, PUBLIC_COMMANDS } from '../../../command-catalog.ts'; import { BASE_COMMAND_CAPABILITY_MATRIX } from '../../capabilities.ts'; import { DAEMON_COMMAND_DESCRIPTORS, @@ -9,7 +9,11 @@ import { } from '../../../daemon/daemon-command-registry.ts'; import type { DaemonRequest } from '../../../daemon/types.ts'; import { deriveDaemonCommandDescriptors, deriveStructuredBatchCommandNames } from '../derive.ts'; -import { commandDescriptors } from '../registry.ts'; +import { + commandDescriptors, + listCapabilityCheckedCommandNames, + listMcpExposedCommandNames, +} from '../registry.ts'; // Function-valued traits cannot be deep-equaled across re-authored closures, so // (mirroring daemon-command-registry.test.ts) they are compared by presence and @@ -174,3 +178,40 @@ test('structured-batch allowlist is built from descriptors', () => { assert.ok(!batchable.has(excluded), `${excluded} is not batchable`); } }); + +test('MCP exposure list is built from descriptors', () => { + const cliCommands = new Set(listCliCommandNames()); + const expected = commandDescriptors + .filter((descriptor) => descriptor.mcpExposed && cliCommands.has(descriptor.name)) + .map((descriptor) => descriptor.name) + .sort(); + const expectedNames = new Set(expected); + + assert.deepEqual(listMcpExposedCommandNames(), expected); + assert.ok(expectedNames.has('debug'), 'local debug command stays MCP-exposed'); + assert.ok(expectedNames.has('metro'), 'local metro command stays MCP-exposed'); + assert.ok(expectedNames.has('session'), 'local session command stays MCP-exposed'); + assert.equal(expectedNames.has(PUBLIC_COMMANDS.prepare), false, 'prepare stays out of MCP'); + assert.equal(expectedNames.has('auth'), false, 'schema-only auth command stays out of MCP'); +}); + +test('capability-checked command list is built from descriptor capabilities', () => { + const cliCommands = new Set(listCliCommandNames()); + const expected = commandDescriptors + .filter( + (descriptor) => + 'capability' in descriptor && descriptor.capability && cliCommands.has(descriptor.name), + ) + .map((descriptor) => descriptor.name) + .sort(); + const expectedNames = new Set(expected); + + assert.deepEqual(listCapabilityCheckedCommandNames(), expected); + assert.ok(expectedNames.has(PUBLIC_COMMANDS.snapshot), 'snapshot remains capability-checked'); + assert.equal( + expectedNames.has(PUBLIC_COMMANDS.capabilities), + false, + 'control-plane capabilities command stays capability-exempt', + ); + assert.equal(expectedNames.has('debug'), false, 'local debug command stays capability-exempt'); +}); diff --git a/src/core/command-descriptor/registry.ts b/src/core/command-descriptor/registry.ts index 4ae62f6e1..81a99d984 100644 --- a/src/core/command-descriptor/registry.ts +++ b/src/core/command-descriptor/registry.ts @@ -1,6 +1,7 @@ import { INTERNAL_COMMANDS, - listMcpExposedCommandNames, + listCliCommandNames, + type CliCommandName, PUBLIC_COMMANDS, } from '../../command-catalog.ts'; import type { CommandCapability } from '../capabilities.ts'; @@ -15,6 +16,10 @@ import { resolvePostActionObservationSupport } from './post-action-observation.t import type { PostActionObservationSupport } from './post-action-observation.ts'; import type { CommandDescriptor, CommandTimeoutPolicy } from './types.ts'; +type RawCommandDescriptor = Omit & { + mcpExposed?: boolean; +}; + // --------------------------------------------------------------------------- // Daemon request-policy trait bundles — copied VERBATIM from // src/daemon/daemon-command-registry.ts (DAEMON_COMMAND_DESCRIPTORS). @@ -396,6 +401,7 @@ const RAW_COMMAND_DESCRIPTORS = [ onTimeout: 'reset-daemon', }, batchable: false, + mcpExposed: false, }, { name: PUBLIC_COMMANDS.batch, @@ -694,9 +700,26 @@ const RAW_COMMAND_DESCRIPTORS = [ timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, }, -] as const satisfies readonly Omit[]; -const MCP_EXPOSED_COMMAND_NAMES = new Set(listMcpExposedCommandNames()); + // -- local client-backed CLI/MCP commands (no daemon route/capability) -- + { + name: 'debug', + timeoutPolicy: DEFAULT_TIMEOUT_POLICY, + batchable: false, + }, + { + name: 'metro', + timeoutPolicy: DEFAULT_TIMEOUT_POLICY, + batchable: false, + }, + { + name: 'session', + timeoutPolicy: DEFAULT_TIMEOUT_POLICY, + batchable: false, + }, +] as const satisfies readonly RawCommandDescriptor[]; + +const CLI_COMMAND_NAMES = new Set(listCliCommandNames()); /** * The additive single source of truth (ADR-0008, Phase 1 step 1). Proven @@ -708,16 +731,46 @@ const MCP_EXPOSED_COMMAND_NAMES = new Set(listMcpExposedCommandNames()); */ export const commandDescriptors = RAW_COMMAND_DESCRIPTORS.map((descriptor) => ({ ...descriptor, - mcpExposed: MCP_EXPOSED_COMMAND_NAMES.has(descriptor.name), + mcpExposed: resolveMcpExposure(descriptor), })) satisfies readonly CommandDescriptor[]; /** The literal union of every registered command name. */ export type Command = (typeof commandDescriptors)[number]['name']; +export function listMcpExposedCommandNames(): CliCommandName[] { + return commandDescriptors + .filter((descriptor) => isMcpExposedCliCommand(descriptor)) + .map((descriptor) => descriptor.name as CliCommandName) + .sort(); +} + +export function listCapabilityCheckedCommandNames(): CliCommandName[] { + return commandDescriptors + .filter((descriptor) => isCapabilityCheckedCliCommand(descriptor)) + .map((descriptor) => descriptor.name as CliCommandName) + .sort(); +} + const COMMAND_DESCRIPTOR_BY_NAME: ReadonlyMap = new Map( commandDescriptors.map((descriptor) => [descriptor.name, descriptor]), ); +function isCliCommandName(command: string): command is CliCommandName { + return CLI_COMMAND_NAMES.has(command); +} + +function resolveMcpExposure(descriptor: RawCommandDescriptor): boolean { + return descriptor.mcpExposed ?? CLI_COMMAND_NAMES.has(descriptor.name); +} + +function isMcpExposedCliCommand(descriptor: CommandDescriptor): boolean { + return descriptor.mcpExposed && isCliCommandName(descriptor.name); +} + +function isCapabilityCheckedCliCommand(descriptor: CommandDescriptor): boolean { + return Boolean(descriptor.capability) && isCliCommandName(descriptor.name); +} + const TIMEOUT_POLICY_BY_COMMAND: ReadonlyMap = new Map( commandDescriptors.map((descriptor) => [descriptor.name, descriptor.timeoutPolicy]), ); diff --git a/src/mcp/__tests__/router.test.ts b/src/mcp/__tests__/router.test.ts index d9e00dcff..525955e3b 100644 --- a/src/mcp/__tests__/router.test.ts +++ b/src/mcp/__tests__/router.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { setImmediate } from 'node:timers/promises'; import { test } from 'vitest'; -import { listMcpExposedCommandNames } from '../../command-catalog.ts'; +import { listMcpExposedCommandNames } from '../../core/command-descriptor/registry.ts'; import { handleMcpMessage } from '../router.ts'; import { createMcpPayloadQueue, handleMcpPayload } from '../server.ts'; diff --git a/src/utils/__tests__/command-schema-guards.test.ts b/src/utils/__tests__/command-schema-guards.test.ts index 66e18f5bf..185168f2c 100644 --- a/src/utils/__tests__/command-schema-guards.test.ts +++ b/src/utils/__tests__/command-schema-guards.test.ts @@ -8,10 +8,10 @@ import { listCapabilityCommands } from '../../core/capabilities.ts'; import { INTERNAL_COMMANDS, isKnownCliCommandName, - listCapabilityCheckedCommandNames, listCliCommandNames, SPECIAL_CLI_COMMANDS, } from '../../command-catalog.ts'; +import { listCapabilityCheckedCommandNames } from '../../core/command-descriptor/registry.ts'; import { getCliCommandSchema } from '../command-schema.ts'; test('every public capability command has a parser schema entry', () => {