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

Explain host component selection in `inspect`. Every inspection plan now
lists `selected` components beside `skipped`, and each component that needs a
host capability carries that target's own four-state judgment as
`capability` — `supported` with pinned evidence for emitted surfaces, or
`degraded`/`unavailable`/`prohibited` with the host's reason for omissions —
so `inspect --json` explains why a surface is absent from a bundle in the
host's words. An adapter that publishes no row for a needed capability reads
as an honest `unavailable`. Human `inspect` output prints one accounting line
per target followed by each omission and its reason.
33 changes: 33 additions & 0 deletions docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ gate a build, a validation, or a dev rebuild.
| `AB472x` | The `tools.rsbuild` / `tools.rspack` escape hatch. |
| `AB473x` | Migration nudges (informational; see below). |
| `AB474x`/`AB4750` | Prebuilt payloads and prebuilt entries (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. |
| `AB5000` | General CLI and adapter failures. |
| `AB60xx` | Built-artifact validation, including schema documents and referenced files (`AB6025`: a manifest-declared `logo` path is missing from the artifact or escapes the deploy tree; `AB6034`: emitted Skill Markdown has no instruction body). |
| `AB700x` | Host installation: bundle identity, host availability, scope, command failure, and collision checks. |
Expand Down Expand Up @@ -252,6 +253,38 @@ simply not been built yet is a validation **warning** that only
| `AB4749` | error (build) | A payload directory overlaps the artifact `--output` root. |
| `AB4750` | info | A payload is older than the newest project source file and may be stale; rerun the project's own build if so. |

## Conventional host components: rules and commands (`AB4900`–`AB4906`, `AB4920`–`AB4926`)

Conventional `src/rules/*.mdc` documents compile to the Rule IR (closed
frontmatter: `description`, `globs`, `alwaysApply`, plus the bundle-only
`targets` key that is peeled before emission) and `src/commands/*.md`
documents compile to the Command IR (closed frontmatter: `description`,
`argumentHint`, `allowedTools`, `model`, `disableModelInvocation`, plus
`targets`). Each host lowers only the surfaces its pinned capability table
supports; a document without `targets` is emitted where supported and
accounted as `skipped` with the host's judgment elsewhere (see
`agent-bundle inspect`), while a document that explicitly names a host without
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.

| Code | Severity | Trigger | Recovery |
| --- | --- | --- | --- |
| `AB4900` | error | A conventional rule file cannot be read. | Make the `.mdc` file readable, or remove it from `src/rules/`. |
| `AB4901` | error | Rule YAML frontmatter is invalid. | Repair the YAML between the `---` fences. |
| `AB4902` | error | Rule frontmatter declares a field outside `description`, `globs`, `alwaysApply`, `targets`. | Remove the field; host-specific rule metadata is not part of the closed contract. |
| `AB4903` | error | A rule frontmatter field has the wrong shape (`description` string, `globs` nonempty string or array, `alwaysApply` boolean, `targets` array of target names). | Fix the field's value. |
| `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. |
| `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. |
| `AB4923` | error | A command frontmatter field has the wrong shape (`allowedTools` nonempty string or array, string fields, `disableModelInvocation` boolean, `targets` array of target names). | Fix the field's value. |
| `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. |

## Route graph, state, and provider conventions (`AB4800`–`AB4825`, `AB4940`–`AB4942`)

The route-graph compiler discovers conventional route modules
Expand Down
22 changes: 22 additions & 0 deletions docs/entry-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,28 @@ to `tools.rspack` mutator functions —
`tools: { rspack: (config, { rspack }) => { ... } }` — which always hands the
engine's own `rspack` object.

### `agent-bundle inspect` component accounting

```sh
agent-bundle inspect [--target <t>] [--json]
```

Every inspection plan accounts for each host component the project declares
— skills, commands, rules, hooks, MCP servers, MCP Apps, and scripts — as
either `selected` (emitted for that target) or `skipped` (omitted), in one
deterministic order. A skipped component names its cause: `excluded-by-targets`
when the author's `targets` left the host out, or `unsupported-capability` when
the host's pinned capability table does not support the surface. Components
that need a host capability carry that target's own four-state judgment as
`capability` — `{ name, state: 'supported', evidence }` for emitted surfaces,
or `{ name, state: 'degraded' | 'unavailable' | 'prohibited', reason }` — so
the JSON explains why a Cursor rule is absent from a Claude bundle in the
host's words rather than the compiler's. An adapter that publishes no row for
a needed capability reads as an honest `unavailable`, never a silent pass.
Scripts need no host capability and carry none. The human output prints one
line per target (`<target>: N component(s) selected, M omitted`) followed by
each omission and its reason.

### `agent-bundle inspect --bundler`

```sh
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-bundle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ manifests at files inside those payloads without compiling them. Payload files c
| `agent-bundle prepack` | Run the release build, dry-run npm packing without scripts, and verify packaged outputs, artifact hashes, bins, and versions (`--output` and `--json` supported). |
| `agent-bundle install <host>` | Install a built bundle into Claude, Codex, or Cursor (`--from`, `--scope`, and `--json` supported). |
| `agent-bundle validate` | Validate project source, or an artifact with `--artifact`. |
| `agent-bundle inspect` | Inspect normalized targets and adapter plans from source. |
| `agent-bundle inspect` | Inspect normalized targets and adapter plans from source, with per-target component accounting: which skills, commands, rules, hooks, MCP surfaces, and scripts each host emits and, for every omission, whether the author excluded it or the host's pinned capability judgment (`degraded`/`unavailable`/`prohibited`, with reason) ruled it out. |
| `agent-bundle inspect --bundler` | Dump the synthesized Rslib/Rsbuild configs (post-`tools`-hatch merge) for every generated output. |
| `agent-bundle mcp list` / `mcp invoke` | List or invoke one MCP tool from an artifact. |
| `agent-bundle mcp run` | Run one built stdio MCP server in the foreground, resolving its hashed entry, loading the project-root `.env` set (`--env-file`/`--no-env` to override), and expanding env state anchors to the project root (`--plugin-root` to override). Environment precedence: manifest env < `.env` files < operator `process.env`. |
Expand Down
112 changes: 90 additions & 22 deletions packages/agent-bundle/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { mkdtemp, rm } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { promisify } from 'node:util';

