Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/evidence-backed-capability-states.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-bundle": minor
---

Replace target adapter Boolean capability records with evidence-backed supported, degraded, unavailable, and prohibited states while retaining `TargetRegistry.supports()` as a derived compatibility view.
170 changes: 170 additions & 0 deletions packages/agent-bundle/src/adapters/capability-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { sha256Hex, stableJson } from '../core/digest.ts';
import type { CapabilityEvidence, CapabilityState } from '../core/capabilities.ts';
import type { TargetAdapterMetadata } from './types.ts';

/** Builds immutable evidence from a target's pinned capability-table metadata. */
export const capabilityEvidence = (
target: string,
metadata: TargetAdapterMetadata,
): CapabilityEvidence => Object.freeze({
capabilityRevision: metadata.capabilityRevision,
capabilitySha256: metadata.capabilitySha256,
observedVersion: metadata.observedVersion,
target,
});

export const supportedCapability = (evidence: CapabilityEvidence): CapabilityState => Object.freeze({
evidence,
state: 'supported',
});

export const unavailableCapability = (reason: string): CapabilityState => Object.freeze({
reason,
state: 'unavailable',
});

export const capabilityStateFromSupport = (
supported: boolean,
evidence: CapabilityEvidence,
unavailableReason: string,
): CapabilityState => supported ? supportedCapability(evidence) : unavailableCapability(unavailableReason);

/** The temporary Boolean compatibility rule: only supported maps to true. */
export const capabilityIsSupported = (capability: CapabilityState | undefined): boolean => {
if (capability === undefined) return false;
switch (capability.state) {
case 'supported':
return true;
case 'degraded':
case 'unavailable':
case 'prohibited':
return false;
default: {
const exhaustive: never = capability;
return exhaustive;
Comment thread
ScriptedAlchemy marked this conversation as resolved.
}
}
};

/** Immutable Boolean view retained for callers that have not migrated yet. */
export const capabilityBooleanView = (
capabilities: Readonly<Record<string, CapabilityState>>,
): Readonly<Record<string, boolean>> => Object.freeze(Object.fromEntries(
Object.entries(capabilities).map(([name, capability]) => [name, capabilityIsSupported(capability)]),
));

const evidenceFor = (capability: CapabilityState): CapabilityEvidence | undefined => {
switch (capability.state) {
case 'supported':
return capability.evidence;
case 'degraded':
return capability.evidence;
case 'unavailable':
case 'prohibited':
return undefined;
default: {
const exhaustive: never = capability;
return exhaustive;
}
}
};

const precedenceFor = (capability: CapabilityState): 0 | 1 | 2 | 3 => {
switch (capability.state) {
case 'supported':
return 0;
case 'degraded':
return 1;
case 'unavailable':
return 2;
case 'prohibited':
return 3;
default: {
const exhaustive: never = capability;
return exhaustive;
}
}
};

const reasonFor = (capability: CapabilityState, precedence: 1 | 2 | 3): string | undefined => {
switch (capability.state) {
case 'supported':
return undefined;
case 'degraded':
return precedence === 1 ? capability.reason : undefined;
case 'unavailable':
return precedence === 2 ? capability.reason : undefined;
case 'prohibited':
return precedence === 3 ? capability.reason : undefined;
default: {
const exhaustive: never = capability;
return exhaustive;
}
}
};

const mergedReason = (
left: CapabilityState,
right: CapabilityState,
precedence: 1 | 2 | 3,
): string => [...new Set([reasonFor(left, precedence), reasonFor(right, precedence)]
.filter((reason): reason is string => reason !== undefined))]
.sort((first, second) => first.localeCompare(second))
.join('; ');

/** Merges evidence without discarding either pinned table identity. */
export const mergeCapabilityEvidence = (
left: CapabilityEvidence,
right: CapabilityEvidence,
): CapabilityEvidence => {
const evidence = [left, right].sort((first, second) => {
const targetOrder = first.target.localeCompare(second.target);
return targetOrder === 0 ? stableJson(first).localeCompare(stableJson(second)) : targetOrder;
});
return Object.freeze({
capabilityRevision: evidence.map((entry) => `${entry.target}@${entry.capabilityRevision}`).join('+'),
capabilitySha256: sha256Hex(stableJson(evidence)),
observedVersion: evidence.map((entry) => `${entry.target}@${entry.observedVersion}`).join('+'),
target: evidence.map((entry) => entry.target).join('+'),
});
};

