Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/small-static-plugins.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'agent-bundle': patch
---

Keep static and plain-Hook plugins free of undeclared MCP and Hook surfaces, and expose rules and commands directly in the Workbench (#658).
4 changes: 4 additions & 0 deletions examples/hooks-and-scripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ emitted only when the build selects `portable`, into the shared `scripts/` of
the one plugin root every selected host installs — so the example keeps both
modes covered.

The plain Hook imports the application-owned `releaseContext` function from
`src/release-context.ts`; the emitted wrapper bundles it without starting an MCP
service or render worker.

## Workbench walkthrough

1. The shell header reports the authoritative current-or-stale epoch state and
Expand Down
12 changes: 7 additions & 5 deletions examples/hooks-and-scripts/src/hooks/session-start.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import type { HookHandler } from 'agent-bundle';

import { releaseContext } from '../release-context.ts';

export default ((event) => ({
additionalContext: [
`This release preparation session is active for ${event.sessionId} from ${event.source ?? 'an unknown source'}.`,
`Run verify-release from ${event.cwd ?? process.cwd()} to confirm the manifest is ready for packaging.`,
'Run detect-risk to surface open high-severity release blockers before publishing.',
].join(' '),
additionalContext: releaseContext(
event.sessionId,
event.cwd ?? process.cwd(),
event.source ?? 'an unknown source',
),
outcome: 'continue',
})) satisfies HookHandler<'sessionStart'>;
9 changes: 9 additions & 0 deletions examples/hooks-and-scripts/src/release-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export const releaseContext = (
sessionId: string,
cwd: string,
source: string,
): string => [
`This release preparation session is active for ${sessionId} from ${source}.`,
`Run verify-release from ${cwd} to confirm the manifest is ready for packaging.`,
'Run detect-risk to surface open high-severity release blockers before publishing.',
].join(' ');
15 changes: 9 additions & 6 deletions examples/skills-starter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ required. Both eval suites are deterministic and read only checked-in fixtures.
## What is authored

- `agent-bundle.config.ts` declares the plugin and its portable, Codex, and
Claude targets. The Skills are not listed there: every `src/skills/*/SKILL.md`
directory is discovered automatically by convention, the model described in
Cursor targets. Skills, commands, and rules under `src/` are discovered
automatically by convention, the model described in
[`docs/framework-mode.md`](../../docs/framework-mode.md).
- `src/skills/incident-triage/SKILL.md` guides a production incident from first
signal through containment, evidence collection, and a handoff-ready update.
Expand All @@ -24,6 +24,8 @@ required. Both eval suites are deterministic and read only checked-in fixtures.
and final-report requirements for an explicit release review.
- Each Skill links its own `references/` checklist or runbook and reusable
`assets/` handoff or planning template.
- `src/commands/review-release.md` and `src/rules/release-safety.mdc` provide
host-native static content without an MCP server or executable renderer.
- `evals/release-readiness.eval.ts` defines the deterministic
`release-artifact-is-ready` case and its checked-in evidence fixture.
- `evals/engineering-operations.eval.ts` directly exercises the incident and
Expand All @@ -39,12 +41,13 @@ required. Both eval suites are deterministic and read only checked-in fixtures.
Skill shows its deterministic outcome-eval coverage;
it is labeled indirect because the deterministic harness cannot observe host
Skill activation.
3. **Advanced → Artifact** defaults to the Claude target. Change the target to compare
the portable, Codex, and Claude output trees and their provenance.
4. **Advanced → Evals → Runs** defaults to the `release-readiness` suite. Run its deterministic
3. Under **Application → Rules / Commands**, open authored and generated
content directly. Unsupported hosts show their capability reason; nothing runs.
4. **Advanced → Artifact** shows each target's output tree and provenance.
5. **Advanced → Evals → Runs** defaults to the `release-readiness` suite. Run its deterministic
`release-artifact-is-ready` case and inspect the passing trial. It consumes
only the checked-in evidence fixture, so no model login or API key is needed.
5. To practice repair, make a reversible policy edit, press **Rebuild**, and
6. To practice repair, make a reversible policy edit, press **Rebuild**, and
wait for the failed or idle result rather than a Building state. Restore the
checked-in policy and rebuild. The prior eval becomes stale for the changed
build; rerun `release-readiness` to record current, repaired evidence.
Expand Down
2 changes: 1 addition & 1 deletion examples/skills-starter/agent-bundle.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@ export default defineConfig({
name: 'skills-starter',
version: '1.0.0',
},
targets: ['portable', 'codex', 'claude'],
targets: ['portable', 'codex', 'cursor'],
});
7 changes: 7 additions & 0 deletions examples/skills-starter/src/commands/review-release.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
targets:
- cursor
---
Review the current release evidence with the `release-review` Skill.

