From b9f02fd38a39580b5e5129429aae184c3a482b0a Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 1 Sep 2026 22:57:18 +0000 Subject: [PATCH] fix(inspect): account composite plugin component emission as host-side dispatch, not intersected claims --- .../plugin-inspect-emission-accounting.md | 5 ++ .../src/adapters/capability-state.ts | 46 +++++++++++++++++++ packages/agent-bundle/src/adapters/plugin.ts | 15 ++++++ .../agent-bundle/src/adapters/registry.ts | 14 ++++++ packages/agent-bundle/src/adapters/types.ts | 2 + packages/agent-bundle/src/api.ts | 6 ++- .../tests/adapter-capability-states.test.ts | 28 +++++++++++ packages/agent-bundle/tests/api.test.ts | 13 +++++- 8 files changed, 126 insertions(+), 3 deletions(-) create mode 100644 .changeset/plugin-inspect-emission-accounting.md diff --git a/.changeset/plugin-inspect-emission-accounting.md b/.changeset/plugin-inspect-emission-accounting.md new file mode 100644 index 000000000..1073b96ad --- /dev/null +++ b/.changeset/plugin-inspect-emission-accounting.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Fix inspect so commands and rules emitted by the composite plugin target are no longer reported as skipped for unsupported capabilities by accounting for component kinds as a union of host-side emission while preserving honest intersected capability claims. diff --git a/packages/agent-bundle/src/adapters/capability-state.ts b/packages/agent-bundle/src/adapters/capability-state.ts index 07f31518c..c19d70051 100644 --- a/packages/agent-bundle/src/adapters/capability-state.ts +++ b/packages/agent-bundle/src/adapters/capability-state.ts @@ -207,3 +207,49 @@ export const intersectCapabilityStates = ( } } }; + +/** + * Unions two host judgments for composite emission dispatch: a composite + * emits a surface if any host side does. Support dominates degradation, + * unavailability, and prohibition; equally ranked supporting sides merge + * evidence, while equally ranked non-supported sides merge reasons. + */ +export const unionCapabilityStates = ( + left: CapabilityState, + right: CapabilityState, +): CapabilityState => { + const leftPrecedence = precedenceFor(left); + const rightPrecedence = precedenceFor(right); + const precedence = leftPrecedence < rightPrecedence ? leftPrecedence : rightPrecedence; + switch (precedence) { + case 0: + if (left.state === 'supported' && right.state === 'supported') { + return supportedCapability(mergeCapabilityEvidence(left.evidence, right.evidence)); + } + if (left.state === 'supported') return supportedCapability(left.evidence); + if (right.state === 'supported') return supportedCapability(right.evidence); + throw new Error('Supported capability union lost its evidence invariant.'); + case 1: { + const leftEvidence = left.state === 'degraded' ? left.evidence : undefined; + const rightEvidence = right.state === 'degraded' ? right.evidence : undefined; + const evidence = leftEvidence === undefined + ? rightEvidence + : rightEvidence === undefined + ? leftEvidence + : mergeCapabilityEvidence(leftEvidence, rightEvidence); + return Object.freeze({ + ...(evidence === undefined ? {} : { evidence }), + reason: mergedReason(left, right, precedence), + state: 'degraded', + }); + } + case 2: + return Object.freeze({ reason: mergedReason(left, right, precedence), state: 'unavailable' }); + case 3: + return Object.freeze({ reason: mergedReason(left, right, precedence), state: 'prohibited' }); + default: { + const exhaustive: never = precedence; + throw new CapabilityStateError(`Capability precedence ${String(exhaustive)} has no union rule.`); + } + } +}; diff --git a/packages/agent-bundle/src/adapters/plugin.ts b/packages/agent-bundle/src/adapters/plugin.ts index 7fcc5b6f7..38404d479 100644 --- a/packages/agent-bundle/src/adapters/plugin.ts +++ b/packages/agent-bundle/src/adapters/plugin.ts @@ -12,6 +12,7 @@ import { intersectCapabilityStates, supportedEventRouteNamesFrom, unavailableCapability, + unionCapabilityStates, } from './capability-state.ts'; import claudeCapabilityTable from './capabilities/claude-2.1.250.json' with { type: 'json' }; import codexCapabilityTable from './capabilities/codex-0.147.0.json' with { type: 'json' }; @@ -528,6 +529,19 @@ const compositeEventCapabilities = Object.freeze(Object.fromEntries( }), )); +const componentCapabilities = Object.freeze(Object.fromEntries( + ['commands', 'hooks', 'mcp', 'rules', 'skills'].map((capability) => [ + capability, + unionCapabilityStates( + unionCapabilityStates( + claudeAdapter.capabilities[capability]!, + codexAdapter.capabilities[capability]!, + ), + cursorAdapter.capabilities[capability]!, + ), + ]), +)); + export const pluginAdapter: TargetAdapter = Object.freeze({ artifactValidation, artifactLayout, @@ -568,6 +582,7 @@ export const pluginAdapter: TargetAdapter = Object.freeze({ cursorAdapter.capabilities.skills!, ), }), + componentCapabilities, hookContract: bundleHookContract, metadata, mcpRuntime, diff --git a/packages/agent-bundle/src/adapters/registry.ts b/packages/agent-bundle/src/adapters/registry.ts index ff39e4258..655155605 100644 --- a/packages/agent-bundle/src/adapters/registry.ts +++ b/packages/agent-bundle/src/adapters/registry.ts @@ -373,6 +373,20 @@ const assertCapabilityContract = (adapter: TargetAdapter): void => { `${capabilityStateNames.join('/')} with that state's required fields.`, ); } + if (adapter.componentCapabilities === undefined) return; + const componentCapabilities = record(adapter.componentCapabilities); + if (componentCapabilities === undefined) { + throw new CapabilityStateError( + `Target adapter "${adapter.name}" must declare component capabilities as a record.`, + ); + } + for (const [capability, state] of Object.entries(componentCapabilities)) { + if (state === undefined || isCapabilityState(state)) continue; + throw new CapabilityStateError( + `Target adapter "${adapter.name}" component capability "${capability}" must declare one of ` + + `${capabilityStateNames.join('/')} with that state's required fields.`, + ); + } }; export class TargetRegistry implements NormalizationTargetRegistry { diff --git a/packages/agent-bundle/src/adapters/types.ts b/packages/agent-bundle/src/adapters/types.ts index e3cc06d43..1ddd2f5dd 100644 --- a/packages/agent-bundle/src/adapters/types.ts +++ b/packages/agent-bundle/src/adapters/types.ts @@ -492,6 +492,8 @@ export interface TargetAdapter { /** Declares compiler-owned artifact layouts beyond target-native documents. */ readonly artifactLayout?: TargetArtifactLayout; readonly capabilities: Readonly>; + /** Per-component-kind emission dispatch used by inspect skip accounting; defaults to `capabilities`. */ + readonly componentCapabilities?: Readonly>; readonly configExtension?: TargetConfigExtension; readonly hookContract?: TargetHookContract; readonly metadata: TargetAdapterMetadata; diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index b376b3567..dcde4dcf3 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -519,7 +519,11 @@ export const inspect = async (options: InspectOptions): Promise = diagnostics: freezeDiagnostics(plan.diagnostics), entries: Object.freeze([...plan.entries]), hookEntries: Object.freeze([...(plan.hookEntries ?? [])]), - skipped: skippedComponentsFor(components, target.name, adapter.capabilities), + skipped: skippedComponentsFor( + components, + target.name, + adapter.componentCapabilities ?? adapter.capabilities, + ), target: target.name, }); })); diff --git a/packages/agent-bundle/tests/adapter-capability-states.test.ts b/packages/agent-bundle/tests/adapter-capability-states.test.ts index 6f6ab0224..10dfc8ed4 100644 --- a/packages/agent-bundle/tests/adapter-capability-states.test.ts +++ b/packages/agent-bundle/tests/adapter-capability-states.test.ts @@ -7,6 +7,7 @@ import { intersectCapabilityStates, supportedCapability, unavailableCapability, + unionCapabilityStates, } from '../src/adapters/capability-state.ts'; import { TargetRegistry, createDefaultRegistry } from '../src/adapters/registry.ts'; import { CapabilityStateError, isCapabilityState } from '../src/core/capabilities.ts'; @@ -150,6 +151,24 @@ it('applies prohibited, unavailable, degraded, and supported intersection preced }); }); +it('unions host capability states according to composite emission dispatch', () => { + const supported = supportedCapability(evidence('supported')); + const unavailable = unavailableCapability('unavailable host'); + const prohibited = state({ state: 'prohibited', reason: 'prohibited host' }); + + expect(unionCapabilityStates(supported, unavailable)).toEqual(supported); + expect(unionCapabilityStates(unavailable, supported)).toEqual(supported); + expect(unionCapabilityStates( + unavailableCapability('second unavailable host'), + unavailable, + )).toEqual({ + reason: 'second unavailable host; unavailable host', + state: 'unavailable', + }); + expect(unionCapabilityStates(prohibited, supported)).toEqual(supported); + expect(unionCapabilityStates(supported, prohibited)).toEqual(supported); +}); + it('keeps the Boolean compatibility view thin and exhaustive', () => { expect(capabilityBooleanView({ degraded: { state: 'degraded', reason: 'partial' }, @@ -227,6 +246,15 @@ it('rejects a malformed capability declaration when the adapter registers', () = expect(() => new TargetRegistry().register(source)).not.toThrow(); }); +it('rejects a malformed inspection component capability when the adapter registers', () => { + const source = createDefaultRegistry().get('cursor'); + + expect(() => new TargetRegistry().register({ + ...source, + componentCapabilities: { commands: malformed({ state: 'suported' }) }, + })).toThrow(/component capability "commands" must declare one of degraded\/prohibited\/supported\/unavailable/u); +}); + it('surfaces built-in adapter metadata as immutable capability evidence', () => { const registry = createDefaultRegistry(); const cursor = registry.get('cursor'); diff --git a/packages/agent-bundle/tests/api.test.ts b/packages/agent-bundle/tests/api.test.ts index 8533a4034..db9ec5cdf 100644 --- a/packages/agent-bundle/tests/api.test.ts +++ b/packages/agent-bundle/tests/api.test.ts @@ -542,7 +542,7 @@ it('returns an invalid inspection for selected targets outside the normalized pr } }); -it('reports skipped target/component pairs with intersection-rule reasons', async () => { +it('reports skipped target/component pairs against each target emission surface', async () => { const root = await createProject(); try { await Promise.all([ @@ -566,7 +566,7 @@ it('reports skipped target/component pairs with intersection-rule reasons', asyn " hooks: { sessionStart: { handler: './src/hook.ts' } },", " plugin: { name: 'api-fixture', version: '1.0.0' },", " scripts: { report: { entry: './src/report.ts', targets: ['codex'] } },", - " targets: ['portable', 'codex', 'claude', 'cursor'],", + " targets: ['portable', 'codex', 'claude', 'cursor', 'plugin'],", '};', '', ].join('\n')), @@ -602,6 +602,15 @@ it('reports skipped target/component pairs with intersection-rule reasons', asyn component.kind === 'command' && component.name === 'shared')).toBe(false); expect(planFor('cursor')?.skipped.some((component) => component.kind === 'command')).toBe(false); expect(planFor('cursor')?.skipped.some((component) => component.kind === 'rule')).toBe(false); + expect(planFor('plugin')?.skipped).toEqual([ + expect.objectContaining({ kind: 'command', name: 'cursor-only', reason: 'excluded-by-targets' }), + expect.objectContaining({ kind: 'rule', name: 'cursor-only', reason: 'excluded-by-targets' }), + expect.objectContaining({ kind: 'script', name: 'report', reason: 'excluded-by-targets' }), + ]); + expect(planFor('plugin')?.entries).toEqual(expect.arrayContaining([ + expect.objectContaining({ relativePath: 'commands/shared.md' }), + expect.objectContaining({ relativePath: 'rules/shared.mdc' }), + ])); expect(Object.isFrozen(planFor('portable')?.skipped)).toBe(true); } finally { await rm(join(root, '..'), { force: true, recursive: true });