diff --git a/.changeset/component-feature-sets.md b/.changeset/component-feature-sets.md new file mode 100644 index 000000000..d0477980e --- /dev/null +++ b/.changeset/component-feature-sets.md @@ -0,0 +1,5 @@ +--- +'agent-bundle': minor +--- + +Enforce per-host component feature sets for conventional `src/commands/*.md` and `src/rules/*.mdc` documents (#100). Every frontmatter field is judged against the target's `.` capability row: a command or rule that explicitly targets a host which cannot express a field it uses fails the build (`AB4927` commands, `AB4907` rules), while an implicitly selected host still receives the document minus the field and `agent-bundle validate` reports the omission as a warning with the host's reason (`AB4928`, `AB4908`). `agent-bundle inspect` lists the same omissions as `omittedFeatures` on the selected component (`--json`) and as ` omits : …` lines. Cursor's pinned commands surface is frontmatter-free Markdown, so a Cursor-required command must not carry `description`, `argumentHint`, `allowedTools`, `model`, or `disableModelInvocation`; Cursor rules keep `description`, `globs`, and `alwaysApply` (#427) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index db5a7e85b..30fd759f2 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -25,7 +25,7 @@ gate a build, a validation, or a dev rebuild. | `AB474x`/`AB4750` | Prebuilt payloads and prebuilt entries (see below). | | `AB4760` | The published `agent-bundle/meta` identity module evaluated outside every compiled surface and outside the Rstest presets (see below). | | `AB4765`–`AB4766` | Artifact-hosted routed CLI: a target without the `cli` capability omits `bin/.mjs`; a host-emitted file collides with it (see below). | -| `AB490x`/`AB492x` | Conventional host components (#100 stage 2): rules `src/rules/*.mdc` (`AB4900`–`AB4906`) and commands `src/commands/*.md` (`AB4920`–`AB4926`); see below. | +| `AB490x`/`AB492x` | Conventional host components (#100 stage 2): rules `src/rules/*.mdc` (`AB4900`–`AB4908`) and commands `src/commands/*.md` (`AB4920`–`AB4928`), including per-host feature-set enforcement (`AB4907`/`AB4908`, `AB4927`/`AB4928`); see below. | | `AB48xx`/`AB494x` | Route graph, state, layout (`AB4830`–`AB4832`), and provider conventions (see below). | | `AB5000` | General CLI and adapter failures. | | `AB60xx` | Built-artifact validation, including schema documents and referenced files (`AB6011`/`AB6012`: a target's required pinned-schema document is missing or invalid; `AB6025`: a manifest-declared `logo` path is missing from the artifact or escapes the deploy tree; `AB6034`: emitted Skill Markdown has no instruction body; `AB6035`–`AB6038`: Agent Plugins portable validation, see below). | @@ -419,7 +419,7 @@ its entry. | --- | --- | --- | | `AB4340` | error | A declaration for a route-generated server sets `entry`, `command`, or `url` while `routes.servers.` is `generated`. The routes already compile this server, so a second entry claim has no reading the compiler could honor. Remove the field to keep the generated server (the other fields still apply), or set the mode to `custom`, `command`, or `remote` to serve the declared entry and omit the routes. Without an explicit mode the same collision is `AB4800`. | -## Conventional host components: rules and commands (`AB4900`–`AB4906`, `AB4920`–`AB4926`) +## Conventional host components: rules and commands (`AB4900`–`AB4908`, `AB4920`–`AB4928`) Conventional `src/rules/*.mdc` documents compile to the Rule IR (closed frontmatter: `description`, `globs`, `alwaysApply`, plus the bundle-only @@ -434,6 +434,19 @@ the surface is a build error — unsupported components fail before artifact publication rather than shipping as a broken half. Identity paths are canonicalized so the model digest is root-independent. +Every frontmatter field is also a **component feature** (#100): each host +publishes one `.` capability row per field it can express +(`commands.argumentHint`, `rules.globs`, …; see +[Host components](framework-mode.md#component-feature-sets)). A component +that uses a feature the target's row does not support is judged per target: +an explicitly named target fails closed (`AB4907` / `AB4927`), while an +implicitly selected target still receives the component minus the feature and +the omission is reported as a warning with the host's reason (`AB4908` / +`AB4928`) and on the selected component in `inspect` (`omittedFeatures`). +Targets whose kind row is itself unsupported are judged by the kind-level codes +above, never per feature. Skills keep their own closed per-host schemas +(`AB3006`, `AB3008`, `AB3010`). + | Code | Severity | Trigger | Recovery | | --- | --- | --- | --- | | `AB4900` | error | A conventional rule file cannot be read. | Make the `.mdc` file readable, or remove it from `src/rules/`. | @@ -443,6 +456,8 @@ canonicalized so the model digest is root-independent. | `AB4904` | error | A rule's `targets` names a target that is not registered or not selected for the project. | Name only selected targets, or select that target in `targets`. | | `AB4905` | error | A rule explicitly targets a host whose `rules` capability is `degraded`, `unavailable`, or `prohibited` (the message carries the host's reason). | Drop that host from the rule's `targets`; only Cursor publishes a rules surface. | | `AB4906` | error | Two rule files share a name. | Rename one file so every rule name is unique. | +| `AB4907` | error | A rule explicitly targets a host that supports rules but whose `rules.` row for a frontmatter field the rule uses is `degraded`, `unavailable`, or `prohibited` (the message carries the host's reason). | Remove the field or drop that host from the rule's `targets`. Cursor documents `description`, `globs`, and `alwaysApply`. | +| `AB4908` | warning | An implicitly selected host supports rules but cannot express a frontmatter field the rule uses; the rule ships there without it. | Accept the omission, restrict the rule's `targets` to hosts that support the field, or remove the field. | | `AB4920` | error | A conventional command file cannot be read. | Make the `.md` file readable, or remove it from `src/commands/`. | | `AB4921` | error | Command YAML frontmatter is invalid. | Repair the YAML between the `---` fences. | | `AB4922` | error | Command frontmatter declares a field outside `description`, `argumentHint`, `allowedTools`, `model`, `disableModelInvocation`, `targets`. | Remove the field; per-host frontmatter is regenerated from the validated fields at lowering time. | @@ -450,6 +465,8 @@ canonicalized so the model digest is root-independent. | `AB4924` | error | A command's `targets` names a target that is not registered or not selected for the project. | Name only selected targets, or select that target in `targets`. | | `AB4925` | error | A command explicitly targets a host whose `commands` capability is `degraded`, `unavailable`, or `prohibited` (the message carries the host's reason). | Drop that host from the command's `targets`; Cursor and Claude publish command surfaces, Codex and portable do not. | | `AB4926` | error | Two command files share a name. | Rename one file so every command name is unique. | +| `AB4927` | error | A command explicitly targets a host that supports commands but whose `commands.` row for a frontmatter field the command uses is `degraded`, `unavailable`, or `prohibited` (the message carries the host's reason). Cursor's pinned commands surface is frontmatter-free Markdown, so every field row is unavailable there. | Remove the field or drop that host from the command's `targets`. | +| `AB4928` | warning | An implicitly selected host supports commands but cannot express a frontmatter field the command uses; the command ships there without it (Cursor receives the prompt body only). | Accept the omission, restrict the command's `targets` to hosts that support the field, or remove the field. | ## Route graph, state, layout, and provider conventions (`AB4800`–`AB4832`, `AB4940`–`AB4942`) diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index b7a7164e8..8555a00bb 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -797,8 +797,13 @@ 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. Every plan also carries -`kinds`: one entry per canonical kind, in kind order, with the target's own +Scripts need no host capability and carry none. A selected component that +uses a feature the target cannot express (a command frontmatter field on +Cursor, for example) carries `omittedFeatures`: one entry per omitted feature +with the host's `.` row, matching the `AB4908` / `AB4928` +warnings `validate` reports (see +[Component feature sets](framework-mode.md#component-feature-sets)). 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 diff --git a/docs/framework-mode.md b/docs/framework-mode.md index 169f345d4..6994bfe73 100644 --- a/docs/framework-mode.md +++ b/docs/framework-mode.md @@ -370,6 +370,40 @@ 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. +### Component feature sets + +Each kind also has a **feature set**: the host features a component of that +kind may use, published as one `.` row per feature +with the same four-state judgment. A component is judged feature by feature +against every target that supports its kind: a target the author named in +`targets` fails closed when it cannot express a feature (`AB4907` rules, +`AB4927` commands), while an implicitly selected target still receives the +component minus the feature, reports the omission as a warning with the host's +reason (`AB4908` / `AB4928`), and lists it under `omittedFeatures` on the +selected component in `inspect` (human output: ` omits : +…`). The composite `plugin` bundle is judged by the half that emits the kind +(Claude for commands, Cursor for rules). Skills keep the closed per-host Skill +IR schemas from #108 as their feature mechanism (`AB3006`, `AB3008`, `AB3010`); +their rows below mirror that contract rather than adding a second check. + +| Kind | Feature rows | Claude | Codex | Cursor | portable | +| --- | --- | --- | --- | --- | --- | +| `command` | `commands.description`, `commands.argumentHint`, `commands.allowedTools`, `commands.model`, `commands.disableModelInvocation` | supported (documented kebab-case frontmatter) | no commands | unavailable — frontmatter-free Markdown, body only | no commands | +| `rule` | `rules.description`, `rules.globs`, `rules.alwaysApply` | no rules | no rules | supported (`.mdc` frontmatter, retrieved 2026-09-03) | no rules | +| `hook` | `hooks.toolMatchers`, `hooks.timeout` | supported | supported | supported | no hooks | +| `skill` | `skills.hostFrontmatter` (typed host extension / Codex `agents/openai.yaml` sidecar) | supported | supported | supported | unavailable (portable fields only) | +| `skill` | `skills.markdownTokens` (`$ARGUMENTS`, `${CLAUDE_PLUGIN_ROOT}`, …) | supported | unavailable (`AB3008`) | unavailable (`AB3008`) | unavailable (`AB3008`) | + +The composite `plugin` bundle ships one shared `skills/` tree and lowers any +skill that declares a host extension or token to the portable document, so +both skill feature rows are `unavailable` there and `inspect` reports the +dropped host frontmatter under `omittedFeatures`; per-host skill trees are +install-time selection (#101). + +Hook tool selectors a host cannot map still fail at plan time +(`.hook.tool.`), and the per-host matcher tables live under +`hooks.matchers` in each capability table. + ## 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 99eefd478..1a18db914 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 @@ -109,6 +109,20 @@ "state": "unavailable" } }, + "features": { + "timeout": { + "evidence": [ + "2026-09-03: the pinned schemas/claude/hooks.schema.json declares an integer `timeout` (minimum 1) on every command hook entry; the hook contract lowers `timeoutMs` to whole seconds." + ], + "state": "supported" + }, + "toolMatchers": { + "evidence": [ + "2026-09-03: the pinned schemas/claude/hooks.schema.json declares a string `matcher` on every hook group, and hooks.matchers above pins the canonical tool selectors (file.read, file.write, mcp, shell) to Claude tool-name regexes; an unmapped selector fails the plan (claude.hook.tool.)." + ], + "state": "supported" + } + }, "matchers": { "file.read": "^Read$", "file.write": "^(?:Write|Edit)$", @@ -280,6 +294,20 @@ "bindsToPluginMcpServer": true, "perChannelUserConfig": true }, + "commandFrontmatter": { + "evidence": [ + "2026-09-01: https://code.claude.com/docs/en/plugins-reference documents plugin commands/ as Markdown files with optional YAML frontmatter fields description, argument-hint, allowed-tools, model, and disable-model-invocation (see provenance.evidence).", + "2026-09-03: adapters/claude.ts claudeCommandMarkdown regenerates exactly those five kebab-case fields from the validated Command IR (allowedTools, argumentHint, description, disableModelInvocation, model) and never emits an authoring-only key." + ], + "fields": { + "allowedTools": "allowed-tools", + "argumentHint": "argument-hint", + "description": "description", + "disableModelInvocation": "disable-model-invocation", + "model": "model" + }, + "state": "supported" + }, "commands": true, "dependencies": { "autoInstall": true, @@ -689,6 +717,20 @@ "supportedKeys": ["agent", "subagentStatusLine"], "unknownKeysIgnored": true }, + "skillFeatures": { + "hostFrontmatter": { + "evidence": [ + "2026-09-03: the Skill IR lowers the typed Claude extension (agent, allowed-tools, argument-hint, arguments, background, context, disable-model-invocation, disallowed-tools, effort, hooks, model, paths, shell, user-invocable, when_to_use) into SKILL.md frontmatter validated against the sha-pinned schemas/skill-hosts/claude-skill-frontmatter.schema.json (#108); unknown fields fail closed with AB3006 and schema violations with AB3010." + ], + "state": "supported" + }, + "markdownTokens": { + "evidence": [ + "2026-09-03: skills/tokens.ts pins every canonical token to a documented Claude Skill Markdown placeholder ($ARGUMENTS, ${CLAUDE_PLUGIN_ROOT}, ${CLAUDE_PLUGIN_DATA}, ${CLAUDE_PROJECT_DIR}, ${CLAUDE_SESSION_ID}, ${CLAUDE_SKILL_DIR}) per https://code.claude.com/docs/en/skills; the #242 real-session proof observed $ARGUMENTS, ${CLAUDE_PLUGIN_ROOT}, and ${CLAUDE_SKILL_DIR} resolving on Claude Code 2.1.257." + ], + "state": "supported" + } + }, "skills": true, "userConfig": { "envVarPrefix": "CLAUDE_PLUGIN_OPTION_", 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 9b2f3d85c..16ce10cb0 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 @@ -226,6 +226,20 @@ "state": "unavailable" } }, + "features": { + "timeout": { + "evidence": [ + "2026-09-03: the pinned schemas/codex/hooks.schema.json declares an integer `timeout` (minimum 1) on hook entries; hooks.contract.timeoutRules records the per-event default and maximum seconds the host enforces." + ], + "state": "supported" + }, + "toolMatchers": { + "evidence": [ + "2026-09-03: the pinned schemas/codex/hooks.schema.json declares a string `matcher` per hook group, and hooks.matchers above pins the canonical selectors (file.write, mcp, shell) to Codex tool-name regexes; file.read has no Codex mapping and fails the plan (codex.hook.tool.file-read), and hooks.contract.matcherSemantics records the events that ignore matchers." + ], + "state": "supported" + } + }, "matchers": { "file.write": "^(?:apply_patch|Edit|Write)$", "mcp": "^mcp__", @@ -630,6 +644,21 @@ "state": "unavailable" } }, + "skillFeatures": { + "hostFrontmatter": { + "evidence": [ + "2026-09-03: the Skill IR lowers the typed Codex extension (interface, policy, dependencies) into the agents/openai.yaml sidecar validated against the sha-pinned schemas/skill-hosts/codex-openai-yaml.schema.json (#108), while SKILL.md keeps only the portable Agent Skills fields; the #242 host-install proof observed the byte-identical sidecar in the installed Codex cache." + ], + "state": "supported" + }, + "markdownTokens": { + "evidence": [ + "2026-09-03: skills/tokens.ts classifies every canonical token as `none` for Codex Skill Markdown because https://learn.chatgpt.com/docs/build-skills documents no interpolation placeholders inside SKILL.md; lowering fails closed with AB3008 instead of emitting an unresolved token." + ], + "reason": "The pinned Codex skills contract (0.147.0) documents no Skill Markdown interpolation placeholders; a skill body token has no Codex equivalent and fails closed (AB3008).", + "state": "unavailable" + } + }, "skills": true }, "validation": { 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 b29b1fdd0..29da8becb 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 @@ -334,6 +334,15 @@ "reason": "Cursor advertises plugin canvases without publishing a schema-backed authoring contract; no canvas component is inferred from marketplace examples, so this compiler emits none.", "state": "unavailable" }, + "commandFrontmatter": { + "evidence": [ + "2026-09-01: https://cursor.com/docs documents agent chat commands as plain Markdown prompt files in commands/ named by filename (see provenance.evidence); the Cursor lowering emits the prompt body only.", + "2026-09-03: https://cursor.com/docs/agent/chat/commands now resolves to the Skills page, which documents slash commands only as a `/migrate-to-skills` source (Cursor 2.4+) and documents no command frontmatter field, so no authored field has a Cursor command equivalent." + ], + "fields": ["allowedTools", "argumentHint", "description", "disableModelInvocation", "model"], + "reason": "The pinned Cursor commands surface is frontmatter-free Markdown: the lowering emits the prompt body only, so authored command frontmatter fields do not reach Cursor.", + "state": "unavailable" + }, "commands": true, "componentDiscovery": { "defaults": { @@ -453,6 +462,28 @@ "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" }, + "ruleFrontmatter": { + "evidence": [ + "retrieved 2026-09-03: https://cursor.com/docs/context/rules documents project rules as .mdc files whose frontmatter fields description, globs, and alwaysApply control when a rule is included (alwaysApply true: always; globs: auto-attached on matching files; description: agent decides; none: manual @-mention), and states a plain .md file is ignored because it lacks that frontmatter." + ], + "fields": ["alwaysApply", "description", "globs"], + "state": "supported" + }, + "skillFeatures": { + "hostFrontmatter": { + "evidence": [ + "2026-09-03: the Skill IR lowers the typed Cursor extension (color, disable-model-invocation, globs, icon, paths) into SKILL.md frontmatter validated against the sha-pinned schemas/skill-hosts/cursor-skill-frontmatter.schema.json (#108); unknown fields fail closed with AB3006 and schema violations with AB3010." + ], + "state": "supported" + }, + "markdownTokens": { + "evidence": [ + "2026-09-03: skills/tokens.ts classifies every canonical token as `none` for Cursor Skill Markdown because https://prod.cursor.com/docs/skills documents no interpolation placeholders inside SKILL.md; lowering fails closed with AB3008 instead of emitting an unresolved token." + ], + "reason": "The pinned Cursor skills contract (2026-08-28) documents no Skill Markdown interpolation placeholders; a skill body token has no Cursor equivalent and fails closed (AB3008).", + "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 13c44ea3a..572d7eca5 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 @@ -171,6 +171,22 @@ "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" }, + "skillFeatures": { + "hostFrontmatter": { + "evidence": [ + "2026-09-03: the portable lowering emits only the Agent Skills fields (name, description, license, compatibility, metadata, allowed-tools) validated against the pinned Agent Skills schema (schemas/agent-skills); typed Claude, Cursor, and Codex extensions are peeled per host and never reach the portable SKILL.md (#108)." + ], + "reason": "The Agent Skills specification pinned for the portable target defines only the portable frontmatter fields; host-specific extensions are not emitted in the portable SKILL.md.", + "state": "unavailable" + }, + "markdownTokens": { + "evidence": [ + "2026-09-03: skills/tokens.ts classifies every canonical token as `none` for portable Skill Markdown because https://agentskills.io/specification (69ef37e9424c0a7ea9dd2293b559e43ec8176379) documents no interpolation placeholders inside SKILL.md; lowering fails closed with AB3008." + ], + "reason": "The Agent Skills specification pinned for the portable target documents no Skill Markdown interpolation placeholders; a skill body token has no portable equivalent and fails closed (AB3008).", + "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 ec9893582..4f1e75763 100644 --- a/packages/agent-bundle/src/adapters/capability-state.ts +++ b/packages/agent-bundle/src/adapters/capability-state.ts @@ -1,8 +1,11 @@ import { stableJson } from '../core/digest.ts'; import { CapabilityStateError, unknownCapabilityStateError } from '../core/capabilities.ts'; import type { CapabilityEvidence, CapabilityState } from '../core/capabilities.ts'; +import { featureCapabilityName } from '../core/components.ts'; import type { TargetAdapterMetadata } from './types.ts'; +export { featureCapabilityName } from '../core/components.ts'; + /** Builds immutable evidence from a target's pinned capability-table metadata. */ export const capabilityEvidence = ( target: string, @@ -103,6 +106,34 @@ export const capabilityFromTableRow = ( } }; +/** Publishes one row per feature from a pinned `{ : row }` table block. */ +export const featureCapabilitiesFrom = ( + kindCapability: string, + features: Readonly>, + evidence: CapabilityEvidence, +): Readonly> => Object.freeze(Object.fromEntries( + Object.entries(features) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([feature, row]) => [featureCapabilityName(kindCapability, feature), capabilityFromTableRow(row, evidence)]), +)); + +/** + * Publishes one row per frontmatter field from a pinned frontmatter block whose + * single state covers every field (`fields` is a list or an authored→emitted map). + */ +export const frontmatterFeatureCapabilitiesFrom = ( + kindCapability: string, + block: CapabilityTableRow & { readonly fields: readonly string[] | Readonly> }, + evidence: CapabilityEvidence, +): Readonly> => { + const fields = Array.isArray(block.fields) ? block.fields : Object.keys(block.fields); + return Object.freeze(Object.fromEntries( + [...fields] + .sort((left, right) => left.localeCompare(right)) + .map((field) => [featureCapabilityName(kindCapability, field), capabilityFromTableRow(block, evidence)]), + )); +}; + 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 691dd9ff0..55010d511 100644 --- a/packages/agent-bundle/src/adapters/claude.ts +++ b/packages/agent-bundle/src/adapters/claude.ts @@ -22,6 +22,8 @@ import { capabilityFromTableRow, capabilityStateFromSupport, eventRouteCapabilitiesFrom, + featureCapabilitiesFrom, + frontmatterFeatureCapabilitiesFrom, supportedEventRouteNamesFrom, cliBinCapability, supportedCapability, @@ -428,7 +430,7 @@ const hookContract = Object.freeze({ wrapperSource: (entry) => nativeHookWrapperSource(entry, 'Claude'), } satisfies TargetHookContract); const metadata = Object.freeze({ - adapterRevision: '1.23.0', + adapterRevision: '1.24.0', observedVersion: capabilityTable.observedCliVersion, schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion), }); @@ -3259,6 +3261,12 @@ export const claudeAdapter: TargetAdapter = Object.freeze({ capabilities: Object.freeze({ ...agentCapabilities, ...eventRouteCapabilitiesFrom(capabilityTable.hooks.eventRoutes, evidence), + // Component feature sets (#100): the host features each kind may use, + // one row per feature, enforced at build time (AB4927/AB4928 commands) + // and reported by inspect as omitted features. + ...frontmatterFeatureCapabilitiesFrom('commands', capabilityTable.plugin.commandFrontmatter, evidence), + ...featureCapabilitiesFrom('hooks', capabilityTable.hooks.features, evidence), + ...featureCapabilitiesFrom('skills', capabilityTable.plugin.skillFeatures, evidence), bin: capabilityStateFromSupport( capabilityTable.plugin.bin.directory === 'bin' && capabilityTable.plugin.bin.bashPath && diff --git a/packages/agent-bundle/src/adapters/codex.ts b/packages/agent-bundle/src/adapters/codex.ts index 9915f16db..e7061ec2b 100644 --- a/packages/agent-bundle/src/adapters/codex.ts +++ b/packages/agent-bundle/src/adapters/codex.ts @@ -20,6 +20,7 @@ import { capabilityEvidence, capabilityStateFromSupport, eventRouteCapabilitiesFrom, + featureCapabilitiesFrom, supportedEventRouteNamesFrom, cliBinCapability, supportedCapability, @@ -176,7 +177,7 @@ const hookContract = Object.freeze({ wrapperSource: (entry) => nativeHookWrapperSource(entry, 'Codex'), } satisfies TargetHookContract); const metadata = Object.freeze({ - adapterRevision: '1.10.0', + adapterRevision: '1.11.0', observedVersion: capabilityTable.observedCliVersion, schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion), }); @@ -1264,6 +1265,9 @@ export const codexAdapter: TargetAdapter = Object.freeze({ // The routed CLI bin rides the same plugin-root directory the pinned // contract already executes `mcp/` and `scripts/` files from (#387). [cliBinCapability]: supportedCapability(evidence), + // Component feature sets (#100): one row per host feature a kind may use. + ...featureCapabilitiesFrom('hooks', capabilityTable.hooks.features, evidence), + ...featureCapabilitiesFrom('skills', capabilityTable.plugin.skillFeatures, evidence), commands: unavailableCapability( 'The pinned Codex plugin contract (0.147.0) defines no commands component.', ), diff --git a/packages/agent-bundle/src/adapters/cursor.ts b/packages/agent-bundle/src/adapters/cursor.ts index eb1569f5b..271acb988 100644 --- a/packages/agent-bundle/src/adapters/cursor.ts +++ b/packages/agent-bundle/src/adapters/cursor.ts @@ -19,6 +19,8 @@ import { capabilityStateFromSupport, eventRouteCapabilitiesFrom, cliBinCapability, + featureCapabilitiesFrom, + frontmatterFeatureCapabilitiesFrom, supportedEventRouteNamesFrom, supportedCapability, type CapabilityTableRow, @@ -462,7 +464,7 @@ export const cursorManifest = ( }); const metadata = Object.freeze({ - adapterRevision: '1.10.0', + adapterRevision: '1.11.0', observedVersion: capabilityTable.observedCliVersion, schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion), }); @@ -682,6 +684,13 @@ export const cursorAdapter: TargetAdapter = Object.freeze({ // The routed CLI bin rides the same plugin-root directory the pinned // contract already executes `mcp/` and `scripts/` files from (#387). [cliBinCapability]: supportedCapability(evidence), + // Component feature sets (#100): commands are frontmatter-free on Cursor + // (every field row unavailable), rules carry the documented .mdc fields. + ...frontmatterFeatureCapabilitiesFrom('commands', capabilityTable.plugin.commandFrontmatter, evidence), + // Hook feature rows reuse the #189 hooks.options rows (matcher, timeout). + ...featureCapabilitiesFrom('hooks', { timeout: hookOptions.timeout, toolMatchers: hookOptions.matcher }, evidence), + ...frontmatterFeatureCapabilitiesFrom('rules', capabilityTable.plugin.ruleFrontmatter, evidence), + ...featureCapabilitiesFrom('skills', capabilityTable.plugin.skillFeatures, evidence), commands: capabilityStateFromSupport( capabilityTable.plugin.commands, evidence, diff --git a/packages/agent-bundle/src/adapters/plugin.ts b/packages/agent-bundle/src/adapters/plugin.ts index 88b2693e9..097aeb3be 100644 --- a/packages/agent-bundle/src/adapters/plugin.ts +++ b/packages/agent-bundle/src/adapters/plugin.ts @@ -235,7 +235,7 @@ const artifactValidation = deepFreeze({ }); const metadata = Object.freeze({ - adapterRevision: '1.26.0', + adapterRevision: '1.27.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 @@ -703,11 +703,60 @@ const cursorOnlyCapabilities = Object.freeze(Object.fromEntries( ]), )); +/** + * Feature rows (`.`, #100) published by any host half. The + * composite emits Claude-format commands and Cursor rules, so a feature is + * available to the bundle when the emitting half supports it (union, used by + * inspection); the intersection keeps the honest three-host judgment. + */ +const compositeFeatureCapabilityNames = Object.freeze([...new Set([ + claudeAdapter, codexAdapter, cursorAdapter, +].flatMap((adapter) => Object.keys(adapter.capabilities) + .filter((capability) => /^(?:commands|hooks|rules|skills)\./u.test(capability))))].sort((left, right) => left.localeCompare(right))); + +const compositeFeatureCapability = ( + capability: string, + combine: (left: CapabilityState, right: CapabilityState) => CapabilityState, +): CapabilityState => [claudeAdapter, codexAdapter, cursorAdapter] + .map((adapter) => adapter.capabilities[capability] ?? unavailableCapability( + `The pinned ${adapter.name} contract publishes no ${capability} feature row.`, + )) + .reduce(combine); + +/** + * The composite ships one shared `skills/` tree: a skill lowers to the shared + * Claude/Codex pass-through document only when it declares no host extension + * and no placeholder, and otherwise to the portable document, which strips + * every host extension and admits no Skill Markdown token (AB3008). Neither + * skill feature therefore reaches the composite regardless of what any host + * half supports; the emission-dispatch union must not claim otherwise. + */ +const compositeSkillFeatureCapabilities = Object.freeze({ + 'skills.hostFrontmatter': unavailableCapability( + 'The unified bundle emits one shared skills/ tree and lowers any skill that declares a host frontmatter extension to the portable document, which strips the extension; per-host skill trees are install-time selection (#101).', + ), + 'skills.markdownTokens': unavailableCapability( + 'The unified bundle lowers a skill that uses a Skill Markdown token to the portable document, which documents no interpolation placeholder; the token fails closed (AB3008).', + ), +}); + +const compositeFeatureCapabilities = ( + combine: (left: CapabilityState, right: CapabilityState) => CapabilityState, +): Readonly> => Object.freeze({ + ...Object.fromEntries( + compositeFeatureCapabilityNames + .filter((capability) => !Object.hasOwn(compositeSkillFeatureCapabilities, capability)) + .map((capability) => [capability, compositeFeatureCapability(capability, combine)]), + ), + ...compositeSkillFeatureCapabilities, +}); + const pluginCapabilities: Readonly> = Object.freeze({ ...cursorOnlyCapabilities, ...agentCapabilities, ...codexHookContractUnifiedCapabilities, ...compositeEventCapabilities, + ...compositeFeatureCapabilities(intersectCapabilityStates), bin: unavailableCapability( 'The unified bundle emits the Claude-only bin directory, but the pinned Codex and Cursor contracts declare no shared plugin executable surface.', ), @@ -1057,6 +1106,7 @@ const pluginCapabilities: Readonly> = Object.fre */ const componentCapabilities: Readonly> = Object.freeze({ ...pluginCapabilities, + ...compositeFeatureCapabilities(unionCapabilityStates), ...Object.fromEntries( [cliBinCapability, 'commands', 'hooks', 'lsp', 'mcp', 'nativeDiagnostics', 'nativeExtension', 'rules', 'skills'] .map((capability) => [capability, compositeUnion(capability)]), diff --git a/packages/agent-bundle/src/adapters/portable.ts b/packages/agent-bundle/src/adapters/portable.ts index a14122f4e..615c46319 100644 --- a/packages/agent-bundle/src/adapters/portable.ts +++ b/packages/agent-bundle/src/adapters/portable.ts @@ -21,6 +21,7 @@ import { cliBinCapability, eventRouteCapabilitiesFrom, supportedCapability, + featureCapabilitiesFrom, unavailableCapability, } from './capability-state.ts'; import capabilityTable from './capabilities/portable-1.0.0.json' with { type: 'json' }; @@ -92,7 +93,7 @@ const schemaValidator = createAdapterValidator(); const validatePlugin = schemaValidator.compile(pluginSchema); const validateMcp = schemaValidator.compile(mcpSchema); const metadata = Object.freeze({ - adapterRevision: '1.7.0', + adapterRevision: '1.8.0', observedVersion: capabilityTable.observedSpecificationVersion, schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.version), }); @@ -625,6 +626,9 @@ export const portableAdapter: TargetAdapter = Object.freeze({ // it rides the plugin-root directory the standard's stdio MCP servers // already execute from (#387). [cliBinCapability]: supportedCapability(evidence), + // Component feature sets (#100): the portable Skill document carries only + // the Agent Skills fields and no interpolation placeholders. + ...featureCapabilitiesFrom('skills', capabilityTable.plugin.skillFeatures, evidence), commands: unavailableCapability( 'The portable Agent Plugin contract (1.0.0) defines only skills and MCP components; it has no commands surface.', ), diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index e5321ef27..c88cf67c8 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 { unavailableCapability } from './adapters/capability-state.ts'; +import { capabilityIsSupported, 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'; @@ -18,6 +18,7 @@ import type { CapabilityEvidence, CapabilityState } from './core/capabilities.ts import { agentComponentKinds, componentKindCapabilityName, + featureCapabilityName, type AgentComponentKind, } from './core/components.ts'; import { isInsideOrEqual } from './core/paths.ts'; @@ -298,12 +299,24 @@ export type InspectionComponentKind = AgentComponentKind; */ export type InspectionComponentCapability = CapabilityState & { readonly name: string }; +/** + * One host feature a selected component uses that this target cannot express + * (#100 feature sets): the component still ships, minus the feature, and the + * host's own `.` row explains why. + */ +export interface InspectionOmittedFeature { + readonly capability: InspectionComponentCapability; + readonly feature: string; +} + /** One component the plan emits for this target. */ export interface InspectionSelectedComponent { readonly capability?: InspectionComponentCapability; readonly id: string; readonly kind: InspectionComponentKind; readonly name: string; + /** Features this target omits from the emitted component, in feature order; absent when none. */ + readonly omittedFeatures?: readonly InspectionOmittedFeature[]; } /** @@ -620,6 +633,8 @@ export const validate = async (options: ValidateOptions): Promise.` rows. */ + readonly features?: readonly string[]; readonly id: string; readonly kind: InspectionComponentKind; readonly name: string; @@ -629,10 +644,12 @@ interface InspectableComponent { const fixedKindComponent = ( kind: Exclude, component: { readonly id: string; readonly name: string; readonly targets: readonly string[] }, + features: readonly string[] = [], ): InspectableComponent => { const capability = componentKindCapabilityName(kind); return { ...(capability === undefined ? {} : { capability }), + ...(features.length === 0 ? {} : { features: [...features].sort((left, right) => left.localeCompare(right)) }), id: component.id, kind, name: component.name, @@ -640,6 +657,32 @@ const fixedKindComponent = ( }; }; +/** Frontmatter keys are the features a command or rule uses (the closed sets from #219/#207). */ +const frontmatterFeatures = (frontmatter: Readonly>): readonly string[] => + Object.keys(frontmatter); + +const hookFeatures = (hook: NormalizedPlugin['hooks'][number]): readonly string[] => [ + ...(hook.timeoutMs === undefined ? [] : ['timeout']), + ...(hook.tools.length === 0 && (hook.nativeTools ?? []).length === 0 ? [] : ['toolMatchers']), +]; + +/** + * Skill features follow the Skill IR (#108): typed host frontmatter extensions + * and Skill Markdown placeholder tokens. The IR already fails closed per host + * (AB3006/AB3008/AB3010); inspection reports the same classes against the + * host's `skills.*` rows. + */ +const skillFeatures = (skill: NormalizedPlugin['skills'][number]): readonly string[] => { + const ir = skill.skillIr; + if (ir === undefined) return []; + return [ + ...(ir.extensions.claude === undefined && ir.extensions.codex === undefined && ir.extensions.cursor === undefined + ? [] + : ['hostFrontmatter']), + ...(ir.placeholders.length === 0 ? [] : ['markdownTokens']), + ]; +}; + /** * Every project component in canonical-kind terms. Config-declared hooks stay * `hook` (judged by the host's `hooks` row); filesystem event routes are the @@ -655,9 +698,9 @@ const inspectableComponents = (model: NormalizedPlugin): readonly InspectableCom name: bin.name, targets: model.targets.map((target) => target.name), })), - ...(model.commands ?? []).map((command) => fixedKindComponent('command', command)), + ...(model.commands ?? []).map((command) => fixedKindComponent('command', command, frontmatterFeatures(command.frontmatter))), ...model.hooks.map((hook) => hook.eventRoute === undefined - ? fixedKindComponent('hook', { id: hook.id, name: hook.event, targets: hook.targets }) + ? fixedKindComponent('hook', { id: hook.id, name: hook.event, targets: hook.targets }, hookFeatures(hook)) : { capability: `event:${hook.eventRoute.event}`, id: hook.id, @@ -668,11 +711,33 @@ const inspectableComponents = (model: NormalizedPlugin): readonly InspectableCom ...(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.rules ?? []).map((rule) => fixedKindComponent('rule', rule, frontmatterFeatures(rule.frontmatter))), ...model.scripts.map((script) => fixedKindComponent('script', script)), - ...model.skills.map((skill) => fixedKindComponent('skill', skill)), + ...model.skills.map((skill) => fixedKindComponent('skill', skill, skillFeatures(skill))), ]; +/** + * The features a selected component uses that this target's `.` + * rows do not support. A host with no row for a feature has not evidenced it, + * so the feature reads as omitted `unavailable` rather than silently kept. + */ +const omittedFeaturesFor = ( + component: InspectableComponent, + target: string, + capabilities: Readonly>, +): readonly InspectionOmittedFeature[] => { + if (component.capability === undefined || component.features === undefined) return Object.freeze([]); + const kindCapability = component.capability; + return Object.freeze(component.features.flatMap((feature) => { + const capability = componentCapabilityFor( + { ...component, capability: featureCapabilityName(kindCapability, feature) }, + target, + capabilities, + ); + return capability === undefined || capabilityIsSupported(capability) ? [] : [Object.freeze({ capability, feature })]; + })); +}; + /** * The target's judgment for one component capability. An adapter that * publishes no row for a capability it is asked about has not evidenced it, so @@ -800,7 +865,11 @@ const accountComponentsFor = ( } else if (capability !== undefined && !admitsComponent(component.kind, capability)) { skipped.push(Object.freeze({ ...identity, reason: 'unsupported-capability' satisfies InspectionSkipReason })); } else { - selected.push(Object.freeze(identity)); + const omittedFeatures = omittedFeaturesFor(component, target, capabilities); + selected.push(Object.freeze({ + ...identity, + ...(omittedFeatures.length === 0 ? {} : { omittedFeatures }), + })); } } return { diff --git a/packages/agent-bundle/src/cli.ts b/packages/agent-bundle/src/cli.ts index 4550f7cde..30fa6eaaa 100644 --- a/packages/agent-bundle/src/cli.ts +++ b/packages/agent-bundle/src/cli.ts @@ -17,6 +17,7 @@ import type { runEvals, startDevServer, validate, + InspectionComponentCapability, InspectionSkippedComponent, ProjectOptions, } from './api.ts'; @@ -427,6 +428,13 @@ const writeHumanInspect = (output: Output, result: Awaited.` row does not support. + for (const component of plan.selected) { + for (const omitted of component.omittedFeatures ?? []) { + output.write(` ${component.kind} ${component.name} omits ${omitted.feature}: ${formatCapabilityJudgment(omitted.capability)}\n`); + } + } // The kind matrix names every canonical kind this host cannot emit, with // the host's own state, even when the project declares none of them. const unsupportedKinds = plan.kinds @@ -438,6 +446,21 @@ const writeHumanInspect = (output: Output, result: Awaited { + switch (capability.state) { + case 'supported': + return `${capability.name} supported`; + case 'degraded': + case 'unavailable': + case 'prohibited': + return `${capability.name} ${capability.state} — ${capability.reason}`; + default: { + const exhaustive: never = capability; + throw new TypeError(`Unhandled capability state ${JSON.stringify(exhaustive)}.`); + } + } +}; + const formatInspectionOmission = (component: InspectionSkippedComponent): string => { switch (component.reason) { case 'excluded-by-targets': @@ -445,18 +468,7 @@ const formatInspectionOmission = (component: InspectionSkippedComponent): string case 'unsupported-capability': { const capability = component.capability; if (capability === undefined) return 'unsupported capability'; - switch (capability.state) { - case 'supported': - return `${capability.name} supported`; - case 'degraded': - case 'unavailable': - case 'prohibited': - return `${capability.name} ${capability.state} — ${capability.reason}`; - default: { - const exhaustive: never = capability; - throw new TypeError(`Unhandled capability state ${JSON.stringify(exhaustive)}.`); - } - } + return formatCapabilityJudgment(capability); } default: { const exhaustive: never = component.reason; diff --git a/packages/agent-bundle/src/config/validate.ts b/packages/agent-bundle/src/config/validate.ts index 332463410..76a46bd76 100644 --- a/packages/agent-bundle/src/config/validate.ts +++ b/packages/agent-bundle/src/config/validate.ts @@ -21,6 +21,7 @@ import { } from '../core/runtime.ts'; import { canonicalHookEvents, isPrebuiltEntryInput, parseNativeHookToolSelector } from '../core/types.ts'; import { type RouteModuleExports, scanRouteModuleExports } from '../routes/contract.ts'; +import { featureCapabilityName } from '../core/components.ts'; import type { AgentBundleBinEntry, AgentBundleHookEntry, @@ -996,6 +997,8 @@ const validateSkill = (skill: SkillDocument): Diagnostic[] => { interface TargetedDocument { readonly authoredTargets?: readonly string[]; readonly diagnostics: readonly Diagnostic[]; + /** Validated frontmatter; every key is a host feature the component uses. */ + readonly frontmatter: Readonly>; readonly source: string; } @@ -1003,9 +1006,87 @@ interface TargetedDocumentCodes { /** Explicit target whose capability is degraded, prohibited, or unavailable. */ readonly capability: string; readonly duplicateName: string; + /** Implicit target that cannot express a frontmatter feature: the feature is omitted with the host's reason. */ + readonly featureOmitted: string; + /** Explicit target that cannot express a frontmatter feature: fail closed. */ + readonly featureRequired: string; readonly outsideTargets: string; } +/** + * The host's judgment of one component feature row (`.`, #100), + * read through the emission-dispatch view so the composite `plugin` target is + * judged by the half that emits the kind. A host that publishes no row for a + * feature it is asked about has not evidenced it and reads as `unavailable`. + */ +const featureCapabilityStateFor = ( + registry: NormalizationTargetRegistry, + target: string, + capabilityName: string, +): CapabilityState => { + const lookup = registry.componentCapabilityState ?? registry.capabilityState; + return lookup?.call(registry, target, capabilityName) + ?? { reason: `The ${target} adapter publishes no ${capabilityName} capability row.`, state: 'unavailable' }; +}; + + +/** + * Component feature sets (#100): a component may use only the host features the + * target's pinned feature rows support. An author-required target fails closed; + * an implicitly selected target still receives the component, minus the + * feature, and the omission is reported with the host's own reason. Targets + * whose kind row is not supported are judged by the kind-level checks instead. + */ +const validateDocumentFeatures = ( + document: TargetedDocument, + name: string, + label: 'Command' | 'Rule', + capabilityName: 'commands' | 'rules', + codes: TargetedDocumentCodes, + selectedTargets: readonly string[], + registry: NormalizationTargetRegistry, +): Diagnostic[] => { + const diagnostics: Diagnostic[] = []; + const features = Object.keys(document.frontmatter).sort((left, right) => left.localeCompare(right)); + if (features.length === 0) return diagnostics; + const explicit = document.authoredTargets !== undefined; + const targets = explicit + ? document.authoredTargets!.filter((target) => registry.has(target) && selectedTargets.includes(target)) + : selectedTargets.filter((target) => registry.has(target)); + for (const target of targets) { + if (!capabilityIsSupported(featureCapabilityStateFor(registry, target, capabilityName))) continue; + for (const feature of features) { + const featureName = featureCapabilityName(capabilityName, feature); + const capability = featureCapabilityStateFor(registry, target, featureName); + switch (capability.state) { + case 'supported': + break; + case 'degraded': + case 'prohibited': + case 'unavailable': + diagnostics.push({ + code: explicit ? codes.featureRequired : codes.featureOmitted, + message: explicit + ? `${label} ${JSON.stringify(name)} uses ${feature} and explicitly targets ${JSON.stringify(target)}, whose ${featureName} capability is ${capability.state}: ${capability.reason}` + : `${label} ${JSON.stringify(name)} uses ${feature}, which ${target} omits (${featureName} ${capability.state}): ${capability.reason}`, + recovery: explicit + ? `Remove ${feature} from the ${label.toLowerCase()} or drop ${JSON.stringify(target)} from its targets.` + : `Accept the omission, restrict the ${label.toLowerCase()}'s targets to hosts that support ${featureName}, or remove ${feature}.`, + severity: explicit ? 'error' : 'warning', + sourcePath: document.source, + target, + }); + break; + default: { + const exhaustive: never = capability; + return exhaustive; + } + } + } + } + return diagnostics; +}; + /** Shared validator for commands and rules, which differ only in label and codes. */ const validateTargetedDocuments = ( loaded: LoadedConfig, @@ -1033,6 +1114,8 @@ const validateTargetedDocuments = ( )); } + diagnostics.push(...validateDocumentFeatures(document, name, label, capabilityName, codes, selectedTargets, registry)); + for (const target of document.authoredTargets ?? []) { if (!registry.has(target) || !selectedTargets.includes(target)) { diagnostics.push({ @@ -1088,6 +1171,8 @@ const validateCommands = ( validateTargetedDocuments(loaded, discovered.commands ?? [], 'Command', 'commands', { capability: 'AB4925', duplicateName: 'AB4926', + featureOmitted: 'AB4928', + featureRequired: 'AB4927', outsideTargets: 'AB4924', }, registry); @@ -1099,6 +1184,8 @@ const validateRules = ( validateTargetedDocuments(loaded, discovered.rules ?? [], 'Rule', 'rules', { capability: 'AB4905', duplicateName: 'AB4906', + featureOmitted: 'AB4908', + featureRequired: 'AB4907', outsideTargets: 'AB4904', }, registry); diff --git a/packages/agent-bundle/src/core/components.ts b/packages/agent-bundle/src/core/components.ts index 888fe7f2d..e129369bf 100644 --- a/packages/agent-bundle/src/core/components.ts +++ b/packages/agent-bundle/src/core/components.ts @@ -61,6 +61,15 @@ export const agentComponentKinds: readonly AgentComponentKind[] = Object.freeze( export const componentKindCapability = (kind: AgentComponentKind): AgentComponentKindCapability => componentKindCapabilities[kind]; +/** + * Component feature rows (#100): `.` names one host + * feature a component kind may use (a command frontmatter field, a rule + * frontmatter field, a hook matcher or timeout, a Skill IR feature class). + * Build-time enforcement and `inspect` look the row up under exactly this name. + */ +export const featureCapabilityName = (kindCapability: string, feature: string): string => + `${kindCapability}.${feature}`; + /** 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]; diff --git a/packages/agent-bundle/tests/adapter-capability-states.test.ts b/packages/agent-bundle/tests/adapter-capability-states.test.ts index 4ae777237..9031281b5 100644 --- a/packages/agent-bundle/tests/adapter-capability-states.test.ts +++ b/packages/agent-bundle/tests/adapter-capability-states.test.ts @@ -258,6 +258,62 @@ it('records dated unavailable native-diagnostics and native-extension rows on ev expect(claudeCapabilityTable.plugin.lsp.optionalFields).toContain('diagnostics'); }); +it('publishes dated component feature rows per kind and host (#100 feature sets)', () => { + const registry = createDefaultRegistry(); + const claude = registry.get('claude').capabilities; + const codex = registry.get('codex').capabilities; + const cursor = registry.get('cursor').capabilities; + const portable = registry.get('portable').capabilities; + const plugin = registry.get('plugin'); + + // Commands: Claude documents the five frontmatter fields; Cursor's commands + // surface is frontmatter-free, so every field row is unavailable there. + const commandFields = ['allowedTools', 'argumentHint', 'description', 'disableModelInvocation', 'model']; + expect(Object.keys(claudeCapabilityTable.plugin.commandFrontmatter.fields)).toEqual(commandFields); + expect(cursorCapabilityTable.plugin.commandFrontmatter.fields).toEqual(commandFields); + for (const field of commandFields) { + expect(claude[`commands.${field}`]).toMatchObject({ evidence: { target: 'claude' }, state: 'supported' }); + expect(cursor[`commands.${field}`]).toEqual({ reason: cursorCapabilityTable.plugin.commandFrontmatter.reason, state: 'unavailable' }); + expect(codex[`commands.${field}`]).toBeUndefined(); + expect(portable[`commands.${field}`]).toBeUndefined(); + // Composite: intersection stays honest, emission dispatch follows the Claude half. + expect(plugin.capabilities[`commands.${field}`]).toMatchObject({ state: 'unavailable' }); + expect(plugin.componentCapabilities?.[`commands.${field}`]).toMatchObject({ evidence: { target: 'claude' }, state: 'supported' }); + } + expect(cursorCapabilityTable.plugin.commandFrontmatter.evidence.some((entry) => entry.startsWith('2026-09-03: '))).toBe(true); + + // Rules: only Cursor publishes a rules surface, with the three documented .mdc fields. + for (const field of ['alwaysApply', 'description', 'globs']) { + expect(cursor[`rules.${field}`]).toMatchObject({ evidence: { target: 'cursor' }, state: 'supported' }); + expect(claude[`rules.${field}`]).toBeUndefined(); + expect(plugin.componentCapabilities?.[`rules.${field}`]).toMatchObject({ evidence: { target: 'cursor' }, state: 'supported' }); + } + expect(cursorCapabilityTable.plugin.ruleFrontmatter.evidence[0]).toMatch(/^retrieved 2026-09-03: https:\/\/cursor\.com\/docs\/context\/rules/u); + + // Hooks: every hook host pins timeout and tool matchers in its hooks schema. + for (const capabilities of [claude, codex, cursor]) { + expect(capabilities['hooks.timeout']).toMatchObject({ state: 'supported' }); + expect(capabilities['hooks.toolMatchers']).toMatchObject({ state: 'supported' }); + } + expect(portable['hooks.timeout']).toBeUndefined(); + + // Skills follow the Skill IR: typed host frontmatter on the three hosts, Markdown tokens on Claude only. + expect(claude['skills.hostFrontmatter']).toMatchObject({ state: 'supported' }); + expect(codex['skills.hostFrontmatter']).toMatchObject({ state: 'supported' }); + expect(cursor['skills.hostFrontmatter']).toMatchObject({ state: 'supported' }); + expect(portable['skills.hostFrontmatter']).toMatchObject({ reason: expect.stringContaining('Agent Skills'), state: 'unavailable' }); + expect(claude['skills.markdownTokens']).toMatchObject({ state: 'supported' }); + for (const capabilities of [codex, cursor, portable]) { + expect(capabilities['skills.markdownTokens']).toMatchObject({ reason: expect.stringContaining('AB3008'), state: 'unavailable' }); + } + // The composite's shared skills/ tree falls back to the portable document for + // any skill with a host extension or token, so neither feature reaches it. + for (const capability of ['skills.hostFrontmatter', 'skills.markdownTokens']) { + expect(plugin.componentCapabilities?.[capability]).toMatchObject({ reason: expect.stringContaining('portable document'), state: 'unavailable' }); + expect(plugin.capabilities[capability]).toEqual(plugin.componentCapabilities?.[capability]); + } +}); + it('judges composite event routes by the same intersection validation applies (#100 event-route kind)', () => { const registry = createDefaultRegistry(); const plugin = registry.get('plugin'); diff --git a/packages/agent-bundle/tests/adapter-metadata.test.ts b/packages/agent-bundle/tests/adapter-metadata.test.ts index bc2a0e864..5db429328 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.7.0', + adapterRevision: '1.8.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.10.0', + adapterRevision: '1.11.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.23.0', + adapterRevision: '1.24.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.10.0', + adapterRevision: '1.11.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.26.0'); + expect(registryMetadata(registry, 'plugin').adapterRevision).toBe('1.27.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 5b2cf52a0..74e073d5a 100644 --- a/packages/agent-bundle/tests/api.test.ts +++ b/packages/agent-bundle/tests/api.test.ts @@ -717,8 +717,10 @@ it('reports skipped target/component pairs against each target emission surface' await Promise.all([ writeFile(join(root, 'src', 'commands', 'shared.md'), '---\ndescription: Shared command\n---\nShared command prompt.\n'), writeFile( + // Cursor's commands surface is frontmatter-free, so a Cursor-required + // command carries only the authoring-only `targets` key (#100 feature sets). join(root, 'src', 'commands', 'cursor-only.md'), - '---\ndescription: Cursor-only command\ntargets:\n - cursor\n---\nCursor command prompt.\n', + '---\ntargets:\n - cursor\n---\nCursor command prompt.\n', ), writeFile(join(root, 'src', 'report.ts'), 'export const report = true;\n'), writeFile(join(root, 'src', 'rules', 'shared.mdc'), '---\ndescription: Shared rule\n---\nShared guidance.\n'), @@ -1111,6 +1113,76 @@ it('never reports an lsp component as selected when the declaring planner reject } }); +it('reports omitted component features per target from the host feature rows (#100 feature sets)', async () => { + const root = await createProject(); + try { + await Promise.all([ + mkdir(join(root, 'src', 'commands'), { recursive: true }), + mkdir(join(root, 'src', 'rules'), { recursive: true }), + ]); + await Promise.all([ + writeFile(join(root, 'src', 'commands', 'deploy.md'), '---\nargumentHint: \ndescription: Deploy\n---\nDeploy prompt.\n'), + writeFile(join(root, 'src', 'rules', 'style.mdc'), '---\nalwaysApply: true\nglobs: src/**\n---\nStyle guidance.\n'), + writeFile(join(root, 'agent-bundle.config.ts'), [ + 'export default {', + " hooks: { beforeTool: { handler: './src/hook.ts', timeout: 3, tools: ['shell'] } },", + " plugin: { name: 'api-fixture', version: '1.0.0' },", + " targets: ['claude', 'cursor', 'plugin'],", + '};', + '', + ].join('\n')), + ]); + + const result = await readyInspection({ root }); + const selectedOn = (target: string, kind: string) => + result.plans.find((plan) => plan.target === target)!.selected.find((component) => component.kind === kind)!; + + // Cursor ships the command body only: both authored fields are reported as + // omitted with the host's own `commands.` judgment, in feature order. + expect(selectedOn('cursor', 'command').omittedFeatures).toEqual([ + { capability: { name: 'commands.argumentHint', reason: expect.stringContaining('frontmatter-free'), state: 'unavailable' }, feature: 'argumentHint' }, + { capability: { name: 'commands.description', reason: expect.stringContaining('frontmatter-free'), state: 'unavailable' }, feature: 'description' }, + ]); + expect(Object.isFrozen(selectedOn('cursor', 'command').omittedFeatures)).toBe(true); + // Claude documents every field and the composite emits Claude-format + // commands, so neither omits anything; a component with no omissions has no key. + expect(selectedOn('claude', 'command')).not.toHaveProperty('omittedFeatures'); + expect(selectedOn('plugin', 'command')).not.toHaveProperty('omittedFeatures'); + // Cursor documents every .mdc field; hooks pin timeout and matchers on both hosts. + expect(selectedOn('cursor', 'rule')).not.toHaveProperty('omittedFeatures'); + expect(selectedOn('plugin', 'rule')).not.toHaveProperty('omittedFeatures'); + for (const target of ['claude', 'cursor', 'plugin']) { + expect(selectedOn(target, 'hook')).not.toHaveProperty('omittedFeatures'); + } + // A skill with a Claude host extension keeps it on Claude, but the + // composite's shared skills/ tree lowers it to the portable document, so + // the composite reports the host frontmatter as omitted. + await writeFile( + join(root, 'src', 'skills', 'review', 'SKILL.md'), + '---\nname: review\ndescription: Reviews changes\ntargets:\n claude:\n model: sonnet\n---\n# Review\n', + ); + const withExtension = await readyInspection({ root }); + const skillOn = (target: string) => + withExtension.plans.find((plan) => plan.target === target)!.selected.find((component) => component.kind === 'skill')!; + expect(skillOn('claude')).not.toHaveProperty('omittedFeatures'); + expect(skillOn('plugin').omittedFeatures).toEqual([ + { capability: { name: 'skills.hostFrontmatter', reason: expect.stringContaining('portable document'), state: 'unavailable' }, feature: 'hostFrontmatter' }, + ]); + const pluginSkill = withExtension.model.skills[0]!.hostDocuments?.plugin; + expect(pluginSkill?.frontmatter).not.toHaveProperty('model'); + // `validate` surfaces the matching omit-with-reason warnings for the + // implicit Cursor target; inspect stays a ready plan. + const validated = await validate({ root }); + expect(validated.diagnostics.filter((diagnostic) => diagnostic.code === 'AB4928')).toEqual([ + expect.objectContaining({ message: expect.stringContaining('Command "deploy" uses argumentHint, which cursor omits'), severity: 'warning', target: 'cursor' }), + expect.objectContaining({ message: expect.stringContaining('Command "deploy" uses description, which cursor omits'), severity: 'warning', target: 'cursor' }), + ]); + expect(validated.diagnostics.some((diagnostic) => diagnostic.code === 'AB4927' || diagnostic.code === 'AB4907' || diagnostic.code === 'AB4908')).toBe(false); + } 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({ @@ -1400,7 +1472,6 @@ it('keeps rule and command model digests root-independent and sensitive to conte const sharedRule = '---\ndescription: Shared guidance\n---\nShared body.\n'; const targetedCommand = [ '---', - 'description: Cursor-only command', 'targets:', ' - cursor', '---', diff --git a/packages/agent-bundle/tests/cli.test.ts b/packages/agent-bundle/tests/cli.test.ts index c90fa13ee..cf8e281de 100644 --- a/packages/agent-bundle/tests/cli.test.ts +++ b/packages/agent-bundle/tests/cli.test.ts @@ -481,6 +481,22 @@ 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'); + // A feature the host cannot express is reported on the shipped component + // (#100 feature sets): select Cursor too and add one command with a field + // Cursor's frontmatter-free commands surface drops. + const originalConfig = await readFile(join(project.root, 'agent-bundle.config.ts'), 'utf8'); + await mkdir(join(project.root, 'src', 'commands'), { recursive: true }); + await Promise.all([ + writeFile(join(project.root, 'src', 'commands', 'deploy.md'), '---\nargumentHint: \n---\nDeploy.\n'), + writeFile(join(project.root, 'agent-bundle.config.ts'), originalConfig.replace("['portable', 'codex']", "['portable', 'codex', 'cursor']")), + ]); + const withCursor = await runSourceCliWithOutput(['inspect', '--root', project.root, '--target', 'cursor']); + expect(withCursor).toMatchObject({ code: 0, stderr: '' }); + expect(withCursor.stdout).toMatch(/^ {2}command deploy omits argumentHint: commands\.argumentHint unavailable — .*frontmatter-free.*$/mu); + await Promise.all([ + rm(join(project.root, 'src', 'commands'), { force: true, recursive: true }), + writeFile(join(project.root, 'agent-bundle.config.ts'), originalConfig), + ]); // The canonical kind matrix names every kind a host cannot emit, even // kinds this project never declares (#100). expect(human.stdout).toContain( diff --git a/packages/agent-bundle/tests/command-config.test.ts b/packages/agent-bundle/tests/command-config.test.ts index a149954c2..228af8edf 100644 --- a/packages/agent-bundle/tests/command-config.test.ts +++ b/packages/agent-bundle/tests/command-config.test.ts @@ -177,6 +177,54 @@ it('discovers flat non-ignored commands deterministically and omits the collecti }); }); +it('enforces command feature sets: explicit Cursor targets fail closed, implicit ones omit with the host reason (#100)', async () => { + await withProject(async (root) => { + const registry = createDefaultRegistry(); + const command = (authoredTargets?: readonly string[]): CommandDocument => ({ + ...(authoredTargets === undefined ? {} : { authoredTargets }), + body: '# Deploy\n', + diagnostics: [], + frontmatter: { argumentHint: '', description: 'Deploy the service' }, + markdown: '---\nargumentHint: \ndescription: Deploy the service\n---\n# Deploy\n', + source: join(root, 'src', 'commands', 'deploy.md'), + }); + + // Cursor's documented commands surface is frontmatter-free: every field + // row is unavailable, so an author-required Cursor target fails closed. + const explicit = validateSource(loadedProject(root, ['claude', 'cursor']), { commands: [command(['cursor'])], skills: [] }, registry); + expect(explicit).toEqual([ + expect.objectContaining({ + code: 'AB4927', + message: expect.stringMatching(/uses argumentHint and explicitly targets "cursor", whose commands\.argumentHint capability is unavailable: .*frontmatter-free/u), + recovery: expect.stringContaining('Remove argumentHint'), + severity: 'error', + target: 'cursor', + }), + expect.objectContaining({ code: 'AB4927', message: expect.stringContaining('uses description'), severity: 'error', target: 'cursor' }), + ]); + + // Implicit selection ships the command everywhere the kind is supported and + // records each omitted feature per host as a warning; Claude expresses both. + const implicit = validateSource(loadedProject(root, ['claude', 'cursor', 'codex']), { commands: [command()], skills: [] }, registry); + expect(implicit).toEqual([ + expect.objectContaining({ + code: 'AB4928', + message: expect.stringMatching(/^Command "deploy" uses argumentHint, which cursor omits \(commands\.argumentHint unavailable\): /u), + recovery: expect.stringContaining('Accept the omission'), + severity: 'warning', + target: 'cursor', + }), + expect.objectContaining({ code: 'AB4928', message: expect.stringContaining('uses description, which cursor omits'), severity: 'warning', target: 'cursor' }), + ]); + expect(implicit.some((diagnostic) => diagnostic.target === 'claude' || diagnostic.target === 'codex')).toBe(false); + + // A Claude-only command uses only features Claude documents: silence. + expect(validateSource(loadedProject(root, ['claude']), { commands: [command()], skills: [] }, registry)).toEqual([]); + // The composite emits Claude-format commands, so its feature rows follow the Claude half. + expect(validateSource(loadedProject(root, ['plugin']), { commands: [command()], skills: [] }, registry)).toEqual([]); + }); +}); + it('normalizes peeled targets and reports unknown, unavailable, and duplicate commands', async () => { await withProject(async (root) => { const command = ( diff --git a/packages/agent-bundle/tests/rule-config.test.ts b/packages/agent-bundle/tests/rule-config.test.ts index 54c09f45b..f0e735b0a 100644 --- a/packages/agent-bundle/tests/rule-config.test.ts +++ b/packages/agent-bundle/tests/rule-config.test.ts @@ -198,6 +198,46 @@ it('discovers flat non-ignored rules deterministically and omits the collection }); }); +it('judges rule frontmatter features against the pinned Cursor .mdc field rows (#100)', async () => { + await withProject(async (root) => { + const registry = createDefaultRegistry(); + const rule: RuleDocument = { + body: '# Rule\n', + diagnostics: [], + emittedMarkdown: '---\nalwaysApply: true\nglobs: src/**\n---\n# Rule\n', + frontmatter: { alwaysApply: true, description: 'Rule guidance', globs: 'src/**' }, + markdown: '---\nalwaysApply: true\ndescription: Rule guidance\nglobs: src/**\n---\n# Rule\n', + source: join(root, 'src', 'rules', 'review.mdc'), + }; + // Cursor documents description, globs, and alwaysApply (retrieved + // 2026-09-03), so every authored field is supported: no diagnostic, and + // hosts without a rules surface are judged by the kind row, not per field. + expect(validateSource(loadedProject(root, ['cursor', 'claude', 'codex', 'portable', 'plugin']), { rules: [rule], skills: [] }, registry)).toEqual([]); + for (const field of ['alwaysApply', 'description', 'globs']) { + expect(registry.get('cursor').capabilities[`rules.${field}`]).toMatchObject({ evidence: { target: 'cursor' }, state: 'supported' }); + } + + // A synthetic host that supports rules but publishes no field rows omits + // every feature honestly (AB4908) and fails an explicit target (AB4907). + const rulesOnly = createDefaultRegistry().register({ + capabilities: { rules: { evidence: { observedVersion: 'test', target: 'ruleshost' }, state: 'supported' } }, + metadata: { adapterRevision: 'test', observedVersion: 'test', schemas: [] }, + name: 'ruleshost', + plan: () => ({ diagnostics: [], entries: [] }), + }); + expect(validateSource(loadedProject(root, ['ruleshost']), { rules: [rule], skills: [] }, rulesOnly)).toEqual([ + expect.objectContaining({ code: 'AB4908', message: expect.stringContaining('uses alwaysApply, which ruleshost omits (rules.alwaysApply unavailable): The ruleshost adapter publishes no rules.alwaysApply capability row.'), severity: 'warning', target: 'ruleshost' }), + expect.objectContaining({ code: 'AB4908', message: expect.stringContaining('uses description'), severity: 'warning' }), + expect.objectContaining({ code: 'AB4908', message: expect.stringContaining('uses globs'), severity: 'warning' }), + ]); + expect(validateSource(loadedProject(root, ['ruleshost']), { rules: [{ ...rule, authoredTargets: ['ruleshost'] }], skills: [] }, rulesOnly)).toEqual([ + expect.objectContaining({ code: 'AB4907', severity: 'error', target: 'ruleshost' }), + expect.objectContaining({ code: 'AB4907', severity: 'error' }), + expect.objectContaining({ code: 'AB4907', severity: 'error' }), + ]); + }); +}); + it('normalizes peeled rule targets and reports unknown, unavailable, and duplicate rules', async () => { await withProject(async (root) => { const rule = (