Report missing checks and do not approve a release with unresolved blockers.
7 changes: 7 additions & 0 deletions examples/skills-starter/src/rules/release-safety.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
description: Keep release reviews evidence-based
alwaysApply: true
---
Use the release checklist before approving a package.

State unresolved blockers explicitly and never infer missing evidence.
12 changes: 9 additions & 3 deletions packages/agent-bundle/src/adapters/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ import {
type TargetArtifactPlan,
} from './types.ts';
import { pluginLogoManifestRef, withPluginLogoEntry } from './plugin-logo.ts';
import { folderDiscoveryShadowed, hookWrapperPath } from './composite-layout.ts';
import { hookWrapperPath } from './composite-layout.ts';
import { deepFreeze } from '../core/freeze.ts';

export interface CodexInterfaceConfig {
Expand Down Expand Up @@ -1121,6 +1121,12 @@ export const planCodexArtifacts = (model: NormalizedPlugin): TargetArtifactPlan
const mcpRelativePath = mcpRuntime.manifestPath;
const planContract = planHookContract(selected);
const isSelected = (targets: readonly string[]): boolean => targets.includes(targetName);
const claudeHooks = selected.includes('claude') && (
model.hooks.some((hook) => hook.targets.includes('claude'))
|| nativeHooksFor(model, 'claude')?.document !== undefined
);
const claudeMcp = selected.includes('claude')
&& model.mcpServers.some((server) => server.targets.includes('claude'));
const diagnostics: Diagnostic[] = [];
const servers: Record<string, Record<string, unknown>> = Object.create(null) as Record<string, Record<string, unknown>>;
for (const server of model.mcpServers) {
Expand All @@ -1133,7 +1139,7 @@ export const planCodexArtifacts = (model: NormalizedPlugin): TargetArtifactPlan
// An empty document still carries the manifest pointer when Claude's
// conventional `.mcp.json` shares the root, so Codex never loads it (#555).
const mcp = Object.keys(servers).length === 0
? (folderDiscoveryShadowed('.mcp.json', selected) ? { mcpServers: {} } : undefined)
? (claudeMcp ? { mcpServers: {} } : undefined)
: { mcpServers: servers };
const mcpValid = mcp !== undefined && validateMcp(mcp);
if (mcp !== undefined) diagnostics.push(...schemaDiagnostics('mcp', mcpValid, validateMcp.errors));
Expand All @@ -1156,7 +1162,7 @@ export const planCodexArtifacts = (model: NormalizedPlugin): TargetArtifactPlan
diagnostics.push(...nativeHooks.diagnostics);
// Likewise an empty hooks document keeps Codex off Claude's `hooks/hooks.json`.
const hookDocument = mergeHookDocuments(generatedHooks.document, nativeHooks.document)
?? (folderDiscoveryShadowed('hooks/hooks.json', selected) ? emptyHookDocument(planContract) : undefined);
?? (claudeHooks ? emptyHookDocument(planContract) : undefined);
const hookSemantics = hookDocument === undefined ? [] : codexHookDocumentDiagnostics(hookDocument);
diagnostics.push(...hookSemantics);
const hookDocumentValid = hookDocument !== undefined && hookSemantics.length === 0 && validateHooks(hookDocument);
Expand Down
30 changes: 2 additions & 28 deletions packages/agent-bundle/src/adapters/composite-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,8 @@
* beside their manifests (`.codex-plugin/hooks.json`, `.cursor-plugin/mcp.json`,
* …). Each adapter owns its constants. Both hosts also fall back to folder
* discovery of the conventional paths when the pointer is absent, so a
* projection with no document of its own still points at an empty one
* whenever a selected host claims the conventional path
* (`folderDiscoveryShadowed`).
* projection with no document of its own still points at an empty one when
* another selected host actually emits the conventional document.
* - **Hook wrappers** bake the host they were planned for (its codec, its
* `target`, its host contract revision), so a hook that reaches several
* selected hosts compiles one wrapper per host, `hooks/<name>.<host>.mjs`;
Expand Down Expand Up @@ -63,28 +62,3 @@ export const hookWrapperPath = (
const reached = hookTargets.filter((target) => selection.has(target));
return reached.length > 1 ? `hooks/${hookName}.${host}.mjs` : `hooks/${hookName}.mjs`;
};

/**
* The plugin-root documents Codex and Cursor load by folder discovery when
* their manifest carries no pointer, and the selected hosts whose projection
* writes one there. `hooks/hooks.json` and `.mcp.json` are Claude Code's;
* `mcp.json` is the portable format's. Cursor documents the fallback for both
* of its defaults and Codex for `hooks/hooks.json`; Codex's behaviour without
* an `mcpServers` pointer is not pinned, and an explicit empty pointer costs
* nothing, so it is shielded the same way.
*/
const folderDiscoveryClaimants: Readonly<Record<string, readonly string[]>> = Object.freeze({
'.mcp.json': Object.freeze(['claude']),
'hooks/hooks.json': Object.freeze(['claude']),
'mcp.json': Object.freeze(['portable']),
});

/**
* True when a selected host writes the conventional document at
* `defaultPath`, so a host that would otherwise fall back to folder
* discovery there must point its manifest at a document of its own.
*/
export const folderDiscoveryShadowed = (defaultPath: string, selected: Iterable<string>): boolean => {
const selection = new Set(selected);
return (folderDiscoveryClaimants[defaultPath] ?? []).some((host) => selection.has(host));
};
13 changes: 10 additions & 3 deletions packages/agent-bundle/src/adapters/cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
emptyHookDocument,
encodeCursorPlaygroundInput,
encodeCursorPlaygroundOutput,
nativeHooksFor,
planHooks,
readCursorNativeHookCommands,
type TargetHookContract,
Expand All @@ -60,7 +61,7 @@ import {
type TargetArtifactPlan,
} from './types.ts';
import { pluginLogoManifestRef, withPluginLogoEntry } from './plugin-logo.ts';
import { folderDiscoveryShadowed, hookWrapperPath } from './composite-layout.ts';
import { hookWrapperPath } from './composite-layout.ts';

const cursorName = 'cursor';

Expand Down Expand Up @@ -581,6 +582,12 @@ export const planCursorArtifacts = (model: NormalizedPlugin): TargetArtifactPlan
const mcpRelativePath = mcpRuntime.manifestPath;
const selectedCommands = (model.commands ?? []).filter((command) => isSelected(command.targets));
const selectedRules = (model.rules ?? []).filter((rule) => isSelected(rule.targets));
const claudeHooks = selected.includes('claude') && (
model.hooks.some((hook) => hook.targets.includes('claude'))
|| nativeHooksFor(model, 'claude')?.document !== undefined
);
const portableMcp = selected.includes('portable')
&& model.mcpServers.some((server) => server.targets.includes('portable'));
const diagnostics: Diagnostic[] = [];
if (!isValidCursorPluginName(model.metadata.name)) {
diagnostics.push(errorDiagnostic('cursor.name', cursorPluginNameError(model.metadata.name)));
Expand All @@ -596,15 +603,15 @@ export const planCursorArtifacts = (model: NormalizedPlugin): TargetArtifactPlan
// document is still emitted when the portable `mcp.json` or Claude's
// `hooks/hooks.json` shares the root; Cursor never loads another host's (#555).
const mcp = Object.keys(servers).length === 0
? (folderDiscoveryShadowed('mcp.json', selected) ? { mcpServers: {} } : undefined)
? (portableMcp ? { mcpServers: {} } : undefined)
: { mcpServers: servers };
const mcpValid = mcp !== undefined && validateMcp(mcp);
if (mcp !== undefined) diagnostics.push(...schemaDiagnostics('mcp', mcpValid, validateMcp.errors));

const generatedHooks = planHooks(model, cursorName, planContract);
diagnostics.push(...generatedHooks.diagnostics);
const hookDocument = generatedHooks.document
?? (folderDiscoveryShadowed('hooks/hooks.json', selected) ? emptyHookDocument(planContract) : undefined);
?? (claudeHooks ? emptyHookDocument(planContract) : undefined);
const hookDocumentValid = hookDocument !== undefined && validateHooks(hookDocument);
if (hookDocument !== undefined) diagnostics.push(...schemaDiagnostics('hooks', hookDocumentValid, validateHooks.errors));

Expand Down
3 changes: 3 additions & 0 deletions packages/agent-bundle/src/contracts/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@
*/
export type {
ServedSkillDocument,
ServedStaticDocument,
SkillDocumentBase,
SkillDocumentResource,
SkillDocumentTree,
StaticDocumentKind,
StaticDocumentProjection,
} from '../dev/skill-document-service.ts';
19 changes: 19 additions & 0 deletions packages/agent-bundle/src/dev/routes/application-tree.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Diagnostic } from '../../core/diagnostics.ts';
import type { RouteInputSchema } from '../../routes/types.ts';
import type { ServedStaticDocument } from '../skill-document-service.ts';
import type {
RouteManifest,
RouteManifestCliCommand,
Expand All @@ -22,6 +23,7 @@ export interface ApplicationLeaf {
readonly command?: RouteManifestCliCommand;
readonly config: readonly RouteManifestConfigEntry[];
readonly description?: string;
readonly document?: ServedStaticDocument;
readonly event?: string;
readonly execution: ApplicationLeafExecution;
readonly inputSchema?: RouteInputSchema;
Expand Down Expand Up @@ -107,6 +109,7 @@ export interface ApplicationTreeManifestSources {
readonly manifest?: RouteManifest;
readonly message?: string;
readonly skills?: readonly ApplicationTreeSkill[];
readonly staticDocuments?: readonly ServedStaticDocument[];
readonly state: ApplicationTreeState;
}

Expand Down Expand Up @@ -304,6 +307,21 @@ const skillLeaves = (skills: readonly ApplicationTreeSkill[]): readonly Applicat
});
}));