import { capabilityIsSupported } 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';
Expand Down Expand Up @@ -263,10 +263,33 @@ export interface ValidateResult {

export type InspectionSkipReason = 'excluded-by-targets' | 'unsupported-capability';

/** One component the plan silently omits for this target, with the intersection-rule cause. */
export type InspectionComponentKind = 'command' | 'hook' | 'mcp-app' | 'mcp-server' | 'rule' | 'script' | 'skill';

/**
* The target's own four-state judgment of the capability a component needs,
* named so a reader can find the pinned row. Scripts need no host capability
* and carry none.
*/
export type InspectionComponentCapability = CapabilityState & { readonly name: string };

/** One component the plan emits for this target. */
export interface InspectionSelectedComponent {
readonly capability?: InspectionComponentCapability;
readonly id: string;
readonly kind: InspectionComponentKind;
readonly name: string;
}

/**
* One component the plan omits for this target, with the intersection-rule
* cause. `unsupported-capability` carries the host's `degraded`,
* `unavailable`, or `prohibited` judgment and reason; `excluded-by-targets`
* carries the judgment the host would have applied had the author selected it.
*/
export interface InspectionSkippedComponent {
readonly capability?: InspectionComponentCapability;
readonly id: string;
readonly kind: 'command' | 'hook' | 'mcp-app' | 'mcp-server' | 'rule' | 'script' | 'skill';
readonly kind: InspectionComponentKind;
readonly name: string;
readonly reason: InspectionSkipReason;
}
Expand All @@ -275,6 +298,8 @@ export interface InspectionPlan {
readonly diagnostics: readonly Diagnostic[];
readonly entries: readonly TargetArtifactEntry[];
readonly hookEntries: readonly TargetHookEntry[];
/** Components this target emits, in the same deterministic order as `skipped`. */
readonly selected: readonly InspectionSelectedComponent[];
readonly skipped: readonly InspectionSkippedComponent[];
readonly target: string;
}
Expand Down Expand Up @@ -534,7 +559,7 @@ export const validate = async (options: ValidateOptions): Promise<ValidateResult
interface InspectableComponent {
readonly capability?: string;
readonly id: string;
readonly kind: InspectionSkippedComponent['kind'];
readonly kind: InspectionComponentKind;
readonly name: string;
readonly targets: readonly string[];
}
Expand All @@ -549,22 +574,63 @@ const inspectableComponents = (model: NormalizedPlugin): readonly InspectableCom
...model.skills.map((skill) => ({ capability: 'skills', id: skill.id, kind: 'skill' as const, name: skill.name, targets: skill.targets })),
];

