From 7c8ce5cbb755822bf744e719cf191053f3d45d63 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 05:52:38 +0000 Subject: [PATCH 1/2] feat(inspect): explain selected and omitted host components per target (#100) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inspection plans now list selected components beside skipped ones, and every component that needs a host capability carries the target's own four-state judgment — supported with pinned evidence, or degraded/unavailable/prohibited with the host's reason — so inspect explains omissions in the host's words. An adapter with no row for a needed capability reads as an honest unavailable. Human inspect output prints one accounting line per target and each omission with its reason. --- .changeset/inspect-component-accounting.md | 13 +++ docs/entry-conventions.md | 22 ++++ packages/agent-bundle/README.md | 2 +- packages/agent-bundle/src/api.ts | 112 +++++++++++++++++---- packages/agent-bundle/src/cli.ts | 37 +++++++ packages/agent-bundle/tests/api.test.ts | 51 +++++++++- packages/agent-bundle/tests/cli.test.ts | 40 ++++++++ 7 files changed, 253 insertions(+), 24 deletions(-) create mode 100644 .changeset/inspect-component-accounting.md diff --git a/.changeset/inspect-component-accounting.md b/.changeset/inspect-component-accounting.md new file mode 100644 index 000000000..a8c1046c9 --- /dev/null +++ b/.changeset/inspect-component-accounting.md @@ -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. diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index ddad32882..7d76c507c 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -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 ] [--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 (`: N component(s) selected, M omitted`) followed by +each omission and its reason. + ### `agent-bundle inspect --bundler` ```sh diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index 0ec8fa3a6..935b9aa74 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -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 ` | 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`. | diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index 335b273d8..18bf05234 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 { 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'; @@ -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; } @@ -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; } @@ -534,7 +559,7 @@ export const validate = async (options: ValidateOptions): Promise ({ 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>, +): 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.`, + )), + }); +}; + +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>, -): 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; @@ -605,15 +671,17 @@ export const inspect = async (options: InspectOptions): Promise = .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, }); })); diff --git a/packages/agent-bundle/src/cli.ts b/packages/agent-bundle/src/cli.ts index bf2e546de..af7b1225e 100644 --- a/packages/agent-bundle/src/cli.ts +++ b/packages/agent-bundle/src/cli.ts @@ -17,6 +17,7 @@ import type { runEvals, startDevServer, validate, + InspectionSkippedComponent, ProjectOptions, } from './api.ts'; import type { @@ -363,6 +364,42 @@ const writeHumanInspect = (output: Output, result: Awaited { + 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 }); diff --git a/packages/agent-bundle/tests/api.test.ts b/packages/agent-bundle/tests/api.test.ts index 0f799d882..b5d0ed426 100644 --- a/packages/agent-bundle/tests/api.test.ts +++ b/packages/agent-bundle/tests/api.test.ts @@ -156,9 +156,21 @@ it('prepares and inspects a target owned only by the supplied advanced registry' synthetic: expect.objectContaining({ target: 'synthetic', value: { enabled: true } }), }); expect(result.plans).toEqual([expect.objectContaining({ target: 'synthetic' })]); + // An adapter that publishes no row for a capability has not evidenced it: + // the omission reads as an honest unavailable judgment, not a crash. expect(result.plans[0]?.skipped).toEqual([ - expect.objectContaining({ kind: 'skill', name: 'review', reason: 'unsupported-capability' }), + expect.objectContaining({ + capability: { + name: 'skills', + reason: 'The synthetic adapter publishes no skills capability row.', + state: 'unavailable', + }, + kind: 'skill', + name: 'review', + reason: 'unsupported-capability', + }), ]); + expect(result.plans[0]?.selected).toEqual([]); expect(registry.names()).toEqual(['synthetic']); } finally { await rm(join(root, '..'), { force: true, recursive: true }); @@ -689,6 +701,43 @@ it('reports skipped target/component pairs against each target emission surface' expect.objectContaining({ kind: 'rule', name: 'shared', reason: 'unsupported-capability' }), expect.objectContaining({ kind: 'script', name: 'report', reason: 'excluded-by-targets' }), ]); + // Every omission explains itself with the host's own pinned judgment: + // capability-driven omissions carry the four-state row and its reason, + // author exclusions carry the judgment the host would have applied, and + // scripts (which need no host capability) carry none. + const portableSkipped = planFor('portable')!.skipped; + expect(portableSkipped.find((component) => component.kind === 'command' && component.name === 'shared')?.capability) + .toEqual({ name: 'commands', reason: expect.any(String), state: 'unavailable' }); + expect(portableSkipped.find((component) => component.kind === 'rule' && component.name === 'shared')?.capability) + .toEqual({ name: 'rules', reason: expect.any(String), state: 'unavailable' }); + expect(portableSkipped.find((component) => component.kind === 'hook')?.capability) + .toEqual({ name: 'hooks', reason: expect.any(String), state: 'unavailable' }); + expect(portableSkipped.find((component) => component.kind === 'script')).not.toHaveProperty('capability'); + expect(planFor('portable')?.selected).toEqual([ + expect.objectContaining({ capability: expect.objectContaining({ name: 'skills', state: 'supported' }), kind: 'skill', name: 'review' }), + ]); + // Cursor emits both surfaces with dated evidence; Claude emits commands but no rules. + expect(planFor('cursor')?.selected).toEqual(expect.arrayContaining([ + expect.objectContaining({ + capability: expect.objectContaining({ evidence: expect.objectContaining({ target: 'cursor' }), name: 'commands', state: 'supported' }), + kind: 'command', + name: 'shared', + }), + expect.objectContaining({ + capability: expect.objectContaining({ evidence: expect.objectContaining({ target: 'cursor' }), name: 'rules', state: 'supported' }), + kind: 'rule', + name: 'cursor-only', + }), + ])); + expect(planFor('claude')?.selected.some((component) => component.kind === 'command' && component.name === 'shared')).toBe(true); + expect(planFor('claude')?.skipped.find((component) => component.kind === 'rule' && component.name === 'shared')?.capability) + .toEqual({ name: 'rules', reason: expect.any(String), state: 'unavailable' }); + for (const plan of result.plans) { + expect(Object.isFrozen(plan.selected)).toBe(true); + expect(plan.selected.length + plan.skipped.length).toBe( + planFor('cursor')!.selected.length + planFor('cursor')!.skipped.length, + ); + } expect(planFor('codex')?.skipped).toEqual([ expect.objectContaining({ kind: 'command', name: 'cursor-only', reason: 'excluded-by-targets' }), expect.objectContaining({ kind: 'command', name: 'shared', reason: 'unsupported-capability' }), diff --git a/packages/agent-bundle/tests/cli.test.ts b/packages/agent-bundle/tests/cli.test.ts index 87ba053ff..5dea84d4e 100644 --- a/packages/agent-bundle/tests/cli.test.ts +++ b/packages/agent-bundle/tests/cli.test.ts @@ -451,6 +451,46 @@ it('prints a complete invalid inspection on JSON and human output', async () => } }, 30_000 * timeScale); +it('explains selected and omitted components per target on human inspect output', async () => { + const project = await createCliProject(); + try { + // A conventional rule is a Cursor-only surface: portable and codex omit it + // with their pinned capability judgment, while the skill ships everywhere. + await mkdir(join(project.root, 'src', 'rules'), { recursive: true }); + await writeFile( + join(project.root, 'src', 'rules', 'shared.mdc'), + '---\ndescription: Shared rule\n---\nShared guidance.\n', + ); + + const human = await runSourceCliWithOutput(['inspect', '--root', project.root]); + expect(human).toMatchObject({ code: 0, stderr: '' }); + expect(human.stdout).toContain('Inspected cli-fixture: portable, codex\n'); + expect(human.stdout).toContain('portable: 1 component(s) selected, 1 omitted\n'); + 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'); + + // The JSON form carries the same accounting with the full judgment. + const json = await runSourceCliWithOutput(['inspect', '--root', project.root, '--target', 'codex', '--json']); + expect(json).toMatchObject({ code: 0, stderr: '' }); + expect(JSON.parse(json.stdout)).toMatchObject({ + plans: [{ + selected: [expect.objectContaining({ capability: expect.objectContaining({ name: 'skills', state: 'supported' }), kind: 'skill', name: 'review' })], + skipped: [expect.objectContaining({ + capability: { name: 'rules', reason: expect.any(String), state: 'unavailable' }, + kind: 'rule', + name: 'shared', + reason: 'unsupported-capability', + })], + target: 'codex', + }], + state: 'ready', + }); + } finally { + await rm(resolve(project.root, '..'), { force: true, recursive: true }); + } +}, 30_000 * timeScale); + it('reports an unselected inspect target on JSON and human output', async () => { const project = await createCliProject(); try { From 2496018418ed6f5b292eaec709d524ef16ccdf44 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 05:55:24 +0000 Subject: [PATCH 2/2] docs(diagnostics): document the rule and command component codes AB4900-AB4906, AB4920-AB4926 (#100) --- docs/diagnostics.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 05d8f90d0..17535049e 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -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. | @@ -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