/**
* Intersects two host judgments for a composite adapter. Prohibition dominates,
* then unavailability, then degradation; two supported states merge evidence.
*/
export const intersectCapabilityStates = (
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') {
throw new Error('Supported capability intersection lost its evidence invariant.');
}
return supportedCapability(mergeCapabilityEvidence(left.evidence, right.evidence));
case 1: {
const leftEvidence = evidenceFor(left);
const rightEvidence = evidenceFor(right);
const evidence = leftEvidence === undefined || rightEvidence === undefined
? undefined
: 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;
return exhaustive;
}
}
};
18 changes: 14 additions & 4 deletions packages/agent-bundle/src/adapters/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
standardMcpPathTokens,
} from '../services/mcp-path-tokens.ts';
import { createTargetMcpRuntime } from '../services/mcp-runtime.ts';
import { capabilityEvidence, capabilityStateFromSupport, supportedCapability } from './capability-state.ts';
import capabilityTable from './capabilities/claude-2.1.250.json' with { type: 'json' };
import {
mergeHookDocuments,
Expand Down Expand Up @@ -88,6 +89,7 @@ const metadata = Object.freeze({
observedVersion: capabilityTable.observedCliVersion,
schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion),
});
const evidence = capabilityEvidence(claudeName, metadata);

