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
7 changes: 7 additions & 0 deletions .changeset/event-family-capabilities.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"agent-bundle": minor
---

Publish evidence-backed event-route support states for Claude, Codex, Cursor,
the unified plugin, and Portable targets, and reject unavailable routes before
packaging unless their static config restricts them to supported targets.
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,24 @@
"sessionStart": "SessionStart",
"stop": "Stop"
},
"eventRoutes": {
"agent/start": {
"reason": "The pinned Claude Code 2.1.250 hooks contract has no subagent-start event.",
"state": "unavailable"
},
"agent/stop": {
"reason": "The pinned Claude Code 2.1.250 hooks contract has no subagent-stop event.",
"state": "unavailable"
},
"session/start": { "nativeEvent": "SessionStart", "state": "supported" },
"stop": { "nativeEvent": "Stop", "state": "supported" },
"tool/after": { "nativeEvent": "PostToolUse", "state": "supported" },
"tool/before": { "nativeEvent": "PreToolUse", "state": "supported" },
"workspace/open": {
"reason": "The pinned Claude Code 2.1.250 hooks contract has no workspace-open event.",
"state": "unavailable"
}
},
"matchers": {
"file.read": "^Read$",
"file.write": "^(?:Write|Edit)$",
Expand Down
18 changes: 18 additions & 0 deletions packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,24 @@
"sessionStart": "SessionStart",
"stop": "Stop"
},
"eventRoutes": {
"agent/start": {
"reason": "The pinned Codex 0.147.0 hooks contract has no subagent-start event.",
"state": "unavailable"
},
"agent/stop": {
"reason": "The pinned Codex 0.147.0 hooks contract has no subagent-stop event.",
"state": "unavailable"
},
"session/start": { "nativeEvent": "SessionStart", "state": "supported" },
"stop": { "nativeEvent": "Stop", "state": "supported" },
"tool/after": { "nativeEvent": "PostToolUse", "state": "supported" },
"tool/before": { "nativeEvent": "PreToolUse", "state": "supported" },
"workspace/open": {
"reason": "The pinned Codex 0.147.0 hooks contract has no workspace-open event.",
"state": "unavailable"
}
},
"matchers": {
"file.write": "^(?:apply_patch|Edit|Write)$",
"mcp": "^mcp__",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,22 @@
"hooks": {
"config": "hooks/hooks.json",
"events": {
"agentStart": "subagentStart",
"agentStop": "subagentStop",
"afterTool": "postToolUse",
"beforeTool": "preToolUse",
"sessionStart": "sessionStart",
"stop": "stop"
"stop": "stop",
"workspaceOpen": "workspaceOpen"
},
"eventRoutes": {
"agent/start": { "nativeEvent": "subagentStart", "state": "supported" },
"agent/stop": { "nativeEvent": "subagentStop", "state": "supported" },
"session/start": { "nativeEvent": "sessionStart", "state": "supported" },
"stop": { "nativeEvent": "stop", "state": "supported" },
"tool/after": { "nativeEvent": "postToolUse", "state": "supported" },
"tool/before": { "nativeEvent": "preToolUse", "state": "supported" },
"workspace/open": { "nativeEvent": "workspaceOpen", "state": "supported" }
},
"matchers": {
"agent": "^Task$",
Expand Down Expand Up @@ -61,7 +73,8 @@
"Known-loading physical ~/.cursor/plugins/local/tracedecay uses .cursor-plugin/plugin.json, root mcp.json, and hooks/hooks.json.",
"Installed cursor-agent-exec loader candidates: .cursor-plugin/plugin.json, .claude-plugin/plugin.json, plugin.json.",
"Installed loader substitutes CURSOR_PLUGIN_ROOT in MCP command, args, env, and cwd fields and in hook commands.",
"Local-plugin symlinks are realpath checked and rejected when their targets escape ~/.cursor/plugins/local."
"Local-plugin symlinks are realpath checked and rejected when their targets escape ~/.cursor/plugins/local.",
"The pinned Cursor hooks schema admits subagentStart, subagentStop, and workspaceOpen as first-class hook arrays; the public Cursor hooks reference documents workspaceOpen and subagent lifecycle payloads."
]
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
{
"eventRoutes": {
"agent/start": { "reason": "Agent Plugins 1.0.0 does not define hooks.", "state": "unavailable" },
"agent/stop": { "reason": "Agent Plugins 1.0.0 does not define hooks.", "state": "unavailable" },
"session/start": { "reason": "Agent Plugins 1.0.0 does not define hooks.", "state": "unavailable" },
"stop": { "reason": "Agent Plugins 1.0.0 does not define hooks.", "state": "unavailable" },
"tool/after": { "reason": "Agent Plugins 1.0.0 does not define hooks.", "state": "unavailable" },
"tool/before": { "reason": "Agent Plugins 1.0.0 does not define hooks.", "state": "unavailable" },
"workspace/open": { "reason": "Agent Plugins 1.0.0 does not define hooks.", "state": "unavailable" }
},
"host": "portable",
"mcp": {
"pathTokens": {
Expand Down
30 changes: 30 additions & 0 deletions packages/agent-bundle/src/adapters/capability-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,36 @@ export const unavailableCapability = (reason: string): CapabilityState => Object
state: 'unavailable',
});

export interface EventRouteCapabilityTableEntry {
readonly nativeEvent?: string;
readonly reason?: string;
/** JSON imports widen literals; unsupported table states fail closed below. */
readonly state: string;
}

/**
* Converts a pinned host table's semantic-event rows into the shared
* capability-state namespace consumed by route validation and inspect.
*/
export const eventRouteCapabilitiesFrom = (
routes: Readonly<Record<string, EventRouteCapabilityTableEntry>>,
evidence: CapabilityEvidence,
): Readonly<Record<string, CapabilityState>> => Object.freeze(Object.fromEntries(
Object.entries(routes).sort(([left], [right]) => left.localeCompare(right)).map(([event, capability]) => {
switch (capability.state) {
case 'supported':
return [`event:${event}`, supportedCapability(evidence)];
case 'unavailable':
return [
`event:${event}`,
unavailableCapability(capability.reason ?? `The pinned ${evidence.target} contract does not support ${event}.`),
];
default:
throw new TypeError(`Unsupported event-route capability state ${JSON.stringify(capability.state)} for ${event}.`);
}
}),
));

export const capabilityStateFromSupport = (
supported: boolean,
evidence: CapabilityEvidence,
Expand Down
10 changes: 8 additions & 2 deletions packages/agent-bundle/src/adapters/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@ import {
standardMcpPathTokens,
} from '../services/mcp-path-tokens.ts';
import { createTargetMcpRuntime } from '../services/mcp-runtime.ts';
import { capabilityEvidence, capabilityStateFromSupport, supportedCapability } from './capability-state.ts';
import {
capabilityEvidence,
capabilityStateFromSupport,
eventRouteCapabilitiesFrom,
supportedCapability,
} from './capability-state.ts';
import capabilityTable from './capabilities/claude-2.1.250.json' with { type: 'json' };
import {
mergeHookDocuments,
Expand Down Expand Up @@ -125,7 +130,7 @@ const hookContract = Object.freeze({
const metadata = Object.freeze({
adapterRevision: '1.2.0',
capabilityRevision: capabilityTable.observedCliVersion,
capabilitySha256: '952788d759db5152e8bcb7128ba778bb74f51fac79403011f669eecdcb1f45f3',
capabilitySha256: '5beb395c075c22a290a70f76b1670fab82b16d933af37cda89da850dbd8d483c',
observedVersion: capabilityTable.observedCliVersion,
schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion),
});
Expand Down Expand Up @@ -489,6 +494,7 @@ export const claudeAdapter: TargetAdapter = Object.freeze({
artifactValidation,
artifactLayout: standardArtifactLayout,
capabilities: Object.freeze({
...eventRouteCapabilitiesFrom(capabilityTable.hooks.eventRoutes, evidence),
marketplace: supportedCapability(evidence),
hooks: supportedCapability(evidence),
lsp: capabilityStateFromSupport(
Expand Down
4 changes: 3 additions & 1 deletion packages/agent-bundle/src/adapters/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { createTargetMcpRuntime, resolveTargetRelativeStdioArgument } from '../s
import {
capabilityEvidence,
capabilityStateFromSupport,
eventRouteCapabilitiesFrom,
supportedCapability,
unavailableCapability,
} from './capability-state.ts';
Expand Down Expand Up @@ -116,7 +117,7 @@ const hookContract = Object.freeze({
const metadata = Object.freeze({
adapterRevision: '1.1.0',
capabilityRevision: capabilityTable.observedCliVersion,
capabilitySha256: '4b08c8820ace59ca068677dcb1863a9fd4cb730b7e00733b994b0076958beaf0',
capabilitySha256: 'f2c7109e3572ebde8739e08f6fdfa7a7b5d7817e3e216406b9c855f1570e2bd9',
observedVersion: capabilityTable.observedCliVersion,
schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion),
});
Expand Down Expand Up @@ -405,6 +406,7 @@ export const codexAdapter: TargetAdapter = Object.freeze({
artifactValidation,
artifactLayout: standardArtifactLayout,
capabilities: Object.freeze({
...eventRouteCapabilitiesFrom(capabilityTable.hooks.eventRoutes, evidence),
marketplace: supportedCapability(evidence),
hooks: supportedCapability(evidence),
// The pinned Codex plugin contract documents no LSP surface at all, so
Expand Down
11 changes: 9 additions & 2 deletions packages/agent-bundle/src/adapters/cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,13 @@ 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 {
capabilityEvidence,
capabilityStateFromSupport,
eventRouteCapabilitiesFrom,
supportedCapability,
unavailableCapability,
} from './capability-state.ts';
import capabilityTable from './capabilities/cursor-2026-08-28.json' with { type: 'json' };
import {
cursorHookWrapperSource,
Expand Down Expand Up @@ -246,7 +252,7 @@ export const cursorManifest = (
const metadata = Object.freeze({
adapterRevision: '1.3.0',
capabilityRevision: capabilityTable.observedCliVersion,
capabilitySha256: '234920e63508664ae79db4e1a5422c1022d93ad572fae345a179bfd774f6f6d7',
capabilitySha256: '20fc70ad5ba67d984826c3ac917fca66f28e61a8c74edb65dace53c29cc67279',
observedVersion: capabilityTable.observedCliVersion,
schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion),
});
Expand Down Expand Up @@ -358,6 +364,7 @@ export const cursorAdapter: TargetAdapter = Object.freeze({
artifactValidation,
artifactLayout: standardArtifactLayout,
capabilities: Object.freeze({
...eventRouteCapabilitiesFrom(capabilityTable.hooks.eventRoutes, evidence),
hooks: supportedCapability(evidence),
marketplace: unavailableCapability('The pinned Cursor Plugin contract does not define a marketplace document.'),
mcp: capabilityStateFromSupport(
Expand Down
16 changes: 16 additions & 0 deletions packages/agent-bundle/src/adapters/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -436,10 +436,26 @@ const plan = (model: NormalizedPlugin): TargetArtifactPlan => {
});
};

const compositeEventCapabilities = Object.freeze(Object.fromEntries(
Object.keys(claudeCapabilityTable.hooks.eventRoutes)
.sort((left, right) => left.localeCompare(right))
.map((event) => {
const capability = `event:${event}`;
return [
capability,
intersectCapabilityStates(
claudeAdapter.capabilities[capability]!,
codexAdapter.capabilities[capability]!,
),
Comment on lines +446 to +449

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include Cursor in plugin event capability intersection

When a common event route targets the unified plugin adapter, validation treats this Claude/Codex-only intersection as the bundle's complete capability state even though plan also emits Cursor manifests and hook artifacts. The resulting supported evidence already omits the pinned Cursor contract, and a future degraded or unavailable Cursor event would still leave plugin marked supported, allowing an unsupported route through validation. Intersect the Cursor capability as well.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed by #221 (merged as 49281f1). Verified still present on post-#194 main before fixing: compositeEventCapabilities intersected Claude ∩ Codex only. The composite now intersects all three pinned host tables per event family (failing closed at module init if any host table is missing a route key another declares), and the scalar rows the bundle emits for all three hosts — marketplace, hooks, mcp, skills, commands, rules — intersect Cursor as well. Per-host states are preserved through the existing merge semantics (merged evidence targets like claude+codex+cursor, deduped sorted reasons). The plugin metadata fingerprint was also extended to cover all three host pins so a Cursor-only table correction changes the manifest identity.

];
}),
));

export const pluginAdapter: TargetAdapter = Object.freeze({
artifactValidation,
artifactLayout,
capabilities: Object.freeze({
...compositeEventCapabilities,
marketplace: intersectCapabilityStates(claudeAdapter.capabilities.marketplace!, codexAdapter.capabilities.marketplace!),
hooks: intersectCapabilityStates(claudeAdapter.capabilities.hooks!, codexAdapter.capabilities.hooks!),
// Claude supports LSP and Codex has no LSP surface, so the intersection
Expand Down
10 changes: 8 additions & 2 deletions packages/agent-bundle/src/adapters/portable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@ import {
standardMcpPathTokens,
} from '../services/mcp-path-tokens.ts';
import { createTargetMcpRuntime } from '../services/mcp-runtime.ts';
import { capabilityEvidence, capabilityStateFromSupport, unavailableCapability } from './capability-state.ts';
import {
capabilityEvidence,
capabilityStateFromSupport,
eventRouteCapabilitiesFrom,
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 @@ -52,7 +57,7 @@ const validateMcp = schemaValidator.compile(mcpSchema);
const metadata = Object.freeze({
adapterRevision: '1.1.0',
capabilityRevision: capabilityTable.observedSpecificationVersion,
capabilitySha256: '642da9f921374a4d0143da21ed4b4b2260a2375a5eb33c4cbb4ef531f2bb7352',
capabilitySha256: '99a27bd327f2afdb0a71fa869e9fad27438f07d843c5e61062d2dc3f978dce9a',
observedVersion: capabilityTable.observedSpecificationVersion,
schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.version),
});
Expand Down Expand Up @@ -322,6 +327,7 @@ export const portableAdapter: TargetAdapter = Object.freeze({
skills: 'skills',
}),
capabilities: Object.freeze({
...eventRouteCapabilitiesFrom(capabilityTable.eventRoutes, evidence),
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(
Expand Down
77 changes: 77 additions & 0 deletions packages/agent-bundle/src/config/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -933,6 +933,82 @@ const validateBin = (loaded: LoadedConfig): Diagnostic[] => {
return diagnostics;
};

const validateEventRoutes = (
loaded: LoadedConfig,
discovered: DiscoveredProject,
registry: NormalizationTargetRegistry,
): Diagnostic[] => {
const selectedTargets = loaded.context.selectedTargets.length > 0
? loaded.context.selectedTargets
: (loaded.config.targets ?? registry.defaultTargetNames());
const diagnostics: Diagnostic[] = [];

for (const route of discovered.routeGraph?.events ?? []) {
const declaredTargets = route.config['targets'];
if (
declaredTargets !== undefined &&
(!Array.isArray(declaredTargets) ||
declaredTargets.length === 0 ||
declaredTargets.some((target) => typeof target !== 'string' || target.trim().length === 0))
) {
diagnostics.push(sourceDiagnostic(
'AB4815',
`Event route ${route.provenance.relativePath} config.targets must be a nonempty array of target names.`,
route.source,
));
continue;
}
const targets = declaredTargets === undefined
? selectedTargets
: [...new Set(declaredTargets as readonly string[])];
for (const target of targets) {
if (!registry.has(target)) {
diagnostics.push({
code: 'AB4814',
message: `Event route ${route.provenance.relativePath} selects unknown target ${JSON.stringify(target)}.`,
severity: 'error',
sourcePath: route.source,
target,
});
continue;
}
const capabilityName = `event:${route.event}`;
const capability = registry.capabilityState?.(target, capabilityName);
if (capability === undefined) {
if (registry.supports(target, capabilityName)) continue;
diagnostics.push({
code: 'AB4814',
message: `Event route ${route.provenance.relativePath} requires ${capabilityName}, unavailable on ${target}.`,
severity: 'error',
sourcePath: route.source,
target,
});
continue;
}
switch (capability.state) {
case 'supported':
case 'degraded':
break;
case 'unavailable':
case 'prohibited':
diagnostics.push({
code: 'AB4814',
message: `Event route ${route.provenance.relativePath} requires ${capabilityName}, ${capability.state} on ${target}: ${capability.reason}`,
severity: 'error',
sourcePath: route.source,
target,
});
break;
default: {
const exhaustive: never = capability;
return exhaustive;
}
}
}
}
return diagnostics;
};

const validateLib = (loaded: LoadedConfig): Diagnostic[] => {
const lib = loaded.config.lib;
if (lib === undefined || lib === false) return [];
Expand Down Expand Up @@ -1521,6 +1597,7 @@ export const validateSource = (
// Route-graph collisions (AB4800-AB4804) are compiled during discovery;
// they are project-source errors, so they gate inspect and build here.
diagnostics.push(...(discovered.routeGraph?.diagnostics ?? []));
diagnostics.push(...validateEventRoutes(loaded, discovered, registry));
// The stage-1 gate for conventional script routes rides beside the graph's
// own collisions: rendered, nested, and config-conflicting script routes
// stay hard errors until later #102 stages ship them.
Expand Down
2 changes: 2 additions & 0 deletions packages/agent-bundle/src/core/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { EnvironmentConfig } from '@rsbuild/core';

import type { CompiledAgentRoute } from '../routes/types.ts';
import type { CapabilityState } from './capabilities.ts';

export interface AgentBundlePluginConfig {
description?: string;
Expand Down Expand Up @@ -494,6 +495,7 @@ export type NormalizationNativeHookSource =
| NormalizationNativeHookSourceError;

export interface NormalizationTargetRegistry {
capabilityState?(name: string, capability: string): CapabilityState | undefined;
configExtensions(): readonly NormalizationConfigExtension[];
defaultTargetNames(): readonly string[];
has(name: string): boolean;
Expand Down
Loading
Loading