const staticDocumentLeaves = (
documents: readonly ServedStaticDocument[],
): readonly ApplicationLeaf[] => Object.freeze(documents.map((document) => {
const ref = Object.freeze({ id: document.id, kind: document.kind });
return Object.freeze({
config: Object.freeze([]),
document,
execution: 'document' as const,
key: applicationNodeKey(ref),
label: document.name,
ref,
source: document.provenance.sourcePath,
});
}));

export const applicationLeaves = (tree: ApplicationTree): readonly ApplicationLeaf[] => Object.freeze(
tree.groups.flatMap((group) => group.kind === 'mcp'
? group.servers.flatMap((server) => server.subgroups.flatMap((subgroup) => subgroup.leaves))
Expand Down Expand Up @@ -331,6 +349,7 @@ export const applicationTreeForManifest = (
projectGroup('cli', 'CLI', routeCli),
projectGroup('scripts', 'Scripts', [...routeScripts, ...configuredScriptLeaves(sources.inspection, existing)]),
projectGroup('skills', 'Skills', skillLeaves(sources.skills ?? [])),
projectGroup('rules', 'Rules / Commands', staticDocumentLeaves(sources.staticDocuments ?? [])),
].filter((group): group is ApplicationGroup => group !== undefined);
const provisional: ApplicationTree = Object.freeze({
diagnostics: Object.freeze([...(manifest?.diagnostics ?? [])]),
Expand Down
Loading
Loading