diff --git a/.changeset/canonical-component-kinds.md b/.changeset/canonical-component-kinds.md new file mode 100644 index 000000000..f8c589ada --- /dev/null +++ b/.changeset/canonical-component-kinds.md @@ -0,0 +1,5 @@ +--- +'agent-bundle': minor +--- + +Report every canonical host component kind in `agent-bundle inspect` (`--json` and human output). Each inspection plan now carries a `kinds` matrix — `agent`, `cli`, `command`, `event-route`, `hook`, `lsp`, `mcp-app`, `mcp-server`, `native-diagnostics`, `native-extension`, `rule`, `script`, `skill` — with the target's own four-state judgment and selected/omitted counts, plus a `kinds this host cannot emit:` line, so a host with no LSP, diagnostics-provider, extension, or (G5-deferred) agent surface says so in its own dated words instead of by silence. Filesystem `src/events` routes now report as the `event-route` kind judged by the host's `event:` row instead of folding into `hook` (a breaking change to the `kind` and `name` fields of `inspect --json` for event routes), and `claude.lspServers` entries report as `lsp` components emitted by Claude and the composite `plugin` bundle and excluded elsewhere. The `AgentComponentKind` type and `componentKindCapability` helpers are exported from `agent-bundle/api`. No diagnostic codes change (#425) diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index e8fb66945..b7a7164e8 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -781,9 +781,14 @@ agent-bundle inspect [--target ] [--json] ``` Every inspection plan accounts for each host component the project declares -— skills, commands, rules, hooks, MCP servers, MCP Apps, and scripts — as -either `selected` (emitted for that target) or `skipped` (omitted), in one -deterministic order. A skipped component names its cause: `excluded-by-targets` +— skills, commands, rules, config-declared hooks, filesystem event routes, +LSP servers, MCP servers, MCP Apps, and scripts — as either `selected` +(emitted for that target) or `skipped` (omitted), in one deterministic order. +Each component carries its canonical `kind` (`AgentComponentKind`); event +routes report as `event-route`, judged by the host's row for their canonical +event (`event:session/start`, …), separately from `hook`, and Claude-declared +`claude.lspServers` entries report as `lsp` against every target's `lsp` row. +A skipped component names its cause: `excluded-by-targets` when the author's `targets` left the host out, or `unsupported-capability` when the host's pinned capability table does not support the surface. Components that need a host capability carry that target's own four-state judgment as @@ -792,9 +797,17 @@ or `{ name, state: 'degraded' | 'unavailable' | 'prohibited', reason }` — so the JSON explains why a Cursor rule is absent from a Claude bundle in the host's words rather than the compiler's. An adapter that publishes no row for a needed capability reads as an honest `unavailable`, never a silent pass. -Scripts need no host capability and carry none. The human output prints one -line per target (`: N component(s) selected, M omitted`) followed by -each omission and its reason. +Scripts need no host capability and carry none. Every plan also carries +`kinds`: one entry per canonical kind, in kind order, with the target's own +row for that kind (`capability`) and the counts of selected and skipped +components of it — so a host with no `lsp`, `native-diagnostics`, +`native-extension`, or `agent` surface says so in its own words even when the +project declares none of them (`script` and `event-route` carry no kind-level +row; event routes are judged per component). The human output prints one line +per target (`: N component(s) selected, M omitted`) followed by each +omission and its reason, then `kinds this host cannot emit:` listing every +kind whose row is not `supported`. The full matrix is in +[Host components](framework-mode.md#host-components). ### `agent-bundle inspect --bundler` diff --git a/docs/framework-mode.md b/docs/framework-mode.md index f9e4fea4e..6591dff2d 100644 --- a/docs/framework-mode.md +++ b/docs/framework-mode.md @@ -330,6 +330,46 @@ The package README's [Developer workbench](../packages/agent-bundle/README.md#de section carries the exact commands, install layouts, and event payloads; [Diagnostics](diagnostics.md) lists every code on this path. +## Host components + +Every project component belongs to one canonical kind (`AgentComponentKind`, +exported from `agent-bundle/api`), and every kind that needs a host surface +names the capability row a target adapter must publish for it. Adapters judge +each row with the shared four-state contract — `supported` with pinned +evidence, or `degraded` / `unavailable` / `prohibited` with a dated reason — +and `agent-bundle inspect` reports the judgment per target (see +[component accounting](entry-conventions.md#agent-bundle-inspect-component-accounting)). +A host with no row for a kind reads as an honest `unavailable`, never a silent +pass. The matrix below is the state of the pinned tables (Claude Code +2.1.250, Codex 0.147.0, Cursor 2026-08-28, Agent Plugins 1.0.0); the JSON +tables under `packages/agent-bundle/src/adapters/capabilities/` carry the +evidence strings themselves. + +| Kind | Source | Capability row | Claude | Codex | Cursor | portable | `plugin` (composite) | +| --- | --- | --- | --- | --- | --- | --- | --- | +| `skill` | `src/skills//SKILL.{md,ts,tsx}` | `skills` | supported | supported | supported | supported | supported | +| `command` | `src/commands/*.md` | `commands` | supported | unavailable | supported | unavailable | emitted (Claude format; Cursor pointer omitted) | +| `rule` | `src/rules/*.mdc` | `rules` | unavailable | unavailable | supported | unavailable | emitted (Cursor half) | +| `hook` | `hooks` config block | `hooks` | supported | supported | supported | unavailable | supported | +| `event-route` | `src/events//.tsx` | `event:` per route | per host `hooks.eventRoutes` table (#258) | per table | per table | unavailable (no hooks) | three-host intersection | +| `mcp-server` | `src/mcp//**` or `mcp.servers` | `mcp` | supported | supported | supported | supported | supported | +| `mcp-app` | `src/mcp//apps/*` or `mcp.servers.*.apps` | `mcp` | supported | supported | supported | supported | supported | +| `lsp` | `claude.lspServers` (plugin-root `.lsp.json`) | `lsp` | supported | unavailable | unavailable | unavailable | emitted (Claude half); intersection unavailable | +| `native-diagnostics` | none | `nativeDiagnostics` | unavailable (LSP `diagnostics` option only) | unavailable | unavailable | unavailable | unavailable | +| `native-extension` | none | `nativeExtension` | unavailable | unavailable | unavailable | unavailable | unavailable | +| `agent` | `src/agents` (deferred) | `agents` | unavailable — G5 deferral ([#220](https://github.com/ScriptedAlchemy/agent-bundle/pull/220)) | no row | unavailable (G5) | no row | unavailable (G5) | +| `script` | `src/scripts/**`, `scripts` config | none | emitted | emitted | emitted | emitted | emitted | +| `cli` | routed `src/cli/**` bin (#387) | `cli` | supported | supported | supported | supported | supported | + +"Emitted" in the composite column means the multi-host `plugin` bundle writes +the surface for the hosts that support it while its own capability row stays +the honest three-host intersection; inspection judges the composite by what it +emits. `native-diagnostics` and `native-extension` have no authoring surface +at all: the rows exist so the compiler's answer to "can this bundle ship a +diagnostics provider or an editor extension?" is a dated *no* per host rather +than silence. The `agent` kind has no producer until the G5 gate admits it; +Claude's `agents` row and its per-field `agents.*` rows record the deferral. + ## Distribution `agent-bundle build` makes each target directory independently distributable. diff --git a/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json b/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json index 621b56769..99eefd478 100644 --- a/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json +++ b/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json @@ -554,6 +554,20 @@ "transports": ["socket", "stdio"], "vendorsServerBinary": false }, + "nativeDiagnostics": { + "evidence": [ + "2026-09-03: the pinned plugins-reference component set recorded in this table (agents, bin, channels, commands, hooks, LSP servers, MCP servers, monitors, output styles, settings, skills, themes, userConfig, workflows) includes no standalone diagnostics-provider component; `diagnostics` appears only as an optional per-server field of the .lsp.json LSP surface (plugin.lsp.optionalFields), which the `lsp` component kind models." + ], + "reason": "The pinned Claude plugin contract (2.1.250) documents no standalone diagnostics-provider component; diagnostics reach Claude Code only through the `diagnostics` option of an `.lsp.json` language server, which is the `lsp` kind.", + "state": "unavailable" + }, + "nativeExtension": { + "evidence": [ + "2026-09-03: the pinned plugins-reference component set recorded in this table (agents, bin, channels, commands, hooks, LSP servers, MCP servers, monitors, output styles, settings, skills, themes, userConfig, workflows) contains no editor or native extension component; the pinned schemas/claude/plugin.schema.json is closed to author, channels, defaultEnabled, description, dependencies, displayName, hooks, lspServers, metadata, name, userConfig, and version, so a plugin cannot vendor or register an IDE extension." + ], + "reason": "The pinned Claude plugin contract (2.1.250) documents no editor or native extension component; Claude Code plugins are CLI-loaded component trees, and IDE integrations ship separately from the plugin manifest.", + "state": "unavailable" + }, "metadata": { "defaultEnabled": { "default": true, diff --git a/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json b/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json index 58ce9a0ea..9b2f3d85c 100644 --- a/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json +++ b/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json @@ -416,6 +416,29 @@ "state": "supported" } }, + "components": { + "lsp": { + "evidence": [ + "2026-09-03: the pinned schemas/codex/plugin.schema.json is closed to apps, author, description, homepage, hooks, interface, keywords, license, mcpServers, name, repository, skills, and version; the 2026-09-02/03 https://developers.openai.com/plugins/build/plugins evidence in this table documents skills, MCP servers, hooks, and apps as the plugin parts and no language-server component." + ], + "reason": "The pinned Codex plugin contract publishes no LSP server surface; language-server configuration reaches Claude Code only.", + "state": "unavailable" + }, + "nativeDiagnostics": { + "evidence": [ + "2026-09-03: the pinned schemas/codex/plugin.schema.json is closed to apps, author, description, homepage, hooks, interface, keywords, license, mcpServers, name, repository, skills, and version; no property or documented plugin part carries a diagnostics provider." + ], + "reason": "The pinned Codex plugin contract publishes no diagnostics-provider component; plugin-shipped diagnostics have no schema-backed authoring field, so the compiler emits none.", + "state": "unavailable" + }, + "nativeExtension": { + "evidence": [ + "2026-09-03: the pinned schemas/codex/plugin.schema.json has no extension property; the plugins-overview 'browser extensions' part recorded under plugin.overviewSurfaces.browserExtensions is host-managed with no authoring field, and no pinned Codex contract documents a plugin-shipped editor or native extension." + ], + "reason": "The pinned Codex plugin contract publishes no plugin-shipped editor or native extension component; the host-managed browser-extension part has no authoring field, so the compiler emits none.", + "state": "unavailable" + } + }, "overviewSurfaces": { "mcpUi": { "evidence": [ diff --git a/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json b/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json index 812dd82a6..b29b1fdd0 100644 --- a/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json +++ b/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json @@ -432,6 +432,27 @@ "state": "supported" } }, + "lsp": { + "evidence": [ + "2026-09-03: the pinned cursor/plugins@070189284e702e8a4d2e3cc8913994b204c5337a schemas/plugin.schema.json defines component pointers for commands, agents, skills, rules, hooks, and mcpServers only; no property names a language-server configuration, and the 2026-09-01 https://cursor.com/docs/plugins evidence above documents the same component set." + ], + "reason": "The pinned Cursor Plugin contract publishes no LSP server component; language intelligence is editor-owned and not a plugin component pointer, so language-server configuration reaches Claude Code only.", + "state": "unavailable" + }, + "nativeDiagnostics": { + "evidence": [ + "2026-09-03: the pinned cursor/plugins@070189284e702e8a4d2e3cc8913994b204c5337a schemas/plugin.schema.json defines component pointers for commands, agents, skills, rules, hooks, and mcpServers only; no property or documented plugin component carries a diagnostics provider." + ], + "reason": "The pinned Cursor Plugin contract publishes no diagnostics-provider component, so the compiler emits none.", + "state": "unavailable" + }, + "nativeExtension": { + "evidence": [ + "2026-09-03: the pinned cursor/plugins@070189284e702e8a4d2e3cc8913994b204c5337a schemas/plugin.schema.json defines component pointers for commands, agents, skills, rules, hooks, and mcpServers only; no property registers or vendors an editor extension, and the documented physical install root ~/.cursor/plugins/local/ is a plugin tree, not an extension install path." + ], + "reason": "The pinned Cursor Plugin contract publishes no plugin-shipped editor extension component; editor extensions are outside the plugin manifest at this pin, so a plugin cannot vendor or register one.", + "state": "unavailable" + }, "localInstall": { "method": "copy", "root": "~/.cursor/plugins/local/" diff --git a/packages/agent-bundle/src/adapters/capabilities/portable-1.0.0.json b/packages/agent-bundle/src/adapters/capabilities/portable-1.0.0.json index 6c8837504..13c44ea3a 100644 --- a/packages/agent-bundle/src/adapters/capabilities/portable-1.0.0.json +++ b/packages/agent-bundle/src/adapters/capabilities/portable-1.0.0.json @@ -135,6 +135,13 @@ ], "state": "supported" }, + "lsp": { + "evidence": [ + "2026-09-03: Agent Plugins 1.0.0 §7 component types define skills and MCP servers only (https://agent-plugins.org/specification); the pinned schemas/portable/plugin.schema.json is closed to $schema, name, version, description, author, homepage, repository, license, keywords, and extensions, so no manifest field or conventional directory carries a language-server configuration." + ], + "reason": "The portable Agent Plugin contract (1.0.0) defines only skills and MCP components; it has no LSP server surface.", + "state": "unavailable" + }, "manifest": "plugin.json", "manifestMetadata": { "configKey": "portable", @@ -150,6 +157,20 @@ ], "state": "supported" }, + "nativeDiagnostics": { + "evidence": [ + "2026-09-03: Agent Plugins 1.0.0 §7 component types define skills and MCP servers only (https://agent-plugins.org/specification); no section documents a diagnostics-provider component, and §8.2 client-owned reverse-domain directories are opaque to the format, so the compiler cannot claim any client consumes plugin-shipped diagnostics." + ], + "reason": "The portable Agent Plugin contract (1.0.0) defines only skills and MCP components; it has no native diagnostics surface.", + "state": "unavailable" + }, + "nativeExtension": { + "evidence": [ + "2026-09-03: Agent Plugins 1.0.0 §7 component types define skills and MCP servers only, and §8.2 reserves top-level reverse-domain directories for client-owned files without any pinned client publishing a native-extension contract for them (https://agent-plugins.org/specification); the manifest `extensions` field (§5.6) is opaque metadata, not an installable editor extension." + ], + "reason": "The portable Agent Plugin contract (1.0.0) defines only skills and MCP components; it has no native extension surface, and its reverse-domain `extensions` metadata is not one.", + "state": "unavailable" + }, "skills": true }, "specificationSections": { diff --git a/packages/agent-bundle/src/adapters/capability-state.ts b/packages/agent-bundle/src/adapters/capability-state.ts index c15a55e54..ec9893582 100644 --- a/packages/agent-bundle/src/adapters/capability-state.ts +++ b/packages/agent-bundle/src/adapters/capability-state.ts @@ -71,6 +71,38 @@ export const supportedEventRouteNamesFrom = ( .map(([event, capability]) => [event, capability.nativeEvent!]), )); +/** A pinned capability-table row: JSON imports widen the state literal, so unknown states fail closed. */ +export interface CapabilityTableRow { + readonly reason?: string; + readonly state: string; +} + +/** + * Converts one pinned table row into the shared capability-state namespace. + * `supported` and `degraded` carry the adapter's pinned evidence identity; + * `unavailable` and `prohibited` carry the table's dated reason. + */ +export const capabilityFromTableRow = ( + row: CapabilityTableRow, + evidence: CapabilityEvidence, +): CapabilityState => { + switch (row.state) { + case 'supported': + return supportedCapability(evidence); + case 'degraded': + return Object.freeze({ evidence, reason: row.reason ?? '', state: 'degraded' }); + case 'unavailable': + return unavailableCapability(row.reason ?? `The pinned ${evidence.target} contract does not support this surface.`); + case 'prohibited': + return Object.freeze({ + reason: row.reason ?? `The pinned ${evidence.target} contract prohibits this surface.`, + state: 'prohibited', + }); + default: + throw new TypeError(`Unsupported ${evidence.target} capability-table state ${JSON.stringify(row.state)}.`); + } +}; + export const capabilityStateFromSupport = ( supported: boolean, evidence: CapabilityEvidence, diff --git a/packages/agent-bundle/src/adapters/claude.ts b/packages/agent-bundle/src/adapters/claude.ts index 9f838afed..691dd9ff0 100644 --- a/packages/agent-bundle/src/adapters/claude.ts +++ b/packages/agent-bundle/src/adapters/claude.ts @@ -19,6 +19,7 @@ import { import { createTargetMcpRuntime } from '../services/mcp-runtime.ts'; import { capabilityEvidence, + capabilityFromTableRow, capabilityStateFromSupport, eventRouteCapabilitiesFrom, supportedEventRouteNamesFrom, @@ -427,7 +428,7 @@ const hookContract = Object.freeze({ wrapperSource: (entry) => nativeHookWrapperSource(entry, 'Claude'), } satisfies TargetHookContract); const metadata = Object.freeze({ - adapterRevision: '1.22.0', + adapterRevision: '1.23.0', observedVersion: capabilityTable.observedCliVersion, schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion), }); @@ -3383,6 +3384,11 @@ export const claudeAdapter: TargetAdapter = Object.freeze({ evidence, 'The pinned Claude contract does not support both required modern MCP transports.', ), + // Canonical component kinds the pinned Claude contract does not document + // (#100): diagnostics reach Claude only as an LSP server option, and no + // editor/native extension component exists. + nativeDiagnostics: capabilityFromTableRow(capabilityTable.plugin.nativeDiagnostics, evidence), + nativeExtension: capabilityFromTableRow(capabilityTable.plugin.nativeExtension, evidence), monitors: capabilityStateFromSupport( capabilityTable.plugin.monitors.commandTokens.length === 4 && ['${CLAUDE_PLUGIN_ROOT}', '${CLAUDE_PLUGIN_DATA}', '${CLAUDE_PROJECT_DIR}', '${ENV_VAR}'] diff --git a/packages/agent-bundle/src/adapters/codex.ts b/packages/agent-bundle/src/adapters/codex.ts index b49fca15d..9915f16db 100644 --- a/packages/agent-bundle/src/adapters/codex.ts +++ b/packages/agent-bundle/src/adapters/codex.ts @@ -176,7 +176,7 @@ const hookContract = Object.freeze({ wrapperSource: (entry) => nativeHookWrapperSource(entry, 'Codex'), } satisfies TargetHookContract); const metadata = Object.freeze({ - adapterRevision: '1.9.0', + adapterRevision: '1.10.0', observedVersion: capabilityTable.observedCliVersion, schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion), }); @@ -199,6 +199,7 @@ const tableCapability = (row: { readonly reason?: string; readonly state: string const hookContractTable = capabilityTable.hooks.contract; const distributionTable = capabilityTable.distribution; const overviewSurfacesTable = capabilityTable.plugin.overviewSurfaces; +const componentsTable = capabilityTable.plugin.components; const codexReleaseHookEvents: readonly string[] = capabilityTable.hooks.releaseEvents; const codexHookRules = Object.freeze({ additionalContextEvents: hookContractTable.additionalContextLimit.additionalContextEvents as readonly string[], @@ -1350,10 +1351,11 @@ export const codexAdapter: TargetAdapter = Object.freeze({ hookTrustReview: tableCapability(hookContractTable.trustReview), // The pinned Codex plugin contract documents no LSP surface at all, so // this is an absent host capability rather than a degraded one: nothing - // of Claude's `.lsp.json` is copied to the Codex manifest. - lsp: unavailableCapability( - 'The pinned Codex plugin contract publishes no LSP server surface; language-server configuration reaches Claude Code only.', - ), + // of Claude's `.lsp.json` is copied to the Codex manifest. The same + // closed manifest schema rules out the other canonical native kinds (#100). + lsp: tableCapability(componentsTable.lsp), + nativeDiagnostics: tableCapability(componentsTable.nativeDiagnostics), + nativeExtension: tableCapability(componentsTable.nativeExtension), mcp: capabilityStateFromSupport( capabilityTable.mcp.stdio && capabilityTable.mcp.streamableHttp, evidence, diff --git a/packages/agent-bundle/src/adapters/cursor.ts b/packages/agent-bundle/src/adapters/cursor.ts index aea619005..eb1569f5b 100644 --- a/packages/agent-bundle/src/adapters/cursor.ts +++ b/packages/agent-bundle/src/adapters/cursor.ts @@ -1,5 +1,4 @@ import { createTargetDiagnostics } from './diagnostics.ts'; -import type { CapabilityEvidence, CapabilityState } from '../core/capabilities.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; import { readMcpTransport, unsupportedMcpTransportDiagnostic } from '../core/mcp-transport.ts'; import { isPlainDataRecord, ownDataValue } from '../core/strict-json.ts'; @@ -16,12 +15,13 @@ import { import { createTargetMcpRuntime } from '../services/mcp-runtime.ts'; import { capabilityEvidence, + capabilityFromTableRow, capabilityStateFromSupport, eventRouteCapabilitiesFrom, cliBinCapability, supportedEventRouteNamesFrom, supportedCapability, - unavailableCapability, + type CapabilityTableRow, } from './capability-state.ts'; import capabilityTable from './capabilities/cursor-2026-08-28.json' with { type: 'json' }; import { @@ -461,30 +461,8 @@ export const cursorManifest = ( version: model.metadata.version, }); -interface CapabilityTableRow { - readonly reason?: string; - /** JSON imports widen literals; unsupported table states fail closed below. */ - readonly state: string; -} - -/** Converts one dated capability-table row into the shared four-state contract. */ -const rowCapability = (row: CapabilityTableRow, evidence: CapabilityEvidence): CapabilityState => { - switch (row.state) { - case 'supported': - return supportedCapability(evidence); - case 'degraded': - return Object.freeze({ evidence, reason: row.reason ?? 'The pinned Cursor contract degrades this surface.', state: 'degraded' }); - case 'unavailable': - return unavailableCapability(row.reason ?? 'The pinned Cursor contract does not support this surface.'); - case 'prohibited': - return Object.freeze({ reason: row.reason ?? 'The pinned Cursor contract prohibits this surface.', state: 'prohibited' }); - default: - throw new TypeError(`Unsupported Cursor capability table state ${JSON.stringify(row.state)}.`); - } -}; - const metadata = Object.freeze({ - adapterRevision: '1.9.0', + adapterRevision: '1.10.0', observedVersion: capabilityTable.observedCliVersion, schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion), }); @@ -692,7 +670,7 @@ export const cursorContractCapabilityRows = Object.freeze({ } satisfies Readonly>); const contractCapabilities = Object.freeze(Object.fromEntries( - Object.entries(cursorContractCapabilityRows).map(([capability, row]) => [capability, rowCapability(row, evidence)]), + Object.entries(cursorContractCapabilityRows).map(([capability, row]) => [capability, capabilityFromTableRow(row, evidence)]), )); export const cursorAdapter: TargetAdapter = Object.freeze({ @@ -711,12 +689,17 @@ export const cursorAdapter: TargetAdapter = Object.freeze({ ), hooks: supportedCapability(evidence), install: supportedCapability(evidence), + // Canonical component kinds with no Cursor Plugin surface (#100): the + // pinned schema has no LSP, diagnostics-provider, or extension pointer. + lsp: capabilityFromTableRow(capabilityTable.plugin.lsp, evidence), marketplace: supportedCapability(evidence), mcp: capabilityStateFromSupport( capabilityTable.mcp.stdio && capabilityTable.mcp.streamableHttp, evidence, 'The pinned Cursor Plugin contract does not support both required modern MCP transports.', ), + nativeDiagnostics: capabilityFromTableRow(capabilityTable.plugin.nativeDiagnostics, evidence), + nativeExtension: capabilityFromTableRow(capabilityTable.plugin.nativeExtension, evidence), rules: capabilityStateFromSupport( capabilityTable.plugin.rules, evidence, diff --git a/packages/agent-bundle/src/adapters/plugin.ts b/packages/agent-bundle/src/adapters/plugin.ts index ced4c38e8..88b2693e9 100644 --- a/packages/agent-bundle/src/adapters/plugin.ts +++ b/packages/agent-bundle/src/adapters/plugin.ts @@ -1,4 +1,5 @@ import { createTargetDiagnostics } from './diagnostics.ts'; +import type { CapabilityState } from '../core/capabilities.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; import { stableJson } from '../core/digest.ts'; import type { AgentBundleConfig, NormalizedHook, NormalizedPlugin } from '../core/types.ts'; @@ -234,7 +235,7 @@ const artifactValidation = deepFreeze({ }); const metadata = Object.freeze({ - adapterRevision: '1.25.0', + adapterRevision: '1.26.0', observedVersion: `${claudeAdapter.metadata.observedVersion}+${codexAdapter.metadata.observedVersion}+${cursorAdapter.metadata.observedVersion}`, // Metadata schemas must exactly match the validation contract: each host's // documents, with one shared Claude-format hook schema (the pinned Codex @@ -625,18 +626,18 @@ const compositeEventCapabilities = Object.freeze(Object.fromEntries( }), )); -const componentCapabilities = Object.freeze(Object.fromEntries( - [cliBinCapability, 'commands', 'hooks', 'mcp', 'rules', 'skills'].map((capability) => [ - capability, - unionCapabilityStates( - unionCapabilityStates( - claudeAdapter.capabilities[capability]!, - codexAdapter.capabilities[capability]!, - ), - cursorAdapter.capabilities[capability]!, - ), - ]), -)); +/** The composite emits a kind's surface when any host half does. */ +const compositeUnion = (capability: string): CapabilityState => unionCapabilityStates( + unionCapabilityStates( + claudeAdapter.capabilities[capability]!, + codexAdapter.capabilities[capability]!, + ), + cursorAdapter.capabilities[capability]!, +); + +// One shared plugin root serves every host, so the routed CLI bin is hosted +// exactly like the shared `scripts/` and `mcp/` surfaces (#387). +const cliBinUnion = compositeUnion(cliBinCapability); const codexHookContractUnifiedCapabilities = Object.freeze(Object.fromEntries([ ...codexHookContractCapabilities.map((capability) => [ @@ -702,337 +703,376 @@ const cursorOnlyCapabilities = Object.freeze(Object.fromEntries( ]), )); -export const pluginAdapter: TargetAdapter = Object.freeze({ - artifactValidation, - artifactLayout, - capabilities: Object.freeze({ - ...cursorOnlyCapabilities, - ...agentCapabilities, - ...codexHookContractUnifiedCapabilities, - ...compositeEventCapabilities, - bin: unavailableCapability( - 'The unified bundle emits the Claude-only bin directory, but the pinned Codex and Cursor contracts declare no shared plugin executable surface.', - ), - // One shared plugin root serves every host, so the routed CLI bin is - // hosted exactly like the shared `scripts/` and `mcp/` surfaces (#387). - [cliBinCapability]: componentCapabilities[cliBinCapability]!, - channels: unavailableCapability( - 'The unified bundle emits the Claude-only channels manifest field, but the pinned Codex and Cursor contracts declare no shared message-channel surface.', - ), - commands: intersectCapabilityStates( - intersectCapabilityStates(claudeAdapter.capabilities.commands!, codexAdapter.capabilities.commands!), - cursorAdapter.capabilities.commands!, - ), - interfaceAssets: intersectCapabilityStates( - codexAdapter.capabilities.interfaceAssets!, - unavailableCapability(interfaceUnifiedReason), - ), - interfaceBrandColor: intersectCapabilityStates( - codexAdapter.capabilities.interfaceBrandColor!, - unavailableCapability(interfaceUnifiedReason), - ), - interfaceCategoryCapabilities: intersectCapabilityStates( - codexAdapter.capabilities.interfaceCategoryCapabilities!, - unavailableCapability(interfaceUnifiedReason), - ), - interfaceDescriptions: intersectCapabilityStates( - codexAdapter.capabilities.interfaceDescriptions!, - unavailableCapability(interfaceUnifiedReason), - ), - interfaceIdentity: intersectCapabilityStates( - codexAdapter.capabilities.interfaceIdentity!, - unavailableCapability(interfaceUnifiedReason), - ), - interfaceStarterPrompts: intersectCapabilityStates( - codexAdapter.capabilities.interfaceStarterPrompts!, - unavailableCapability(interfaceUnifiedReason), - ), - interfaceUrls: intersectCapabilityStates( - codexAdapter.capabilities.interfaceUrls!, - unavailableCapability(interfaceUnifiedReason), - ), - claudePluginDataEnvironment: intersectCapabilityStates( - codexAdapter.capabilities.claudePluginDataEnvironment!, - unavailableCapability( - 'The pinned Cursor hook contract does not export the CLAUDE_PLUGIN_DATA compatibility variable, so the unified bundle cannot rely on it across hosts.', - ), - ), - claudePluginRootEnvironment: intersectCapabilityStates( - codexAdapter.capabilities.claudePluginRootEnvironment!, - unavailableCapability( - 'The pinned Cursor hook contract does not export the CLAUDE_PLUGIN_ROOT compatibility variable, so the unified bundle cannot rely on it across hosts.', - ), +const pluginCapabilities: Readonly> = Object.freeze({ + ...cursorOnlyCapabilities, + ...agentCapabilities, + ...codexHookContractUnifiedCapabilities, + ...compositeEventCapabilities, + bin: unavailableCapability( + 'The unified bundle emits the Claude-only bin directory, but the pinned Codex and Cursor contracts declare no shared plugin executable surface.', + ), + // One shared plugin root serves every host, so the routed CLI bin is + // hosted exactly like the shared `scripts/` and `mcp/` surfaces (#387). + [cliBinCapability]: cliBinUnion, + channels: unavailableCapability( + 'The unified bundle emits the Claude-only channels manifest field, but the pinned Codex and Cursor contracts declare no shared message-channel surface.', + ), + commands: intersectCapabilityStates( + intersectCapabilityStates(claudeAdapter.capabilities.commands!, codexAdapter.capabilities.commands!), + cursorAdapter.capabilities.commands!, + ), + interfaceAssets: intersectCapabilityStates( + codexAdapter.capabilities.interfaceAssets!, + unavailableCapability(interfaceUnifiedReason), + ), + interfaceBrandColor: intersectCapabilityStates( + codexAdapter.capabilities.interfaceBrandColor!, + unavailableCapability(interfaceUnifiedReason), + ), + interfaceCategoryCapabilities: intersectCapabilityStates( + codexAdapter.capabilities.interfaceCategoryCapabilities!, + unavailableCapability(interfaceUnifiedReason), + ), + interfaceDescriptions: intersectCapabilityStates( + codexAdapter.capabilities.interfaceDescriptions!, + unavailableCapability(interfaceUnifiedReason), + ), + interfaceIdentity: intersectCapabilityStates( + codexAdapter.capabilities.interfaceIdentity!, + unavailableCapability(interfaceUnifiedReason), + ), + interfaceStarterPrompts: intersectCapabilityStates( + codexAdapter.capabilities.interfaceStarterPrompts!, + unavailableCapability(interfaceUnifiedReason), + ), + interfaceUrls: intersectCapabilityStates( + codexAdapter.capabilities.interfaceUrls!, + unavailableCapability(interfaceUnifiedReason), + ), + claudePluginDataEnvironment: intersectCapabilityStates( + codexAdapter.capabilities.claudePluginDataEnvironment!, + unavailableCapability( + 'The pinned Cursor hook contract does not export the CLAUDE_PLUGIN_DATA compatibility variable, so the unified bundle cannot rely on it across hosts.', ), - // The Claude half emits the declaration, but neither pinned non-Claude - // manifest has a shared dependency-resolution surface. - dependencies: intersectCapabilityStates( - claudeAdapter.capabilities.dependencies!, - unavailableCapability( - 'The pinned Codex and Cursor plugin contracts publish no dependency declaration or resolution surface; manifest dependencies reach Claude Code only.', - ), - ), - nodeDependencyInstall: intersectCapabilityStates( - claudeAdapter.capabilities.nodeDependencyInstall!, - unavailableCapability( - 'The unified bundle emits compile-time host artifacts and has no shared host-owned Node dependency installation transaction.', - ), + ), + claudePluginRootEnvironment: intersectCapabilityStates( + codexAdapter.capabilities.claudePluginRootEnvironment!, + unavailableCapability( + 'The pinned Cursor hook contract does not export the CLAUDE_PLUGIN_ROOT compatibility variable, so the unified bundle cannot rely on it across hosts.', ), - yarnPnpmInstallAlternative: intersectCapabilityStates( - claudeAdapter.capabilities.yarnPnpmInstallAlternative!, - unavailableCapability( - 'The pinned Codex and Cursor contracts publish no shared Claude-style Yarn or pnpm persistent-data installation fallback.', - ), + ), + // The Claude half emits the declaration, but neither pinned non-Claude + // manifest has a shared dependency-resolution surface. + dependencies: intersectCapabilityStates( + claudeAdapter.capabilities.dependencies!, + unavailableCapability( + 'The pinned Codex and Cursor plugin contracts publish no dependency declaration or resolution surface; manifest dependencies reach Claude Code only.', ), - pluginCacheLifecycle: intersectCapabilityStates( - claudeAdapter.capabilities.pluginCacheLifecycle!, - unavailableCapability( - 'The unified bundle does not own one cross-host plugin cache, version resolution, orphan sweep, or symlink materialization lifecycle.', - ), + ), + nodeDependencyInstall: intersectCapabilityStates( + claudeAdapter.capabilities.nodeDependencyInstall!, + unavailableCapability( + 'The unified bundle emits compile-time host artifacts and has no shared host-owned Node dependency installation transaction.', ), - pluginPathSubstitution: intersectCapabilityStates( - claudeAdapter.capabilities.pluginPathSubstitution!, - unavailableCapability( - 'The pinned Codex and Cursor contracts do not share Claude path placeholders or their component-specific substitution field table.', - ), + ), + yarnPnpmInstallAlternative: intersectCapabilityStates( + claudeAdapter.capabilities.yarnPnpmInstallAlternative!, + unavailableCapability( + 'The pinned Codex and Cursor contracts publish no shared Claude-style Yarn or pnpm persistent-data installation fallback.', ), - pluginDataLifecycle: intersectCapabilityStates( - claudeAdapter.capabilities.pluginDataLifecycle!, - unavailableCapability( - 'The unified bundle cannot delete or preserve Claude persistent plugin data as one cross-host uninstall transaction.', - ), - ), - managedAllowManagedHooksOnly: intersectCapabilityStates( - claudeAdapter.capabilities.managedAllowManagedHooksOnly!, - unavailableCapability( - 'The unified bundle cannot configure a Claude-only managed hook policy, and the pinned Codex and Cursor contracts publish no shared allowManagedHooksOnly surface.', - ), - ), - managedBlockedMarketplaces: intersectCapabilityStates( - claudeAdapter.capabilities.managedBlockedMarketplaces!, - unavailableCapability( - 'The unified bundle cannot configure a Claude-only managed marketplace denylist, and the pinned Codex and Cursor contracts publish no shared blockedMarketplaces surface.', - ), + ), + pluginCacheLifecycle: intersectCapabilityStates( + claudeAdapter.capabilities.pluginCacheLifecycle!, + unavailableCapability( + 'The unified bundle does not own one cross-host plugin cache, version resolution, orphan sweep, or symlink materialization lifecycle.', ), - managedDisableCommandPluginSources: intersectCapabilityStates( - claudeAdapter.capabilities.managedDisableCommandPluginSources!, - unavailableCapability( - 'The unified bundle cannot configure Claude-only command-source policy, and the pinned Codex and Cursor contracts publish no shared disableCommandPluginSources surface.', - ), + ), + pluginPathSubstitution: intersectCapabilityStates( + claudeAdapter.capabilities.pluginPathSubstitution!, + unavailableCapability( + 'The pinned Codex and Cursor contracts do not share Claude path placeholders or their component-specific substitution field table.', ), - managedDisableSideloadFlags: intersectCapabilityStates( - claudeAdapter.capabilities.managedDisableSideloadFlags!, - unavailableCapability( - 'The unified bundle cannot configure Claude-only sideload policy, and the pinned Codex and Cursor contracts publish no shared disableSideloadFlags surface.', - ), + ), + pluginDataLifecycle: intersectCapabilityStates( + claudeAdapter.capabilities.pluginDataLifecycle!, + unavailableCapability( + 'The unified bundle cannot delete or preserve Claude persistent plugin data as one cross-host uninstall transaction.', ), - managedPluginScope: intersectCapabilityStates( - claudeAdapter.capabilities.managedPluginScope!, - unavailableCapability( - 'The unified bundle has no cross-host managed installation transaction, and the pinned Codex and Cursor contracts publish no shared managed plugin scope.', - ), + ), + managedAllowManagedHooksOnly: intersectCapabilityStates( + claudeAdapter.capabilities.managedAllowManagedHooksOnly!, + unavailableCapability( + 'The unified bundle cannot configure a Claude-only managed hook policy, and the pinned Codex and Cursor contracts publish no shared allowManagedHooksOnly surface.', ), - managedPluginSuggestions: intersectCapabilityStates( - claudeAdapter.capabilities.managedPluginSuggestions!, - unavailableCapability( - 'The unified bundle cannot configure Claude-only contextual plugin suggestions, and the pinned Codex and Cursor contracts publish no shared pluginSuggestionMarketplaces surface.', - ), + ), + managedBlockedMarketplaces: intersectCapabilityStates( + claudeAdapter.capabilities.managedBlockedMarketplaces!, + unavailableCapability( + 'The unified bundle cannot configure a Claude-only managed marketplace denylist, and the pinned Codex and Cursor contracts publish no shared blockedMarketplaces surface.', ), - managedStrictKnownMarketplaces: intersectCapabilityStates( - claudeAdapter.capabilities.managedStrictKnownMarketplaces!, - unavailableCapability( - 'The unified bundle cannot configure a Claude-only managed marketplace allowlist, and the pinned Codex and Cursor contracts publish no shared strictKnownMarketplaces surface.', - ), + ), + managedDisableCommandPluginSources: intersectCapabilityStates( + claudeAdapter.capabilities.managedDisableCommandPluginSources!, + unavailableCapability( + 'The unified bundle cannot configure Claude-only command-source policy, and the pinned Codex and Cursor contracts publish no shared disableCommandPluginSources surface.', ), - marketplaceCliLifecycle: intersectCapabilityStates( - intersectCapabilityStates( - claudeAdapter.capabilities.marketplaceCliLifecycle!, - codexAdapter.capabilities.marketplaceCliLifecycle!, - ), - unavailableCapability( - 'The unified bundle emits host marketplace documents but cannot add, list, remove, or update marketplaces as one cross-host lifecycle transaction.', - ), + ), + managedDisableSideloadFlags: intersectCapabilityStates( + claudeAdapter.capabilities.managedDisableSideloadFlags!, + unavailableCapability( + 'The unified bundle cannot configure Claude-only sideload policy, and the pinned Codex and Cursor contracts publish no shared disableSideloadFlags surface.', ), - install: unavailableCapability( - 'Plugin is a multi-host distribution profile, not one host runtime with a single installation transaction.', + ), + managedPluginScope: intersectCapabilityStates( + claudeAdapter.capabilities.managedPluginScope!, + unavailableCapability( + 'The unified bundle has no cross-host managed installation transaction, and the pinned Codex and Cursor contracts publish no shared managed plugin scope.', ), - marketplace: intersectCapabilityStates( - intersectCapabilityStates(claudeAdapter.capabilities.marketplace!, codexAdapter.capabilities.marketplace!), - cursorAdapter.capabilities.marketplace!, + ), + managedPluginSuggestions: intersectCapabilityStates( + claudeAdapter.capabilities.managedPluginSuggestions!, + unavailableCapability( + 'The unified bundle cannot configure Claude-only contextual plugin suggestions, and the pinned Codex and Cursor contracts publish no shared pluginSuggestionMarketplaces surface.', ), - marketplaceManifest: intersectCapabilityStates( - intersectCapabilityStates( - claudeAdapter.capabilities.marketplaceManifest!, - cursorAdapter.capabilities.marketplaceManifest!, - ), - unavailableCapability( - 'The unified bundle emits the Claude marketplace overlay and the Cursor marketplace document, but the pinned Codex contract does not share a completed marketplace manifest surface.', - ), + ), + managedStrictKnownMarketplaces: intersectCapabilityStates( + claudeAdapter.capabilities.managedStrictKnownMarketplaces!, + unavailableCapability( + 'The unified bundle cannot configure a Claude-only managed marketplace allowlist, and the pinned Codex and Cursor contracts publish no shared strictKnownMarketplaces surface.', ), - allowCrossMarketplaceDependenciesOn: intersectCapabilityStates( - claudeAdapter.capabilities.allowCrossMarketplaceDependenciesOn!, - unavailableCapability( - 'The unified bundle emits Claude allowCrossMarketplaceDependenciesOn, but the pinned Codex and Cursor contracts declare no shared cross-marketplace dependency allowlist.', - ), - ), - hooks: intersectCapabilityStates( - intersectCapabilityStates(claudeAdapter.capabilities.hooks!, codexAdapter.capabilities.hooks!), - cursorAdapter.capabilities.hooks!, + ), + marketplaceCliLifecycle: intersectCapabilityStates( + intersectCapabilityStates( + claudeAdapter.capabilities.marketplaceCliLifecycle!, + codexAdapter.capabilities.marketplaceCliLifecycle!, ), - // Cursor is excluded because it declares no LSP capability surface at all. - // Claude supports LSP and Codex has no LSP surface, so this intersection is - // honestly unavailable even though the Claude half still emits `.lsp.json`. - lsp: intersectCapabilityStates(claudeAdapter.capabilities.lsp!, codexAdapter.capabilities.lsp!), - manifestMetadata: intersectCapabilityStates( - intersectCapabilityStates( - claudeAdapter.capabilities.manifestMetadata!, - codexAdapter.capabilities.manifestMetadata!, - ), - cursorAdapter.capabilities.manifestMetadata!, + unavailableCapability( + 'The unified bundle emits host marketplace documents but cannot add, list, remove, or update marketplaces as one cross-host lifecycle transaction.', ), - manifestPaths: intersectCapabilityStates( - intersectCapabilityStates( - claudeAdapter.capabilities.manifestPaths!, - codexAdapter.capabilities.manifestPaths!, - ), - unavailableCapability( - 'The pinned Cursor plugin contract does not share the Codex and Claude custom manifest path rules.', - ), - ), - mcp: intersectCapabilityStates( - intersectCapabilityStates(claudeAdapter.capabilities.mcp!, codexAdapter.capabilities.mcp!), - cursorAdapter.capabilities.mcp!, + ), + install: unavailableCapability( + 'Plugin is a multi-host distribution profile, not one host runtime with a single installation transaction.', + ), + marketplace: intersectCapabilityStates( + intersectCapabilityStates(claudeAdapter.capabilities.marketplace!, codexAdapter.capabilities.marketplace!), + cursorAdapter.capabilities.marketplace!, + ), + marketplaceManifest: intersectCapabilityStates( + intersectCapabilityStates( + claudeAdapter.capabilities.marketplaceManifest!, + cursorAdapter.capabilities.marketplaceManifest!, ), - pluginDataEnvironment: intersectCapabilityStates( - codexAdapter.capabilities.pluginDataEnvironment!, - unavailableCapability( - 'The pinned Claude and Cursor hook contracts do not export the Codex-specific PLUGIN_DATA variable, so the unified bundle cannot rely on it across hosts.', - ), + unavailableCapability( + 'The unified bundle emits the Claude marketplace overlay and the Cursor marketplace document, but the pinned Codex contract does not share a completed marketplace manifest surface.', ), - pluginMcpPolicyApprovalModes: intersectCapabilityStates( - codexAdapter.capabilities.pluginMcpPolicyApprovalModes!, - unavailableCapability(mcpPolicyUnifiedReason), + ), + allowCrossMarketplaceDependenciesOn: intersectCapabilityStates( + claudeAdapter.capabilities.allowCrossMarketplaceDependenciesOn!, + unavailableCapability( + 'The unified bundle emits Claude allowCrossMarketplaceDependenciesOn, but the pinned Codex and Cursor contracts declare no shared cross-marketplace dependency allowlist.', ), - pluginMcpPolicyEnabled: intersectCapabilityStates( - codexAdapter.capabilities.pluginMcpPolicyEnabled!, - unavailableCapability(mcpPolicyUnifiedReason), + ), + hooks: intersectCapabilityStates( + intersectCapabilityStates(claudeAdapter.capabilities.hooks!, codexAdapter.capabilities.hooks!), + cursorAdapter.capabilities.hooks!, + ), + // Cursor is excluded because it declares no LSP capability surface at all. + // Claude supports LSP and Codex has no LSP surface, so this intersection is + // honestly unavailable even though the Claude half still emits `.lsp.json`. + lsp: intersectCapabilityStates(claudeAdapter.capabilities.lsp!, codexAdapter.capabilities.lsp!), + // No pinned host documents a diagnostics-provider or native extension + // component, so both canonical kinds are honestly unavailable everywhere. + nativeDiagnostics: intersectCapabilityStates( + intersectCapabilityStates( + claudeAdapter.capabilities.nativeDiagnostics!, + codexAdapter.capabilities.nativeDiagnostics!, ), - pluginMcpPolicyTools: intersectCapabilityStates( - codexAdapter.capabilities.pluginMcpPolicyTools!, - unavailableCapability(mcpPolicyUnifiedReason), + cursorAdapter.capabilities.nativeDiagnostics!, + ), + nativeExtension: intersectCapabilityStates( + intersectCapabilityStates( + claudeAdapter.capabilities.nativeExtension!, + codexAdapter.capabilities.nativeExtension!, ), - pluginRootEnvironment: intersectCapabilityStates( - codexAdapter.capabilities.pluginRootEnvironment!, - unavailableCapability( - 'The pinned Claude and Cursor hook contracts do not export the Codex-specific PLUGIN_ROOT variable, so the unified bundle cannot rely on it across hosts.', - ), + cursorAdapter.capabilities.nativeExtension!, + ), + manifestMetadata: intersectCapabilityStates( + intersectCapabilityStates( + claudeAdapter.capabilities.manifestMetadata!, + codexAdapter.capabilities.manifestMetadata!, ), - registeredMcpApps: intersectCapabilityStates( - codexAdapter.capabilities.registeredMcpApps!, - unavailableCapability( - 'The pinned Claude and Cursor plugin contracts publish no registered-MCP app mapping document; the emitted .app.json reaches Codex only.', - ), + cursorAdapter.capabilities.manifestMetadata!, + ), + manifestPaths: intersectCapabilityStates( + intersectCapabilityStates( + claudeAdapter.capabilities.manifestPaths!, + codexAdapter.capabilities.manifestPaths!, ), - monitors: unavailableCapability( - 'The unified bundle emits Claude-only experimental background monitors, but the pinned Codex and Cursor contracts declare no shared monitor surface.', + unavailableCapability( + 'The pinned Cursor plugin contract does not share the Codex and Claude custom manifest path rules.', ), - outputStyles: unavailableCapability( - 'The unified bundle emits Claude-only output styles, but the pinned Codex and Cursor contracts declare no shared output styles surface.', + ), + mcp: intersectCapabilityStates( + intersectCapabilityStates(claudeAdapter.capabilities.mcp!, codexAdapter.capabilities.mcp!), + cursorAdapter.capabilities.mcp!, + ), + pluginDataEnvironment: intersectCapabilityStates( + codexAdapter.capabilities.pluginDataEnvironment!, + unavailableCapability( + 'The pinned Claude and Cursor hook contracts do not export the Codex-specific PLUGIN_DATA variable, so the unified bundle cannot rely on it across hosts.', ), - pluginCliLifecycle: intersectCapabilityStates( - intersectCapabilityStates( - claudeAdapter.capabilities.pluginCliLifecycle!, - codexAdapter.capabilities.pluginCliLifecycle!, - ), - unavailableCapability( - 'The unified bundle emits host artifacts but cannot run Claude-only plugin creation, installation, state, inspection, update, or release commands.', - ), + ), + pluginMcpPolicyApprovalModes: intersectCapabilityStates( + codexAdapter.capabilities.pluginMcpPolicyApprovalModes!, + unavailableCapability(mcpPolicyUnifiedReason), + ), + pluginMcpPolicyEnabled: intersectCapabilityStates( + codexAdapter.capabilities.pluginMcpPolicyEnabled!, + unavailableCapability(mcpPolicyUnifiedReason), + ), + pluginMcpPolicyTools: intersectCapabilityStates( + codexAdapter.capabilities.pluginMcpPolicyTools!, + unavailableCapability(mcpPolicyUnifiedReason), + ), + pluginRootEnvironment: intersectCapabilityStates( + codexAdapter.capabilities.pluginRootEnvironment!, + unavailableCapability( + 'The pinned Claude and Cursor hook contracts do not export the Codex-specific PLUGIN_ROOT variable, so the unified bundle cannot rely on it across hosts.', ), - pluginInstallScopes: intersectCapabilityStates( - claudeAdapter.capabilities.pluginInstallScopes!, - unavailableCapability( - 'The unified bundle has no shared user, project, local, or managed installation-scope transaction across its three hosts.', - ), + ), + registeredMcpApps: intersectCapabilityStates( + codexAdapter.capabilities.registeredMcpApps!, + unavailableCapability( + 'The pinned Claude and Cursor plugin contracts publish no registered-MCP app mapping document; the emitted .app.json reaches Codex only.', ), - pluginReload: intersectCapabilityStates( - claudeAdapter.capabilities.pluginReload!, - unavailableCapability( - 'The unified bundle cannot reload or restart running host sessions, and the pinned hosts publish no shared plugin reload lifecycle.', - ), + ), + monitors: unavailableCapability( + 'The unified bundle emits Claude-only experimental background monitors, but the pinned Codex and Cursor contracts declare no shared monitor surface.', + ), + outputStyles: unavailableCapability( + 'The unified bundle emits Claude-only output styles, but the pinned Codex and Cursor contracts declare no shared output styles surface.', + ), + pluginCliLifecycle: intersectCapabilityStates( + intersectCapabilityStates( + claudeAdapter.capabilities.pluginCliLifecycle!, + codexAdapter.capabilities.pluginCliLifecycle!, ), - pluginTrustGates: intersectCapabilityStates( - claudeAdapter.capabilities.pluginTrustGates!, - unavailableCapability( - 'The unified bundle cannot accept host trust or security prompts, and the pinned hosts publish no shared plugin trust-gate transaction.', - ), + unavailableCapability( + 'The unified bundle emits host artifacts but cannot run Claude-only plugin creation, installation, state, inspection, update, or release commands.', ), - // The bundle exposes Cursor's real rules directory; the composite row is - // the honest three-host intersection, so it stays non-supported while - // Claude and Codex cannot consume rules. - rules: intersectCapabilityStates( - intersectCapabilityStates(claudeAdapter.capabilities.rules!, codexAdapter.capabilities.rules!), - cursorAdapter.capabilities.rules!, + ), + pluginInstallScopes: intersectCapabilityStates( + claudeAdapter.capabilities.pluginInstallScopes!, + unavailableCapability( + 'The unified bundle has no shared user, project, local, or managed installation-scope transaction across its three hosts.', ), - // Neither pinned non-Claude contract declares a plugin settings-defaults - // surface at all, so this intersection is honestly unavailable even - // though the Claude half still emits `settings.json`. - settings: intersectCapabilityStates( - claudeAdapter.capabilities.settings!, - unavailableCapability( - 'The pinned Codex and Cursor plugin contracts publish no plugin settings-defaults surface; plugin-root settings.json reaches Claude Code only.', - ), + ), + pluginReload: intersectCapabilityStates( + claudeAdapter.capabilities.pluginReload!, + unavailableCapability( + 'The unified bundle cannot reload or restart running host sessions, and the pinned hosts publish no shared plugin reload lifecycle.', ), - skills: intersectCapabilityStates( - intersectCapabilityStates(claudeAdapter.capabilities.skills!, codexAdapter.capabilities.skills!), - cursorAdapter.capabilities.skills!, + ), + pluginTrustGates: intersectCapabilityStates( + claudeAdapter.capabilities.pluginTrustGates!, + unavailableCapability( + 'The unified bundle cannot accept host trust or security prompts, and the pinned hosts publish no shared plugin trust-gate transaction.', ), - skillsDirectoryLspTrust: intersectCapabilityStates( - claudeAdapter.capabilities.skillsDirectoryLspTrust!, - unavailableCapability( - 'The pinned Codex and Cursor contracts publish no shared @skills-dir LSP trust gate.', - ), + ), + // The bundle exposes Cursor's real rules directory; the composite row is + // the honest three-host intersection, so it stays non-supported while + // Claude and Codex cannot consume rules. + rules: intersectCapabilityStates( + intersectCapabilityStates(claudeAdapter.capabilities.rules!, codexAdapter.capabilities.rules!), + cursorAdapter.capabilities.rules!, + ), + // Neither pinned non-Claude contract declares a plugin settings-defaults + // surface at all, so this intersection is honestly unavailable even + // though the Claude half still emits `settings.json`. + settings: intersectCapabilityStates( + claudeAdapter.capabilities.settings!, + unavailableCapability( + 'The pinned Codex and Cursor plugin contracts publish no plugin settings-defaults surface; plugin-root settings.json reaches Claude Code only.', ), - skillsDirectoryMcpApproval: intersectCapabilityStates( - claudeAdapter.capabilities.skillsDirectoryMcpApproval!, - unavailableCapability( - 'The pinned Codex and Cursor contracts publish no shared @skills-dir per-server MCP approval gate.', - ), + ), + skills: intersectCapabilityStates( + intersectCapabilityStates(claudeAdapter.capabilities.skills!, codexAdapter.capabilities.skills!), + cursorAdapter.capabilities.skills!, + ), + skillsDirectoryLspTrust: intersectCapabilityStates( + claudeAdapter.capabilities.skillsDirectoryLspTrust!, + unavailableCapability( + 'The pinned Codex and Cursor contracts publish no shared @skills-dir LSP trust gate.', ), - skillsDirectoryMonitors: intersectCapabilityStates( - claudeAdapter.capabilities.skillsDirectoryMonitors!, - unavailableCapability( - 'The pinned Codex and Cursor contracts publish no shared project-scope @skills-dir monitor policy.', - ), + ), + skillsDirectoryMcpApproval: intersectCapabilityStates( + claudeAdapter.capabilities.skillsDirectoryMcpApproval!, + unavailableCapability( + 'The pinned Codex and Cursor contracts publish no shared @skills-dir per-server MCP approval gate.', ), - skillsDirectoryPlugins: intersectCapabilityStates( - claudeAdapter.capabilities.skillsDirectoryPlugins!, - unavailableCapability( - 'The unified bundle does not install into host skills directories, and the pinned Codex and Cursor contracts publish no shared @skills-dir identity.', - ), - ), - skillsDirectoryProjectTrust: intersectCapabilityStates( - claudeAdapter.capabilities.skillsDirectoryProjectTrust!, - unavailableCapability( - 'The pinned Codex and Cursor contracts publish no shared project-scope @skills-dir workspace-trust gate.', - ), - ), - syncedPlugins: intersectCapabilityStates( - claudeAdapter.capabilities.syncedPlugins!, - unavailableCapability( - 'The pinned Codex and Cursor contracts publish no shared claude.ai-style account plugin synchronization surface.', - ), + ), + skillsDirectoryMonitors: intersectCapabilityStates( + claudeAdapter.capabilities.skillsDirectoryMonitors!, + unavailableCapability( + 'The pinned Codex and Cursor contracts publish no shared project-scope @skills-dir monitor policy.', ), - themes: unavailableCapability( - 'The unified bundle emits Claude-only experimental themes, but the pinned Codex and Cursor contracts declare no shared theme surface.', + ), + skillsDirectoryPlugins: intersectCapabilityStates( + claudeAdapter.capabilities.skillsDirectoryPlugins!, + unavailableCapability( + 'The unified bundle does not install into host skills directories, and the pinned Codex and Cursor contracts publish no shared @skills-dir identity.', ), - userConfig: unavailableCapability( - 'The unified bundle emits the Claude-only userConfig manifest field, but the pinned Codex and Cursor contracts declare no shared enable-time option surface.', + ), + skillsDirectoryProjectTrust: intersectCapabilityStates( + claudeAdapter.capabilities.skillsDirectoryProjectTrust!, + unavailableCapability( + 'The pinned Codex and Cursor contracts publish no shared project-scope @skills-dir workspace-trust gate.', ), - workflows: unavailableCapability( - 'The unified bundle emits Claude-only workflows, but the pinned Codex and Cursor contracts declare no shared workflows surface.', + ), + syncedPlugins: intersectCapabilityStates( + claudeAdapter.capabilities.syncedPlugins!, + unavailableCapability( + 'The pinned Codex and Cursor contracts publish no shared claude.ai-style account plugin synchronization surface.', ), - }), + ), + themes: unavailableCapability( + 'The unified bundle emits Claude-only experimental themes, but the pinned Codex and Cursor contracts declare no shared theme surface.', + ), + userConfig: unavailableCapability( + 'The unified bundle emits the Claude-only userConfig manifest field, but the pinned Codex and Cursor contracts declare no shared enable-time option surface.', + ), + workflows: unavailableCapability( + 'The unified bundle emits Claude-only workflows, but the pinned Codex and Cursor contracts declare no shared workflows surface.', + ), +}); + +/** + * Emission dispatch per canonical component kind (#100): every published + * intersection row stays visible to inspection (the `agents` G5 deferral keeps + * its reason), and the kinds the composite emits when any host half does + * (`lsp` rides the Claude half even though the three-host intersection stays + * unavailable) are overridden with the union so inspection reports what the + * bundle actually writes. Event routes lower through the shared bundle hook + * contract and keep the intersection judgment validation already applies. + */ +const componentCapabilities: Readonly> = Object.freeze({ + ...pluginCapabilities, + ...Object.fromEntries( + [cliBinCapability, 'commands', 'hooks', 'lsp', 'mcp', 'nativeDiagnostics', 'nativeExtension', 'rules', 'skills'] + .map((capability) => [capability, compositeUnion(capability)]), + ), +}); + +export const pluginAdapter: TargetAdapter = Object.freeze({ + artifactValidation, + artifactLayout, + capabilities: pluginCapabilities, componentCapabilities, hookContract: bundleHookContract, + // The composite plans the Claude and Codex sides from their own config + // extensions, so host-scoped declarations under those keys (for example + // `claude.lspServers`) are eligible for emission here. + lowersConfigExtensions: Object.freeze([claudeAdapter.name, codexAdapter.name]), metadata, mcpRuntime, name: pluginName, diff --git a/packages/agent-bundle/src/adapters/portable.ts b/packages/agent-bundle/src/adapters/portable.ts index 50a567979..a14122f4e 100644 --- a/packages/agent-bundle/src/adapters/portable.ts +++ b/packages/agent-bundle/src/adapters/portable.ts @@ -16,6 +16,7 @@ import { import { createTargetMcpRuntime } from '../services/mcp-runtime.ts'; import { capabilityEvidence, + capabilityFromTableRow, capabilityStateFromSupport, cliBinCapability, eventRouteCapabilitiesFrom, @@ -91,7 +92,7 @@ const schemaValidator = createAdapterValidator(); const validatePlugin = schemaValidator.compile(pluginSchema); const validateMcp = schemaValidator.compile(mcpSchema); const metadata = Object.freeze({ - adapterRevision: '1.6.0', + adapterRevision: '1.7.0', observedVersion: capabilityTable.observedSpecificationVersion, schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.version), }); @@ -630,6 +631,8 @@ export const portableAdapter: TargetAdapter = Object.freeze({ extensionDirectories: unavailableCapability(capabilityTable.plugin.extensionDirectories.reason), hooks: unavailableCapability('Agent Plugins 1.0.0 does not define a hooks component.'), install: unavailableCapability(capabilityTable.install.reason), + // Canonical component kinds outside the Agent Plugins 1.0.0 component set (#100). + lsp: capabilityFromTableRow(capabilityTable.plugin.lsp, evidence), manifestExtensions: capabilityStateFromSupport( capabilityTable.plugin.extensions.state === 'supported', evidence, @@ -647,6 +650,8 @@ export const portableAdapter: TargetAdapter = Object.freeze({ 'Agent Plugins 1.0.0 does not support both required modern MCP transports.', ), mcpLegacySse: unavailableCapability(capabilityTable.mcp.legacySse.reason), + nativeDiagnostics: capabilityFromTableRow(capabilityTable.plugin.nativeDiagnostics, evidence), + nativeExtension: capabilityFromTableRow(capabilityTable.plugin.nativeExtension, evidence), rules: unavailableCapability( 'The portable Agent Plugin contract (1.0.0) defines only skills and MCP components; it has no rules surface.', ), diff --git a/packages/agent-bundle/src/adapters/registry.ts b/packages/agent-bundle/src/adapters/registry.ts index 8bdc2f827..b02eecd37 100644 --- a/packages/agent-bundle/src/adapters/registry.ts +++ b/packages/agent-bundle/src/adapters/registry.ts @@ -363,6 +363,20 @@ const snapshotNativeHookSource = (adapter: TargetAdapter): NativeHookSource | un return source; }; +/** + * The registry is the boundary where unchecked JavaScript adapters are + * validated, so a malformed `lowersConfigExtensions` fails registration + * instead of throwing later inside normalization. + */ +const snapshotLowersConfigExtensions = (adapter: TargetAdapter): readonly string[] => { + const declared = adapter.lowersConfigExtensions; + if (declared === undefined) return Object.freeze([]); + if (!Array.isArray(declared) || declared.some((key) => typeof key !== 'string' || key.trim().length === 0)) { + throw new Error(`Target adapter "${adapter.name}" lowersConfigExtensions must be an array of nonempty extension keys.`); + } + return Object.freeze([...new Set(declared)]); +}; + const snapshotBinSource = (adapter: TargetAdapter): BinSource | undefined => { const source = adapter.binSource; if (source !== undefined && typeof source !== 'function') { @@ -473,6 +487,7 @@ export class TargetRegistry implements NormalizationTargetRegistry { readonly #defaults: string[] = []; readonly #extensions = new Map(); readonly #hookContracts = new Map(); + readonly #lowersConfigExtensions = new Map(); readonly #metadata = new Map(); readonly #mcpRuntimes = new Map(); readonly #nativeHookSources = new Map(); @@ -497,8 +512,10 @@ export class TargetRegistry implements NormalizationTargetRegistry { const hookContract = snapshotHookContract(adapter); const mcpRuntime = snapshotMcpRuntime(adapter); const artifactLayout = snapshotArtifactLayout(adapter, hookContract, mcpRuntime); + const lowersConfigExtensions = snapshotLowersConfigExtensions(adapter); this.#adapters.set(adapter.name, adapter); + this.#lowersConfigExtensions.set(adapter.name, lowersConfigExtensions); this.#artifactValidations.set(adapter.name, artifactValidation); this.#artifactLayouts.set(adapter.name, artifactLayout); this.#metadata.set(adapter.name, metadata); @@ -685,6 +702,13 @@ export class TargetRegistry implements NormalizationTargetRegistry { return adapter === undefined ? undefined : (adapter.componentCapabilities ?? adapter.capabilities)[capability]; } + lowersConfigExtension(name: string, key: string): boolean { + if (!this.#adapters.has(name)) return false; + // Ownership comes from the snapshots taken at registration, never from + // the live adapter object an unchecked JavaScript caller could mutate. + return this.#extensions.get(key)?.target === name || (this.#lowersConfigExtensions.get(name) ?? []).includes(key); + } + supports(name: string, capability: string): boolean { return capabilityIsSupported(this.capabilityState(name, capability)); } diff --git a/packages/agent-bundle/src/adapters/types.ts b/packages/agent-bundle/src/adapters/types.ts index 9c295f70b..57344e369 100644 --- a/packages/agent-bundle/src/adapters/types.ts +++ b/packages/agent-bundle/src/adapters/types.ts @@ -525,6 +525,14 @@ export interface TargetAdapter { /** Per-component-kind emission dispatch used by inspect skip accounting; defaults to `capabilities`. */ readonly componentCapabilities?: Readonly>; readonly configExtension?: TargetConfigExtension; + /** + * Config extension keys this adapter lowers besides its own (a composite + * that plans other hosts' sides). Host-scoped declarations such as + * `.lspServers` are eligible for emission only on adapters that lower + * that key, so an opaque declaration never counts as emitted by a host whose + * planner never reads it. + */ + readonly lowersConfigExtensions?: readonly string[]; readonly hookContract?: TargetHookContract; readonly metadata: TargetAdapterMetadata; readonly mcpRuntime?: TargetMcpRuntimeContract; diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index 161548cdf..e5321ef27 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -3,7 +3,7 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { join, resolve } from 'node:path'; import { promisify } from 'node:util'; -import { capabilityIsSupported, cliBinCapability, unavailableCapability } from './adapters/capability-state.ts'; +import { unavailableCapability } from './adapters/capability-state.ts'; import { createDefaultRegistry, TargetRegistry } from './adapters/registry.ts'; import type { TargetArtifactEntry, TargetHookEntry } from './adapters/types.ts'; import { build as buildArtifact, type BuildResult } from './build/build.ts'; @@ -15,6 +15,11 @@ import { type PackOutput, } from './build/pack-inventory.ts'; import type { CapabilityEvidence, CapabilityState } from './core/capabilities.ts'; +import { + agentComponentKinds, + componentKindCapabilityName, + type AgentComponentKind, +} from './core/components.ts'; import { isInsideOrEqual } from './core/paths.ts'; import { stateDefinitionProjection, @@ -275,7 +280,15 @@ export interface ValidateResult { export type InspectionSkipReason = 'excluded-by-targets' | 'unsupported-capability'; -export type InspectionComponentKind = 'cli' | 'command' | 'hook' | 'mcp-app' | 'mcp-server' | 'rule' | 'script' | 'skill'; +export type { AgentComponentKind } from './core/components.ts'; +export { agentComponentKinds, componentKindCapability, componentKindCapabilityName } from './core/components.ts'; + +/** + * The canonical component kinds inspection accounts for (#100). `hook` is a + * config-declared hook escape hatch; `event-route` is a filesystem + * `src/events` route judged per canonical event (`event:` rows). + */ +export type InspectionComponentKind = AgentComponentKind; /** * The target's own four-state judgment of the capability a component needs, @@ -307,10 +320,27 @@ export interface InspectionSkippedComponent { readonly reason: InspectionSkipReason; } +/** + * The target's judgment of one canonical component kind, whether or not the + * project declares a component of that kind: `capability` is the host's own + * four-state row for the kind (absent for `script`, which needs no host + * surface, and for `event-route`, whose rows are per canonical event and + * travel on each component instead), and the counts are this target's + * selected and omitted components of the kind. + */ +export interface InspectionComponentKindReport { + readonly capability?: InspectionComponentCapability; + readonly kind: InspectionComponentKind; + readonly selected: number; + readonly skipped: number; +} + export interface InspectionPlan { readonly diagnostics: readonly Diagnostic[]; readonly entries: readonly TargetArtifactEntry[]; readonly hookEntries: readonly TargetHookEntry[]; + /** Every canonical component kind with this target's judgment and counts, in kind order. */ + readonly kinds: readonly InspectionComponentKindReport[]; /** Components this target emits, in the same deterministic order as `skipped`. */ readonly selected: readonly InspectionSelectedComponent[]; readonly skipped: readonly InspectionSkippedComponent[]; @@ -596,23 +626,51 @@ interface InspectableComponent { readonly targets: readonly string[]; } +const fixedKindComponent = ( + kind: Exclude, + component: { readonly id: string; readonly name: string; readonly targets: readonly string[] }, +): InspectableComponent => { + const capability = componentKindCapabilityName(kind); + return { + ...(capability === undefined ? {} : { capability }), + id: component.id, + kind, + name: component.name, + targets: component.targets, + }; +}; + +/** + * Every project component in canonical-kind terms. Config-declared hooks stay + * `hook` (judged by the host's `hooks` row); filesystem event routes are the + * distinct `event-route` kind, judged by the host's row for their canonical + * event (#258 matrix) so `inspect` reports them separately. The `agent` kind + * has no producer while the G5 deferral (#220) holds. + */ const inspectableComponents = (model: NormalizedPlugin): readonly InspectableComponent[] => [ // The routed CLI bin is offered to every selected target; the host's `cli` // capability row decides whether the artifact hosts it (#387). - ...routedCliBins(model).map((bin) => ({ - capability: cliBinCapability, + ...routedCliBins(model).map((bin) => fixedKindComponent('cli', { id: bin.id, - kind: 'cli' as const, name: bin.name, targets: model.targets.map((target) => target.name), })), - ...(model.commands ?? []).map((command) => ({ capability: 'commands', id: command.id, kind: 'command' as const, name: command.name, targets: command.targets })), - ...model.hooks.map((hook) => ({ capability: 'hooks', id: hook.id, kind: 'hook' as const, name: hook.event, targets: hook.targets })), - ...(model.mcpApps ?? []).map((app) => ({ capability: 'mcp', id: app.id, kind: 'mcp-app' as const, name: app.name, targets: app.targets })), - ...model.mcpServers.map((server) => ({ capability: 'mcp', id: server.id, kind: 'mcp-server' as const, name: server.name, targets: server.targets })), - ...(model.rules ?? []).map((rule) => ({ capability: 'rules', id: rule.id, kind: 'rule' as const, name: rule.name, targets: rule.targets })), - ...model.scripts.map((script) => ({ id: script.id, kind: 'script' as const, name: script.name, targets: script.targets })), - ...model.skills.map((skill) => ({ capability: 'skills', id: skill.id, kind: 'skill' as const, name: skill.name, targets: skill.targets })), + ...(model.commands ?? []).map((command) => fixedKindComponent('command', command)), + ...model.hooks.map((hook) => hook.eventRoute === undefined + ? fixedKindComponent('hook', { id: hook.id, name: hook.event, targets: hook.targets }) + : { + capability: `event:${hook.eventRoute.event}`, + id: hook.id, + kind: 'event-route' as const, + name: hook.eventRoute.event, + targets: hook.targets, + }), + ...(model.lspServers ?? []).map((server) => fixedKindComponent('lsp', server)), + ...(model.mcpApps ?? []).map((app) => fixedKindComponent('mcp-app', app)), + ...model.mcpServers.map((server) => fixedKindComponent('mcp-server', server)), + ...(model.rules ?? []).map((rule) => fixedKindComponent('rule', rule)), + ...model.scripts.map((script) => fixedKindComponent('script', script)), + ...model.skills.map((skill) => fixedKindComponent('skill', skill)), ]; /** @@ -662,11 +720,60 @@ const capabilityContract = (state: CapabilityState): CapabilityState => { const capabilityEvidenceContract = (evidence: CapabilityEvidence): CapabilityEvidence => Object.freeze({ observedVersion: evidence.observedVersion, target: evidence.target }); +/** + * Whether the target's judgment lets a component ship. Event routes follow the + * validation rule in `config/validate.ts`, which admits a `degraded` `event:*` + * row (the route lowers with a documented limitation), so an emitted degraded + * route is accounted as selected rather than omitted; every other kind ships + * only on a `supported` row. + */ +const admitsComponent = (kind: InspectionComponentKind, capability: CapabilityState): boolean => { + switch (capability.state) { + case 'supported': + return true; + case 'degraded': + return kind === 'event-route'; + case 'unavailable': + case 'prohibited': + return false; + default: { + const exhaustive: never = capability; + throw new Error(`Unhandled capability state ${JSON.stringify(exhaustive)}`); + } + } +}; + interface AccountedComponents { + readonly kinds: readonly InspectionComponentKindReport[]; readonly selected: readonly InspectionSelectedComponent[]; readonly skipped: readonly InspectionSkippedComponent[]; } +/** + * The per-kind matrix: every canonical kind, the target's row for it, and how + * many components of the kind this target selected and omitted. Kinds the + * project never declares still report the host's judgment, so a host with no + * `lsp`, `native-diagnostics`, or `native-extension` surface says so in its + * own words rather than by silence. + */ +const componentKindReports = ( + target: string, + capabilities: Readonly>, + selected: readonly InspectionSelectedComponent[], + skipped: readonly InspectionSkippedComponent[], +): readonly InspectionComponentKindReport[] => Object.freeze(agentComponentKinds.map((kind) => { + const capabilityName = componentKindCapabilityName(kind); + const capability = capabilityName === undefined + ? undefined + : componentCapabilityFor({ capability: capabilityName, id: kind, kind, name: kind, targets: [] }, target, capabilities); + return Object.freeze({ + ...(capability === undefined ? {} : { capability }), + kind, + selected: selected.filter((component) => component.kind === kind).length, + skipped: skipped.filter((component) => component.kind === kind).length, + }); +})); + /** * Splits the project's components into the ones this target emits and the * ones it omits. Author exclusion (`targets`) is reported before the host's @@ -690,13 +797,17 @@ const accountComponentsFor = ( }; if (!component.targets.includes(target)) { skipped.push(Object.freeze({ ...identity, reason: 'excluded-by-targets' satisfies InspectionSkipReason })); - } else if (capability !== undefined && !capabilityIsSupported(capability)) { + } else if (capability !== undefined && !admitsComponent(component.kind, capability)) { skipped.push(Object.freeze({ ...identity, reason: 'unsupported-capability' satisfies InspectionSkipReason })); } else { selected.push(Object.freeze(identity)); } } - return { selected: Object.freeze(selected), skipped: Object.freeze(skipped) }; + return { + kinds: componentKindReports(target, capabilities, selected, skipped), + selected: Object.freeze(selected), + skipped: Object.freeze(skipped), + }; }; const inspectState = (model: NormalizedPlugin): StateInspection => { @@ -747,6 +858,7 @@ export const inspect = async (options: InspectOptions): Promise = diagnostics: freezeDiagnostics(plan.diagnostics), entries: Object.freeze([...plan.entries]), hookEntries: Object.freeze([...(plan.hookEntries ?? [])]), + kinds: accounted.kinds, selected: accounted.selected, skipped: accounted.skipped, target: target.name, diff --git a/packages/agent-bundle/src/cli.ts b/packages/agent-bundle/src/cli.ts index e0b5030f5..519a61a09 100644 --- a/packages/agent-bundle/src/cli.ts +++ b/packages/agent-bundle/src/cli.ts @@ -372,6 +372,14 @@ const writeHumanInspect = (output: Output, result: Awaited report.capability !== undefined && report.capability.state !== 'supported') + .map((report) => `${report.kind} (${report.capability!.state})`); + if (unsupportedKinds.length > 0) { + output.write(` kinds this host cannot emit: ${unsupportedKinds.join(', ')}\n`); + } } }; diff --git a/packages/agent-bundle/src/config/normalize.ts b/packages/agent-bundle/src/config/normalize.ts index dd08072ae..aef037c87 100644 --- a/packages/agent-bundle/src/config/normalize.ts +++ b/packages/agent-bundle/src/config/normalize.ts @@ -6,6 +6,8 @@ import { basename, dirname, extname, posix, relative, resolve, sep, win32 } from import { digest } from '../core/digest.ts'; import { isErrno } from '../core/errors.ts'; import { deepFreeze } from '../core/freeze.ts'; +import { capabilityIsSupported } from '../adapters/capability-state.ts'; +import { componentKindCapabilityName } from '../core/components.ts'; import { isInside } from '../core/paths.ts'; import { defaultGeneratedRuntime, @@ -44,6 +46,7 @@ import type { NormalizedHostBinFile, NormalizedHostPayloadDirectory, NormalizedLibEntry, + NormalizedLspServer, NormalizedMcpApp, NormalizedMcpServer, NormalizedNativeHook, @@ -1055,6 +1058,66 @@ const normalizeExtensions = ( return deepFreeze(extensions); }; +/** + * Enumerates `lsp` components (#100) from every selected host extension's + * `lspServers` record. Server configuration stays opaque here — the declaring + * adapter validates and lowers it — so a malformed record yields no component + * and the adapter's own diagnostics explain it. The declaration is host-scoped: + * a component targets only the selected adapters that lower its extension key + * (the declaring host and composites that plan that host's side), so a host + * whose planner never reads the key is excluded by the declaration and its + * pinned `lsp` row still explains the omission in inspection. + */ +/** The emission judgment for one component kind: the adapter's component override when published, else its top-level row. */ +const hostsComponent = (registry: NormalizationTargetRegistry, target: string, capability: string): boolean => { + const state = registry.componentCapabilityState?.(target, capability) ?? registry.capabilityState?.(target, capability); + return state === undefined ? registry.supports(target, capability) : capabilityIsSupported(state); +}; + +/** + * Total, injective escape for one `lsp:` id segment: `%` first, then the `:` + * separator. Unlike `encodeURIComponent` it accepts every JavaScript string + * (lone surrogates included), so a computed key can never abort normalization. + */ +const lspIdSegment = (segment: string): string => segment.replaceAll('%', '%25').replaceAll(':', '%3A'); + +const normalizeLspServers = ( + extensions: Readonly>, + targetNames: readonly string[], + registry: NormalizationTargetRegistry, +): readonly NormalizedLspServer[] => { + const servers: NormalizedLspServer[] = []; + for (const extension of Object.values(extensions)) { + if (!isRecord(extension.value)) continue; + const declared = extension.value['lspServers']; + if (!isRecord(declared)) continue; + // The declaring host always judges its own declaration; a composite that + // lowers the extension inherits it only when the declaring host can lower + // LSP servers at all — a composite planning a host with no LSP surface + // (Codex) emits nothing for that host's `lspServers`. + const declaringHostLowersLsp = hostsComponent(registry, extension.target, componentKindCapabilityName('lsp')!); + const targets = targetNames.filter((target) => target === extension.target || ( + declaringHostLowersLsp && + registry.lowersConfigExtension !== undefined && + registry.lowersConfigExtension(target, extension.key) + )); + for (const name of Object.keys(declared).sort((left, right) => left.localeCompare(right))) { + servers.push({ + declaredBy: extension.key, + // Extension keys and server names are unrestricted on advanced + // adapters, so each segment escapes the separator to keep the tuple + // unambiguous (`a` + `b:c` never collides with `a:b` + `c`); the + // common `lsp:claude:typescript` shape is unchanged. + id: `lsp:${lspIdSegment(extension.key)}:${lspIdSegment(name)}`, + name, + provenance: { ...extension.provenance }, + targets, + }); + } + } + return servers.sort((left, right) => left.id.localeCompare(right.id)); +}; + const selectedTargetNames = ( loaded: LoadedConfig, registry: NormalizationTargetRegistry, @@ -1256,15 +1319,18 @@ export const normalizeProject = async ( loaded.configPath, discovered.routeGraph?.cli, ); + const extensions = normalizeExtensions(loaded, registry, configProvenance); + const lspServers = normalizeLspServers(extensions, targetNames, registry); const model: NormalizedPlugin = { ...(assets.length === 0 ? {} : { assets }), ...(commands.length === 0 ? {} : { commands }), ...(loaded.config.marketplace === true ? { marketplace: true as const } : {}), - extensions: normalizeExtensions(loaded, registry, configProvenance), + extensions, ...(hostBins.length === 0 ? {} : { hostBins }), ...(hostOutputStyles.length === 0 ? {} : { hostOutputStyles }), ...(hostWorkflows.length === 0 ? {} : { hostWorkflows }), ...(layouts.length === 0 ? {} : { layouts }), + ...(lspServers.length === 0 ? {} : { lspServers }), metadata: { ...(typeof description === 'string' ? { description } : {}), id: `plugin:${loaded.config.plugin.name}`, diff --git a/packages/agent-bundle/src/config/validate.ts b/packages/agent-bundle/src/config/validate.ts index baa8dcea3..332463410 100644 --- a/packages/agent-bundle/src/config/validate.ts +++ b/packages/agent-bundle/src/config/validate.ts @@ -1324,7 +1324,11 @@ const validateEventRoutes = ( continue; } const capabilityName = `event:${route.event}`; - const capability = registry.capabilityState?.(target, capabilityName); + // Admission uses the same judgment that decides emission and that + // `inspect` reports: the adapter's component override when published, + // else its top-level row. + const capability = registry.componentCapabilityState?.(target, capabilityName) + ?? registry.capabilityState?.(target, capabilityName); if (capability === undefined) { if (registry.supports(target, capabilityName)) continue; diagnostics.push({ @@ -2229,6 +2233,7 @@ export const validateModel = ( ...(model.commands ?? []), ...(model.rules ?? []), ...model.hooks, + ...(model.lspServers ?? []), ...model.mcpServers, ...(model.mcpApps ?? []), ...model.scripts, diff --git a/packages/agent-bundle/src/core/components.ts b/packages/agent-bundle/src/core/components.ts new file mode 100644 index 000000000..888fe7f2d --- /dev/null +++ b/packages/agent-bundle/src/core/components.ts @@ -0,0 +1,78 @@ +/** + * The canonical host-neutral component model (#100). Every project component + * the compiler accounts for belongs to exactly one kind, and every kind that + * needs a host surface names the capability row a target adapter must publish + * for it, so `inspect` can explain each host's judgment in the host's words. + * + * `agent` is part of the canonical model but stays G5-deferred (#107 rev 3, + * PR #220): no compiler path produces an `agent` component until a later + * evidence-backed increment admits it. The row name is still published so the + * deferral reads as a dated host judgment rather than a missing kind. `cli` is + * the routed CLI bin (#387), hosted where the target's `cli` row allows. + */ +export type AgentComponentKind = + | 'agent' + | 'cli' + | 'command' + | 'event-route' + | 'hook' + | 'lsp' + | 'mcp-app' + | 'mcp-server' + | 'native-diagnostics' + | 'native-extension' + | 'rule' + | 'script' + | 'skill'; + +/** + * How a kind is judged against a target: `capability` names one fixed + * capability row; `per-component` kinds (event routes) carry their own row per + * component (`event:`); `none` kinds (scripts) need no host + * surface at all. + */ +export type AgentComponentKindCapability = + | { readonly capability: string; readonly mode: 'fixed' } + | { readonly mode: 'none' } + | { readonly mode: 'per-component' }; + +/** A Record over the union so a new kind cannot be added without judging it here. */ +const componentKindCapabilities: Readonly> = Object.freeze({ + agent: Object.freeze({ capability: 'agents', mode: 'fixed' as const }), + cli: Object.freeze({ capability: 'cli', mode: 'fixed' as const }), + command: Object.freeze({ capability: 'commands', mode: 'fixed' as const }), + 'event-route': Object.freeze({ mode: 'per-component' as const }), + hook: Object.freeze({ capability: 'hooks', mode: 'fixed' as const }), + lsp: Object.freeze({ capability: 'lsp', mode: 'fixed' as const }), + 'mcp-app': Object.freeze({ capability: 'mcp', mode: 'fixed' as const }), + 'mcp-server': Object.freeze({ capability: 'mcp', mode: 'fixed' as const }), + 'native-diagnostics': Object.freeze({ capability: 'nativeDiagnostics', mode: 'fixed' as const }), + 'native-extension': Object.freeze({ capability: 'nativeExtension', mode: 'fixed' as const }), + rule: Object.freeze({ capability: 'rules', mode: 'fixed' as const }), + script: Object.freeze({ mode: 'none' as const }), + skill: Object.freeze({ capability: 'skills', mode: 'fixed' as const }), +}); + +/** Every canonical kind, sorted for stable inspection output. */ +export const agentComponentKinds: readonly AgentComponentKind[] = Object.freeze( + (Object.keys(componentKindCapabilities) as AgentComponentKind[]).sort((left, right) => left.localeCompare(right)), +); + +export const componentKindCapability = (kind: AgentComponentKind): AgentComponentKindCapability => + componentKindCapabilities[kind]; + +/** The fixed capability row a kind needs, or undefined for per-component and capability-free kinds. */ +export const componentKindCapabilityName = (kind: AgentComponentKind): string | undefined => { + const judgment = componentKindCapabilities[kind]; + switch (judgment.mode) { + case 'fixed': + return judgment.capability; + case 'none': + case 'per-component': + return undefined; + default: { + const exhaustive: never = judgment; + throw new TypeError(`Unknown component kind capability mode ${JSON.stringify(exhaustive)}.`); + } + } +}; diff --git a/packages/agent-bundle/src/core/types.ts b/packages/agent-bundle/src/core/types.ts index 2fe09fe5a..add0b254e 100644 --- a/packages/agent-bundle/src/core/types.ts +++ b/packages/agent-bundle/src/core/types.ts @@ -582,6 +582,24 @@ export interface NormalizedConfigExtension { readonly value: unknown; } +/** + * One language-server component (`lsp` kind, #100). LSP servers are declared + * through a host config extension's `lspServers` record (today only Claude + * documents such a surface: `claude.lspServers` → plugin-root `.lsp.json`). + * The component is still accounted against every selected target so each + * host's `lsp` capability row explains emission or omission; the declaring + * host keeps lowering and validating the server configuration itself. + */ +export interface NormalizedLspServer { + /** The config extension key that declared the server (for example `claude`). */ + readonly declaredBy: string; + readonly id: string; + readonly name: string; + readonly provenance: SourceProvenance; + /** Selected targets whose adapter lowers the declaring extension; other hosts are excluded by the declaration. */ + readonly targets: readonly string[]; +} + /** The selected runtime floor for generated executables, written to artifact metadata. */ export interface NormalizedRuntime { readonly node: string; @@ -627,6 +645,12 @@ export interface NormalizedPlugin { /** Adapter-declared host-native workflow directories, enumerated during normalization. */ readonly hostWorkflows?: readonly NormalizedHostPayloadDirectory[]; readonly hooks: readonly NormalizedHook[]; + /** + * Language-server components declared through host config extensions. + * Present only when a selected host extension declares `lspServers`; + * optional so hand-constructed models predating the kind remain valid. + */ + readonly lspServers?: readonly NormalizedLspServer[]; readonly marketplace?: true; readonly metadata: NormalizedMetadata; readonly mcpServers: readonly NormalizedMcpServer[]; @@ -717,6 +741,8 @@ export interface NormalizationTargetRegistry { configExtensions(): readonly NormalizationConfigExtension[]; defaultTargetNames(): readonly string[]; has(name: string): boolean; + /** True when the target's adapter reads the config extension `key` (its own key or a declared composite side). */ + lowersConfigExtension?(name: string, key: string): boolean; nativeHookSources?( config: Readonly, targetNames: readonly string[], diff --git a/packages/agent-bundle/tests/adapter-capability-states.test.ts b/packages/agent-bundle/tests/adapter-capability-states.test.ts index ca2cd7c55..4ae777237 100644 --- a/packages/agent-bundle/tests/adapter-capability-states.test.ts +++ b/packages/agent-bundle/tests/adapter-capability-states.test.ts @@ -206,6 +206,68 @@ it('reports Claude LSP support and honest unavailable composite coverage', () => expect(registry.supports('plugin', 'lsp')).toBe(false); }); +it('publishes a dated four-state lsp row on every adapter so no host is judged by silence (#100)', () => { + const registry = createDefaultRegistry(); + expect(registry.get('cursor').capabilities.lsp).toEqual({ + reason: cursorCapabilityTable.plugin.lsp.reason, + state: 'unavailable', + }); + expect(cursorCapabilityTable.plugin.lsp.evidence[0]).toMatch(/^2026-09-03: .*plugin\.schema\.json/u); + expect(registry.get('portable').capabilities.lsp).toEqual({ + reason: 'The portable Agent Plugin contract (1.0.0) defines only skills and MCP components; it has no LSP server surface.', + state: 'unavailable', + }); + expect(registry.get('codex').capabilities.lsp).toEqual({ + reason: codexCapabilityTable.plugin.components.lsp.reason, + state: 'unavailable', + }); + // Emission dispatch: the composite still writes Claude's `.lsp.json`, so + // inspection judges the `lsp` kind by the union while the three-host + // intersection above stays honestly unavailable. + expect(registry.get('plugin').componentCapabilities?.lsp).toMatchObject({ + evidence: { target: 'claude' }, + state: 'supported', + }); +}); + +it('records dated unavailable native-diagnostics and native-extension rows on every host and the composite (#100)', () => { + const registry = createDefaultRegistry(); + const tables = { + claude: claudeCapabilityTable.plugin, + codex: codexCapabilityTable.plugin.components, + cursor: cursorCapabilityTable.plugin, + } as const; + for (const capability of ['nativeDiagnostics', 'nativeExtension'] as const) { + for (const [target, table] of Object.entries(tables)) { + const row = table[capability]; + expect(row.state).toBe('unavailable'); + expect(row.evidence.length).toBeGreaterThan(0); + for (const entry of row.evidence) expect(entry).toMatch(/^2026-09-03: /u); + expect(registry.get(target).capabilities[capability]).toEqual({ reason: row.reason, state: 'unavailable' }); + expect(registry.supports(target, capability)).toBe(false); + } + expect(registry.get('portable').capabilities[capability]).toMatchObject({ + reason: expect.stringContaining('Agent Plugin contract (1.0.0)'), + state: 'unavailable', + }); + expect(registry.get('plugin').capabilities[capability]).toMatchObject({ state: 'unavailable' }); + expect(registry.get('plugin').componentCapabilities?.[capability]).toMatchObject({ state: 'unavailable' }); + } + // Claude's row points at the LSP `diagnostics` option rather than inventing a component. + expect(claudeCapabilityTable.plugin.nativeDiagnostics.reason).toContain('`lsp` kind'); + expect(claudeCapabilityTable.plugin.lsp.optionalFields).toContain('diagnostics'); +}); + +it('judges composite event routes by the same intersection validation applies (#100 event-route kind)', () => { + const registry = createDefaultRegistry(); + const plugin = registry.get('plugin'); + for (const [capability, state] of Object.entries(plugin.capabilities)) { + if (!capability.startsWith('event:')) continue; + expect(plugin.componentCapabilities?.[capability]).toEqual(state); + } + expect(plugin.componentCapabilities?.['event:session/start']).toMatchObject({ state: 'supported' }); +}); + it('reports Claude bin support without inventing coverage on other native hosts', () => { const registry = createDefaultRegistry(); @@ -1010,6 +1072,31 @@ it('publishes the routed CLI bin capability with its bin layout on every built-i expect(registry.hostsComponent('unknown-target', 'cli')).toBe(false); }); +it('validates lowersConfigExtensions at registration and answers extension lowering per target (#100)', () => { + const source = createDefaultRegistry().get('cursor'); + for (const malformedValue of ['claude', ['claude', 42], [' ']]) { + expect(() => new TargetRegistry().register({ + ...source, + lowersConfigExtensions: malformedValue as never, + })).toThrow(/lowersConfigExtensions must be an array of nonempty extension keys/u); + } + const registry = createDefaultRegistry(); + // Own key, declared composite sides, and nothing else. + expect(registry.lowersConfigExtension('claude', 'claude')).toBe(true); + expect(registry.lowersConfigExtension('plugin', 'claude')).toBe(true); + expect(registry.lowersConfigExtension('plugin', 'codex')).toBe(true); + expect(registry.lowersConfigExtension('cursor', 'claude')).toBe(false); + expect(registry.lowersConfigExtension('portable', 'claude')).toBe(false); + expect(registry.lowersConfigExtension('missing', 'claude')).toBe(false); + // Ownership is answered from the registration snapshot: mutating a live + // adapter's configExtension afterwards changes nothing. + const mutable = { ...createDefaultRegistry().get('cursor'), configExtension: { key: 'mutable' }, name: 'mutable-host' }; + const snapshotted = new TargetRegistry().register(mutable); + mutable.configExtension.key = 'renamed'; + expect(snapshotted.lowersConfigExtension('mutable-host', 'mutable')).toBe(true); + expect(snapshotted.lowersConfigExtension('mutable-host', 'renamed')).toBe(false); +}); + it('rejects a malformed inspection component capability when the adapter registers', () => { const source = createDefaultRegistry().get('cursor'); diff --git a/packages/agent-bundle/tests/adapter-metadata.test.ts b/packages/agent-bundle/tests/adapter-metadata.test.ts index d9f6579c2..bc2a0e864 100644 --- a/packages/agent-bundle/tests/adapter-metadata.test.ts +++ b/packages/agent-bundle/tests/adapter-metadata.test.ts @@ -52,7 +52,7 @@ it('records exact immutable metadata for every built-in target', () => { const registry = createDefaultRegistry(); expect(registryMetadata(registry, 'portable')).toEqual({ - adapterRevision: '1.6.0', + adapterRevision: '1.7.0', observedVersion: '1.0.0', schemas: [ { @@ -68,7 +68,7 @@ it('records exact immutable metadata for every built-in target', () => { ], }); expect(registryMetadata(registry, 'codex')).toEqual({ - adapterRevision: '1.9.0', + adapterRevision: '1.10.0', observedVersion: '0.147.0', schemas: [ { @@ -99,7 +99,7 @@ it('records exact immutable metadata for every built-in target', () => { ], }); expect(registryMetadata(registry, 'claude')).toEqual({ - adapterRevision: '1.22.0', + adapterRevision: '1.23.0', observedVersion: '2.1.250', schemas: [ { @@ -145,7 +145,7 @@ it('records exact immutable metadata for every built-in target', () => { ], }); expect(registryMetadata(registry, 'cursor')).toEqual({ - adapterRevision: '1.9.0', + adapterRevision: '1.10.0', observedVersion: '2026-08-28', schemas: [ { @@ -170,7 +170,7 @@ it('records exact immutable metadata for every built-in target', () => { }, ], }); - expect(registryMetadata(registry, 'plugin').adapterRevision).toBe('1.25.0'); + expect(registryMetadata(registry, 'plugin').adapterRevision).toBe('1.26.0'); }); it('records observed capability versions and rehashes schema snapshots against pinned provenance', async () => { diff --git a/packages/agent-bundle/tests/api.test.ts b/packages/agent-bundle/tests/api.test.ts index 0fdb49463..5b2cf52a0 100644 --- a/packages/agent-bundle/tests/api.test.ts +++ b/packages/agent-bundle/tests/api.test.ts @@ -5,7 +5,8 @@ import { join } from 'node:path'; import { expect, it } from '@rstest/core'; -import { TargetRegistry, build, inspect, invokeMcp, listHooks, listMcp, simulateHook, validate } from '../src/api.ts'; +import { TargetRegistry, build, createDefaultRegistry, inspect, invokeMcp, listHooks, listMcp, simulateHook, validate } from '../src/api.ts'; +import { unavailableCapability } from '../src/adapters/capability-state.ts'; import { nativeHookWrapperSource, planHooks, @@ -132,7 +133,9 @@ const syntheticAdapter: TargetAdapter = Object.freeze({ const readyInspection = async (options: Parameters[0]) => { const result = await inspect(options); - if (result.state !== 'ready') throw new Error('Expected a ready inspection.'); + if (result.state !== 'ready') { + throw new Error(`Expected a ready inspection: ${result.diagnostics.map((entry) => `${entry.code} ${entry.message}`).join('; ')}`); + } return result; }; @@ -816,6 +819,359 @@ it('reports skipped target/component pairs against each target emission surface' } }); +it('accounts lsp servers and event routes as distinct canonical kinds with a per-kind host matrix (#100)', async () => { + const root = await createProject(); + try { + await mkdir(join(root, 'src', 'events', 'session'), { recursive: true }); + await Promise.all([ + writeFile(join(root, 'src', 'events', 'session', 'start.tsx'), [ + "export const config = { runtime: 'standalone', targets: ['claude', 'codex', 'cursor', 'plugin'] };", + 'export default async function SessionStart() {', + ' return null;', + '}', + '', + ].join('\n')), + writeFile(join(root, 'agent-bundle.config.ts'), [ + 'export default {', + ' claude: {', + ' lspServers: {', + " typescript: { command: 'typescript-language-server', args: ['--stdio'], extensionToLanguage: { '.ts': 'typescript' } },", + ' },', + ' },', + " hooks: { sessionStart: { handler: './src/hook.ts' } },", + " plugin: { name: 'api-fixture', version: '1.0.0' },", + " targets: ['portable', 'codex', 'claude', 'cursor', 'plugin'],", + '};', + '', + ].join('\n')), + ]); + + const result = await readyInspection({ root }); + const planFor = (target: string) => result.plans.find((plan) => plan.target === target)!; + const componentsOf = (target: string, kind: string) => [ + ...planFor(target).selected.filter((component) => component.kind === kind).map((component) => ({ ...component, outcome: 'selected' })), + ...planFor(target).skipped.filter((component) => component.kind === kind).map((component) => ({ ...component, outcome: 'skipped' })), + ]; + + // The Claude-declared LSP server is one `lsp` component. Its declaration + // is host-scoped, so it targets only the adapters that lower `claude.*` + // (Claude and the composite, which plans the Claude side); every other + // host reads as excluded by the declaration and still carries its own + // dated `lsp` judgment so the omission is explained in the host's words. + expect(componentsOf('claude', 'lsp')).toEqual([expect.objectContaining({ + capability: expect.objectContaining({ evidence: expect.objectContaining({ target: 'claude' }), name: 'lsp', state: 'supported' }), + id: 'lsp:claude:typescript', + name: 'typescript', + outcome: 'selected', + })]); + expect(planFor('claude').entries).toEqual(expect.arrayContaining([expect.objectContaining({ relativePath: '.lsp.json' })])); + expect(componentsOf('plugin', 'lsp')).toEqual([expect.objectContaining({ + capability: expect.objectContaining({ name: 'lsp', state: 'supported' }), + outcome: 'selected', + })]); + expect(planFor('plugin').entries).toEqual(expect.arrayContaining([expect.objectContaining({ relativePath: '.lsp.json' })])); + for (const target of ['codex', 'cursor', 'portable']) { + expect(componentsOf(target, 'lsp')).toEqual([expect.objectContaining({ + capability: { name: 'lsp', reason: expect.stringMatching(/no LSP server/u), state: 'unavailable' }, + name: 'typescript', + outcome: 'skipped', + reason: 'excluded-by-targets', + })]); + expect(planFor(target).entries.some((entry) => entry.relativePath === '.lsp.json')).toBe(false); + } + expect(result.model.lspServers).toEqual([{ + declaredBy: 'claude', + id: 'lsp:claude:typescript', + name: 'typescript', + provenance: { kind: 'config', sourcePath: join(root, 'agent-bundle.config.ts') }, + targets: ['claude', 'plugin'], + }]); + + // Filesystem event routes report separately from config-declared hooks, + // judged by the host's row for their canonical event (#258 matrix). + expect(componentsOf('claude', 'hook')).toEqual([expect.objectContaining({ + capability: expect.objectContaining({ name: 'hooks', state: 'supported' }), + name: 'sessionStart', + outcome: 'selected', + })]); + for (const target of ['claude', 'codex', 'cursor', 'plugin']) { + expect(componentsOf(target, 'event-route')).toEqual([expect.objectContaining({ + capability: expect.objectContaining({ name: 'event:session/start', state: 'supported' }), + id: 'hook:event-route:session-start', + name: 'session/start', + outcome: 'selected', + })]); + } + expect(componentsOf('portable', 'event-route')).toEqual([expect.objectContaining({ + capability: { name: 'event:session/start', reason: expect.any(String), state: 'unavailable' }, + name: 'session/start', + outcome: 'skipped', + reason: 'excluded-by-targets', + })]); + + // Every plan carries the full canonical kind matrix in kind order, with + // the host's judgment even for kinds the project never declares. + const kindOrder = [ + 'agent', 'cli', 'command', 'event-route', 'hook', 'lsp', 'mcp-app', 'mcp-server', + 'native-diagnostics', 'native-extension', 'rule', 'script', 'skill', + ]; + for (const plan of result.plans) { + expect(plan.kinds.map((report) => report.kind)).toEqual(kindOrder); + expect(Object.isFrozen(plan.kinds)).toBe(true); + expect(plan.kinds.find((report) => report.kind === 'script')).not.toHaveProperty('capability'); + expect(plan.kinds.find((report) => report.kind === 'event-route')).not.toHaveProperty('capability'); + for (const kind of ['native-diagnostics', 'native-extension']) { + expect(plan.kinds.find((report) => report.kind === kind)).toEqual({ + capability: { name: kind === 'native-diagnostics' ? 'nativeDiagnostics' : 'nativeExtension', reason: expect.any(String), state: 'unavailable' }, + kind, + selected: 0, + skipped: 0, + }); + } + } + expect(planFor('claude').kinds.find((report) => report.kind === 'lsp')).toEqual({ + capability: { evidence: { observedVersion: '2.1.250', target: 'claude' }, name: 'lsp', state: 'supported' }, + kind: 'lsp', + selected: 1, + skipped: 0, + }); + expect(planFor('cursor').kinds.find((report) => report.kind === 'lsp')).toMatchObject({ capability: { state: 'unavailable' }, selected: 0, skipped: 1 }); + expect(planFor('claude').kinds.find((report) => report.kind === 'agent')).toEqual({ + capability: { name: 'agents', reason: expect.stringContaining('#220'), state: 'unavailable' }, + kind: 'agent', + selected: 0, + skipped: 0, + }); + expect(planFor('cursor').kinds.find((report) => report.kind === 'agent')).toMatchObject({ + capability: { name: 'agents', reason: expect.stringContaining('#220'), state: 'unavailable' }, + }); + // A host that publishes no row at all reads as an honest unavailable, never a silent pass. + expect(planFor('portable').kinds.find((report) => report.kind === 'agent')).toMatchObject({ + capability: { name: 'agents', reason: 'The portable adapter publishes no agents capability row.', state: 'unavailable' }, + }); + // The composite judges kinds by emission dispatch but keeps every published + // intersection row, so its G5 agents deferral reason survives into inspect. + expect(planFor('plugin').kinds.find((report) => report.kind === 'agent')).toMatchObject({ + capability: { name: 'agents', reason: expect.stringContaining('#220'), state: 'unavailable' }, + }); + expect(planFor('portable').kinds.find((report) => report.kind === 'event-route')).toEqual({ kind: 'event-route', selected: 0, skipped: 1 }); + } finally { + await rm(join(root, '..'), { force: true, recursive: true }); + } +}); + +it('accounts an admitted degraded event route as selected, matching the validation rule (#100)', async () => { + const root = await createProject(); + // An advanced adapter that lowers session/start with a documented + // limitation: validation admits the degraded row, the planner emits the + // route, so inspection must report it as selected with that judgment. + const degradedContract = { ...syntheticHookContract, eventRouteNames: { 'session/start': 'SessionStart' } } satisfies TargetHookContract; + const registry = new TargetRegistry().register({ + ...syntheticAdapter, + capabilities: { + ...supportedCapabilities('hooks', 'mcp'), + 'event:session/start': { + evidence: { observedVersion: 'test', target: syntheticTarget }, + reason: 'The synthetic host delivers session/start without a transcript path.', + state: 'degraded', + }, + }, + hookContract: degradedContract, + plan: (model: NormalizedPlugin) => { + const hooks = planHooks(model, syntheticTarget, degradedContract); + return Object.freeze({ diagnostics: hooks.diagnostics, entries: Object.freeze([]), hookEntries: hooks.hookEntries }); + }, + }, { default: true }); + try { + await mkdir(join(root, 'src', 'events', 'session'), { recursive: true }); + await Promise.all([ + writeFile(join(root, 'src', 'events', 'session', 'start.tsx'), [ + "export const config = { runtime: 'standalone' };", + 'export default async function SessionStart() {', + ' return null;', + '}', + '', + ].join('\n')), + writeFile(join(root, 'agent-bundle.config.ts'), [ + 'export default {', + " plugin: { name: 'api-fixture', version: '1.0.0' },", + ` targets: ['${syntheticTarget}'],`, + '};', + '', + ].join('\n')), + ]); + + const result = await readyInspection({ registry, root }); + const plan = result.plans[0]!; + expect(plan.hookEntries.some((entry) => entry.event === 'sessionStart')).toBe(true); + expect(plan.selected).toEqual(expect.arrayContaining([expect.objectContaining({ + capability: { evidence: { observedVersion: 'test', target: syntheticTarget }, name: 'event:session/start', reason: expect.any(String), state: 'degraded' }, + kind: 'event-route', + name: 'session/start', + })])); + expect(plan.skipped.some((component) => component.kind === 'event-route')).toBe(false); + expect(plan.kinds.find((report) => report.kind === 'event-route')).toEqual({ kind: 'event-route', selected: 1, skipped: 0 }); + } finally { + await rm(join(root, '..'), { force: true, recursive: true }); + } +}); + +it('judges event-route admission and lsp inheritance by the component-emission override, not the top-level row (#100)', async () => { + const root = await createProject(); + const degradedContract = { ...syntheticHookContract, eventRouteNames: { 'session/start': 'SessionStart' } } satisfies TargetHookContract; + // The declaring adapter follows the composite-emission pattern: its + // top-level rows are honest intersections, its component overrides decide + // emission. A second adapter that lowers the `synthetic` extension inherits + // the LSP declaration because the override, not the intersection, governs. + const registry = new TargetRegistry() + .register({ + ...syntheticAdapter, + capabilities: { + ...supportedCapabilities('hooks', 'mcp'), + 'event:session/start': unavailableCapability('intersection: one half lacks session/start'), + lsp: unavailableCapability('intersection: one half lacks LSP'), + }, + componentCapabilities: supportedCapabilities('hooks', 'mcp', 'lsp', 'event:session/start'), + hookContract: degradedContract, + plan: (model: NormalizedPlugin) => { + const hooks = planHooks(model, syntheticTarget, degradedContract); + return Object.freeze({ diagnostics: hooks.diagnostics, entries: Object.freeze([]), hookEntries: hooks.hookEntries }); + }, + }, { default: true }) + .register({ + ...syntheticAdapter, + capabilities: supportedCapabilities('hooks', 'lsp', 'mcp'), + configExtension: undefined, + lowersConfigExtensions: ['synthetic'], + name: 'composite', + plan: () => Object.freeze({ diagnostics: [], entries: Object.freeze([]) }), + }); + try { + await mkdir(join(root, 'src', 'events', 'session'), { recursive: true }); + await Promise.all([ + writeFile(join(root, 'src', 'events', 'session', 'start.tsx'), [ + `export const config = { runtime: 'standalone', targets: ['${syntheticTarget}'] };`, + 'export default async function SessionStart() {', + ' return null;', + '}', + '', + ].join('\n')), + writeFile(join(root, 'agent-bundle.config.ts'), [ + 'export default {', + " synthetic: { lspServers: { rust: { command: 'rust-analyzer' } } },", + " plugin: { name: 'api-fixture', version: '1.0.0' },", + ` targets: ['${syntheticTarget}', 'composite'],`, + '};', + '', + ].join('\n')), + ]); + + const result = await readyInspection({ registry, root }); + const synthetic = result.plans.find((plan) => plan.target === syntheticTarget)!; + // Validation admitted the route on the component override (the top-level + // row is unavailable) and inspection reports it selected on the same judgment. + expect(synthetic.selected).toEqual(expect.arrayContaining([ + expect.objectContaining({ capability: expect.objectContaining({ name: 'event:session/start', state: 'supported' }), kind: 'event-route' }), + expect.objectContaining({ capability: expect.objectContaining({ name: 'lsp', state: 'supported' }), kind: 'lsp', name: 'rust' }), + ])); + expect(result.model.lspServers).toEqual([expect.objectContaining({ declaredBy: 'synthetic', targets: [syntheticTarget, 'composite'] })]); + } finally { + await rm(join(root, '..'), { force: true, recursive: true }); + } +}); + +it('never reports an lsp component as selected when the declaring planner rejects the servers (#100)', async () => { + const root = await createProject(); + try { + // Two servers claiming one extension suppress the whole `.lsp.json` + // document with a plan error; the inspection is then invalid rather than + // a ready plan that counts unemitted lsp components as selected. + await writeFile(join(root, 'agent-bundle.config.ts'), [ + 'export default {', + ' claude: {', + ' lspServers: {', + " first: { command: 'first-ls', extensionToLanguage: { '.ts': 'typescript' } },", + " second: { command: 'second-ls', extensionToLanguage: { '.ts': 'typescript' } },", + ' },', + ' },', + " plugin: { name: 'api-fixture', version: '1.0.0' },", + " targets: ['claude'],", + '};', + '', + ].join('\n')); + + const result = await inspect({ root }); + expect(result.state).toBe('invalid'); + expect(result.plans).toEqual([]); + expect(result.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'claude.lsp.extension.conflict', severity: 'error' }), + ])); + } finally { + await rm(join(root, '..'), { force: true, recursive: true }); + } +}); + +it('never counts an opaque third-party lspServers declaration as emitted by a host that does not lower it (#100)', async () => { + const root = await createProject(); + const registry = createDefaultRegistry().register({ + ...syntheticAdapter, + capabilities: supportedCapabilities('hooks', 'lsp', 'mcp'), + }); + try { + await writeFile(join(root, 'agent-bundle.config.ts'), [ + 'export default {', + " synthetic: { lspServers: { rust: { command: 'rust-analyzer' } } },", + " hooks: { sessionStart: { handler: './src/hook.ts' } },", + " plugin: { name: 'api-fixture', version: '1.0.0' },", + " targets: ['claude', 'synthetic'],", + '};', + '', + ].join('\n')); + + const result = await readyInspection({ registry, root }); + const planFor = (target: string) => result.plans.find((plan) => plan.target === target)!; + // Claude publishes `lsp: supported`, but its planner reads only + // `claude.lspServers`; the synthetic declaration is excluded for Claude + // and writes no `.lsp.json` there, while the declaring adapter selects it. + expect(result.model.lspServers).toEqual([expect.objectContaining({ declaredBy: 'synthetic', name: 'rust', targets: ['synthetic'] })]); + expect(planFor('claude').skipped).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: 'lsp', name: 'rust', reason: 'excluded-by-targets' }), + ])); + expect(planFor('claude').selected.some((component) => component.kind === 'lsp')).toBe(false); + expect(planFor('claude').entries.some((entry) => entry.relativePath === '.lsp.json')).toBe(false); + expect(planFor('synthetic').selected).toEqual(expect.arrayContaining([ + expect.objectContaining({ capability: expect.objectContaining({ name: 'lsp', state: 'supported' }), kind: 'lsp', name: 'rust' }), + ])); + + // A composite that lowers a host's extension inherits its LSP declaration + // only when that host can lower LSP servers: `codex.lspServers` reaches + // neither the Codex half (unavailable) nor the composite bundle. + await writeFile(join(root, 'agent-bundle.config.ts'), [ + 'export default {', + " codex: { lspServers: { rust: { command: 'rust-analyzer' } } },", + " hooks: { sessionStart: { handler: './src/hook.ts' } },", + " plugin: { name: 'api-fixture', version: '1.0.0' },", + " targets: ['codex', 'plugin'],", + '};', + '', + ].join('\n')); + const codexDeclared = await readyInspection({ registry, root }); + expect(codexDeclared.model.lspServers).toEqual([expect.objectContaining({ declaredBy: 'codex', targets: ['codex'] })]); + const codexPlan = codexDeclared.plans.find((plan) => plan.target === 'codex')!; + const compositePlan = codexDeclared.plans.find((plan) => plan.target === 'plugin')!; + expect(codexPlan.skipped).toEqual(expect.arrayContaining([ + expect.objectContaining({ capability: expect.objectContaining({ name: 'lsp', state: 'unavailable' }), kind: 'lsp', reason: 'unsupported-capability' }), + ])); + expect(compositePlan.skipped).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: 'lsp', name: 'rust', reason: 'excluded-by-targets' }), + ])); + expect(compositePlan.selected.some((component) => component.kind === 'lsp')).toBe(false); + expect(compositePlan.entries.some((entry) => entry.relativePath === '.lsp.json')).toBe(false); + } finally { + await rm(join(root, '..'), { force: true, recursive: true }); + } +}); + it('reports target exclusion before unsupported capability when both omit a component', async () => { const root = await createProject(); try { diff --git a/packages/agent-bundle/tests/cli.test.ts b/packages/agent-bundle/tests/cli.test.ts index dee0bcdd9..22e251647 100644 --- a/packages/agent-bundle/tests/cli.test.ts +++ b/packages/agent-bundle/tests/cli.test.ts @@ -481,12 +481,27 @@ it('explains selected and omitted components per target on human inspect output' expect(human.stdout).toContain('codex: 1 component(s) selected, 1 omitted\n'); expect(human.stdout).toMatch(/^ {2}omitted rule shared: rules unavailable — .+$/mu); expect(human.stdout).not.toContain('omitted skill review'); + // The canonical kind matrix names every kind a host cannot emit, even + // kinds this project never declares (#100). + expect(human.stdout).toContain( + ' kinds this host cannot emit: agent (unavailable), command (unavailable), hook (unavailable), lsp (unavailable), ' + + 'native-diagnostics (unavailable), native-extension (unavailable), rule (unavailable)\n', + ); + expect(human.stdout).toMatch( + /^ {2}kinds this host cannot emit: agent \(unavailable\), command \(unavailable\), lsp \(unavailable\), native-diagnostics \(unavailable\), native-extension \(unavailable\), rule \(unavailable\)$/mu, + ); // The JSON form carries the same accounting with the full judgment. const json = await runSourceCliWithOutput(['inspect', '--root', project.root, '--target', 'codex', '--json']); expect(json).toMatchObject({ code: 0, stderr: '' }); expect(JSON.parse(json.stdout)).toMatchObject({ plans: [{ + kinds: expect.arrayContaining([ + { capability: { evidence: { observedVersion: '0.147.0', target: 'codex' }, name: 'skills', state: 'supported' }, kind: 'skill', selected: 1, skipped: 0 }, + { capability: { name: 'rules', reason: expect.any(String), state: 'unavailable' }, kind: 'rule', selected: 0, skipped: 1 }, + { capability: { name: 'lsp', reason: expect.stringContaining('no LSP server surface'), state: 'unavailable' }, kind: 'lsp', selected: 0, skipped: 0 }, + { capability: { name: 'nativeExtension', reason: expect.any(String), state: 'unavailable' }, kind: 'native-extension', selected: 0, skipped: 0 }, + ]), selected: [expect.objectContaining({ capability: expect.objectContaining({ name: 'skills', state: 'supported' }), kind: 'skill', name: 'review' })], skipped: [expect.objectContaining({ capability: { name: 'rules', reason: expect.any(String), state: 'unavailable' }, diff --git a/packages/agent-bundle/tests/normalization.test.ts b/packages/agent-bundle/tests/normalization.test.ts index 9b5626776..2b3f4da9f 100644 --- a/packages/agent-bundle/tests/normalization.test.ts +++ b/packages/agent-bundle/tests/normalization.test.ts @@ -178,6 +178,36 @@ it('normalizes registered extensions and validates registered script and hook ta expect(Object.isFrozen(extensions.example?.value.nested)).toBe(true); }); +it('enumerates lsp components with unambiguous ids for any server name (#100)', async () => { + const model = await normalizeProject(loadedProject({ + claude: { + lspServers: { + 'a:b': { command: 'first-ls', extensionToLanguage: { '.a': 'a' } }, + 'pct%': { command: 'second-ls', extensionToLanguage: { '.b': 'b' } }, + typescript: { command: 'typescript-language-server', extensionToLanguage: { '.ts': 'typescript' } }, + '\uD800': { command: 'third-ls', extensionToLanguage: { '.c': 'c' } }, + }, + }, + plugin: { name: 'claude-lsp-fixture', version: '1.0.0' }, + targets: ['claude', 'cursor', 'plugin'], + }), { skills: [] }, createDefaultRegistry()); + + // Separator and escape characters are escaped so the (key, name) tuple is + // recoverable; a lone surrogate is accepted rather than aborting normalization. + expect(new Set(model.lspServers?.map((server) => server.id))).toEqual(new Set([ + 'lsp:claude:\uD800', + 'lsp:claude:a%3Ab', + 'lsp:claude:pct%25', + 'lsp:claude:typescript', + ])); + expect(model.lspServers).toHaveLength(4); + // Only adapters that lower the `claude` extension are targeted. + for (const server of model.lspServers ?? []) { + expect(server).toMatchObject({ declaredBy: 'claude', targets: ['claude', 'plugin'] }); + } + expect(Object.isFrozen(model.lspServers)).toBe(true); +}); + it('normalizes the typed Claude LSP source surface through the strict JSON extension seam', async () => { const model = await normalizeProject(loadedProject({ claude: {