const artifactValidation = Object.freeze({
documents: Object.freeze([
Expand Down Expand Up @@ -277,10 +279,18 @@ export const claudeAdapter: TargetAdapter = Object.freeze({
artifactValidation,
artifactLayout: standardArtifactLayout,
capabilities: Object.freeze({
marketplace: true,
hooks: true,
mcp: capabilityTable.mcp.stdio && capabilityTable.mcp.streamableHttp,
skills: capabilityTable.plugin.skills,
marketplace: supportedCapability(evidence),
hooks: supportedCapability(evidence),
mcp: capabilityStateFromSupport(
capabilityTable.mcp.stdio && capabilityTable.mcp.streamableHttp,
evidence,
'The pinned Claude contract does not support both required modern MCP transports.',
),
skills: capabilityStateFromSupport(
capabilityTable.plugin.skills,
evidence,
'The pinned Claude plugin contract does not support skills.',
),
}),
configExtension: Object.freeze({ key: claudeName }),
hookContract,
Expand Down
18 changes: 14 additions & 4 deletions packages/agent-bundle/src/adapters/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from '../core/types.ts';
import { createMcpPathTokenResolver, standardMcpPathTokens } from '../services/mcp-path-tokens.ts';
import { createTargetMcpRuntime, resolveTargetRelativeStdioArgument } from '../services/mcp-runtime.ts';
import { capabilityEvidence, capabilityStateFromSupport, supportedCapability } from './capability-state.ts';
import capabilityTable from './capabilities/codex-0.147.0.json' with { type: 'json' };
import {
mergeHookDocuments,
Expand Down Expand Up @@ -114,6 +115,7 @@ const metadata = Object.freeze({
observedVersion: capabilityTable.observedCliVersion,
schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion),
});
const evidence = capabilityEvidence(codexName, metadata);

const artifactValidation = Object.freeze({
documents: Object.freeze([
Expand Down Expand Up @@ -398,10 +400,18 @@ export const codexAdapter: TargetAdapter = Object.freeze({
artifactValidation,
artifactLayout: standardArtifactLayout,
capabilities: Object.freeze({
marketplace: true,
hooks: true,
mcp: capabilityTable.mcp.stdio && capabilityTable.mcp.streamableHttp,
skills: capabilityTable.plugin.skills,
marketplace: supportedCapability(evidence),
hooks: supportedCapability(evidence),
mcp: capabilityStateFromSupport(
capabilityTable.mcp.stdio && capabilityTable.mcp.streamableHttp,
evidence,
'The pinned Codex contract does not support both required modern MCP transports.',
),
skills: capabilityStateFromSupport(
capabilityTable.plugin.skills,
evidence,
'The pinned Codex plugin contract does not support skills.',
),
}),
configExtension: Object.freeze({ key: codexName }),
hookContract,
Expand Down
17 changes: 14 additions & 3 deletions packages/agent-bundle/src/adapters/cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
standardMcpPathTokens,
} from '../services/mcp-path-tokens.ts';
import { createTargetMcpRuntime } from '../services/mcp-runtime.ts';
import { capabilityEvidence, capabilityStateFromSupport, supportedCapability, unavailableCapability } from './capability-state.ts';
import capabilityTable from './capabilities/cursor-2026-08-28.json' with { type: 'json' };
import {
cursorHookWrapperSource,
Expand Down Expand Up @@ -239,6 +240,7 @@ const metadata = Object.freeze({
observedVersion: capabilityTable.observedCliVersion,
schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion),
});
const evidence = capabilityEvidence(cursorName, metadata);

const hookContract = createCursorHookContract({
manifestPath: cursorArtifactPaths.hooks,
Expand Down Expand Up @@ -343,9 +345,18 @@ export const cursorAdapter: TargetAdapter = Object.freeze({
artifactValidation,
artifactLayout: standardArtifactLayout,
capabilities: Object.freeze({
hooks: true,
mcp: capabilityTable.mcp.stdio && capabilityTable.mcp.streamableHttp,
skills: capabilityTable.plugin.skills,
hooks: supportedCapability(evidence),
marketplace: unavailableCapability('The pinned Cursor Plugin contract does not define a marketplace document.'),
mcp: capabilityStateFromSupport(
capabilityTable.mcp.stdio && capabilityTable.mcp.streamableHttp,
evidence,
'The pinned Cursor Plugin contract does not support both required modern MCP transports.',
),
skills: capabilityStateFromSupport(
capabilityTable.plugin.skills,
evidence,
'The pinned Cursor Plugin contract does not support skills.',
),
}),
hookContract,
metadata,
Expand Down
9 changes: 5 additions & 4 deletions packages/agent-bundle/src/adapters/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
standardMcpPathTokens,
} from '../services/mcp-path-tokens.ts';
import { createTargetMcpRuntime } from '../services/mcp-runtime.ts';
import { intersectCapabilityStates } 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' };
import { claudeAdapter, claudeArtifactPaths, claudeHooksValidator, planClaudeArtifacts } from './claude.ts';
Expand Down Expand Up @@ -412,10 +413,10 @@ export const pluginAdapter: TargetAdapter = Object.freeze({
artifactValidation,
artifactLayout,
capabilities: Object.freeze({
marketplace: claudeAdapter.capabilities.marketplace === true && codexAdapter.capabilities.marketplace === true,
hooks: claudeAdapter.capabilities.hooks === true && codexAdapter.capabilities.hooks === true,
mcp: claudeAdapter.capabilities.mcp === true && codexAdapter.capabilities.mcp === true,
skills: claudeAdapter.capabilities.skills === true && codexAdapter.capabilities.skills === true,
marketplace: intersectCapabilityStates(claudeAdapter.capabilities.marketplace!, codexAdapter.capabilities.marketplace!),
hooks: intersectCapabilityStates(claudeAdapter.capabilities.hooks!, codexAdapter.capabilities.hooks!),
mcp: intersectCapabilityStates(claudeAdapter.capabilities.mcp!, codexAdapter.capabilities.mcp!),
skills: intersectCapabilityStates(claudeAdapter.capabilities.skills!, codexAdapter.capabilities.skills!),
}),
hookContract: bundleHookContract,
metadata,
Expand Down
16 changes: 14 additions & 2 deletions packages/agent-bundle/src/adapters/portable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
standardMcpPathTokens,
} from '../services/mcp-path-tokens.ts';
import { createTargetMcpRuntime } from '../services/mcp-runtime.ts';
import { capabilityEvidence, capabilityStateFromSupport, unavailableCapability } from './capability-state.ts';
import capabilityTable from './capabilities/portable-1.0.0.json' with { type: 'json' };
import schemaProvenance from './schemas/portable/PROVENANCE.json' with { type: 'json' };
import mcpSchema from './schemas/portable/mcp.schema.json' with { type: 'json' };
Expand Down Expand Up @@ -55,6 +56,7 @@ const metadata = Object.freeze({
observedVersion: capabilityTable.observedSpecificationVersion,
schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.version),
});
const evidence = capabilityEvidence(portableName, metadata);

const artifactValidation = Object.freeze({
documents: Object.freeze([
Expand Down Expand Up @@ -320,8 +322,18 @@ export const portableAdapter: TargetAdapter = Object.freeze({
skills: 'skills',
}),
capabilities: Object.freeze({
mcp: capabilityTable.mcp.stdio && capabilityTable.mcp.streamableHttp,
skills: capabilityTable.plugin.skills,
hooks: unavailableCapability('Agent Plugins 1.0.0 does not define a hooks component.'),
marketplace: unavailableCapability('Agent Plugins 1.0.0 does not define a marketplace document.'),
mcp: capabilityStateFromSupport(
capabilityTable.mcp.stdio && capabilityTable.mcp.streamableHttp,
evidence,
'Agent Plugins 1.0.0 does not support both required modern MCP transports.',
),
skills: capabilityStateFromSupport(
capabilityTable.plugin.skills,
evidence,
'Agent Plugins 1.0.0 does not support skills.',
),
}),
configExtension: Object.freeze({ key: portableName }),
metadata,
Expand Down
Loading
Loading