const skippedComponentsFor = (
/**
* 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
* the absence reads as an honest `unavailable` rather than a crash or a silent
* pass.
*/
const componentCapabilityFor = (
component: InspectableComponent,
target: string,
capabilities: Readonly<Record<string, CapabilityState>>,
): InspectionComponentCapability | undefined => {
if (component.capability === undefined) return undefined;
const state = capabilities[component.capability];
return Object.freeze({
name: component.capability,
...(state ?? unavailableCapability(
`The ${target} adapter publishes no ${component.capability} capability row.`,
)),
Comment on lines +590 to +594

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 Project only declared capability fields into inspection

When an advanced TargetRegistry uses a JavaScript or third-party adapter whose otherwise-valid CapabilityState has enumerable extension fields, isCapabilityState accepts it but this spread copies those fields into the public inspection. For example, an extension field named name overrides the canonical capability name set above, while a circular extension value makes inspect --json fail during stableJson serialization. Construct the discriminated capability output from its contract fields and assign the canonical name last rather than spreading the adapter-owned object.

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.

Agreed — addressed in follow-up #409 (2d1cd86). componentCapabilityFor now builds the output from the four-state contract fields only (capabilityContract/capabilityEvidenceContract) and assigns the canonical name last. api.test.ts covers adapter rows decorated with name: 'shadow' and a cyclic extension field: inspection reports exactly the contract fields with the canonical name and JSON.stringify succeeds.

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 on main by #409 (62138ef, the #100 follow-up lane): componentCapabilityFor projects only the four-state contract fields (capabilityContract, exhaustive over state) and assigns the canonical name last; api.test.ts covers a decorated row with a name collision and a cyclic value. #408 carried an equivalent projection and dropped it in favour of #409 during rebase.

});
};

interface AccountedComponents {
readonly selected: readonly InspectionSelectedComponent[];
readonly skipped: readonly InspectionSkippedComponent[];
}

/**
* Splits the project's components into the ones this target emits and the
* ones it omits. Author exclusion (`targets`) is reported before the host's
* capability judgment, and every component that needs a capability carries the
* target's four-state judgment so `inspect` explains, not just counts.
*/
const accountComponentsFor = (
components: readonly InspectableComponent[],
target: string,
capabilities: Readonly<Record<string, CapabilityState>>,
): readonly InspectionSkippedComponent[] => Object.freeze(components
.filter((component) =>
!component.targets.includes(target) ||
(component.capability !== undefined && !capabilityIsSupported(capabilities[component.capability])))
.map((component) => Object.freeze({
id: component.id,
kind: component.kind,
name: component.name,
reason: (!component.targets.includes(target)
? 'excluded-by-targets'
: 'unsupported-capability') satisfies InspectionSkipReason,
})));
): AccountedComponents => {
const selected: InspectionSelectedComponent[] = [];
const skipped: InspectionSkippedComponent[] = [];
for (const component of components) {
const capability = componentCapabilityFor(component, target, capabilities);
const identity = {
...(capability === undefined ? {} : { capability }),
id: component.id,
kind: component.kind,
name: component.name,
};
if (!component.targets.includes(target)) {
skipped.push(Object.freeze({ ...identity, reason: 'excluded-by-targets' satisfies InspectionSkipReason }));
} else if (capability !== undefined && !capabilityIsSupported(capability)) {
skipped.push(Object.freeze({ ...identity, reason: 'unsupported-capability' satisfies InspectionSkipReason }));
} else {
selected.push(Object.freeze(identity));
}
}
return { selected: Object.freeze(selected), skipped: Object.freeze(skipped) };
};

const inspectState = (model: NormalizedPlugin): StateInspection => {
const definition = model.state;
Expand Down Expand Up @@ -605,15 +671,17 @@ export const inspect = async (options: InspectOptions): Promise<InspectResult> =
.map((target) => {
const adapter = prepared.registry.get(target.name);
const plan = adapter.plan(model);
const accounted = accountComponentsFor(
components,
target.name,
adapter.componentCapabilities ?? adapter.capabilities,
);
return Object.freeze({
diagnostics: freezeDiagnostics(plan.diagnostics),
entries: Object.freeze([...plan.entries]),
hookEntries: Object.freeze([...(plan.hookEntries ?? [])]),
skipped: skippedComponentsFor(
components,
target.name,
adapter.componentCapabilities ?? adapter.capabilities,
),
selected: accounted.selected,
skipped: accounted.skipped,
target: target.name,
});
}));
Expand Down
37 changes: 37 additions & 0 deletions packages/agent-bundle/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
runEvals,
startDevServer,
validate,
InspectionSkippedComponent,
ProjectOptions,
} from './api.ts';
import type {
Expand Down Expand Up @@ -363,6 +364,42 @@ const writeHumanInspect = (output: Output, result: Awaited<ReturnType<typeof ins
const driver = result.model.state.lifetime === 'workspace-durable' ? 'sqlite' : 'memory';
output.write(`state: ${result.model.state.id} (${result.model.state.lifetime}, ${driver} driver)\n`);
}
// Per-target component accounting: what each host emits and, for every
// omission, whether the author excluded it or the host's pinned capability
// judgment (degraded/unavailable/prohibited, with its reason) ruled it out.
for (const plan of result.plans) {
output.write(`${plan.target}: ${plan.selected.length} component(s) selected, ${plan.skipped.length} omitted\n`);
for (const component of plan.skipped) {
output.write(` omitted ${component.kind} ${component.name}: ${formatInspectionOmission(component)}\n`);
}
}
};

const formatInspectionOmission = (component: InspectionSkippedComponent): string => {
switch (component.reason) {
case 'excluded-by-targets':
return 'excluded by targets';
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)}.`);
}
}
}
default: {
const exhaustive: never = component.reason;
throw new TypeError(`Unhandled inspection skip reason ${String(exhaustive)}.`);
}
}
};

const emptyEvalSummary = Object.freeze({ cases: 0, fail: 0, inconclusive: 0, pass: 0, trials: 0 });
Expand Down
Loading
Loading